Amadren/lgogd_uri-mk.II

View on GitHub
lgogd_uri/main.py

Summary

Maintainability
C
1 day
Test Coverage
#!/usr/bin/env python2
# -*- coding: utf-8 -*-
"""Wrapper for lgogdownloader to support queueing up gogdownloader:// URIs
Copyright (C) 2015 Stephan Sokolow

--snip--

Thanks to Luca "Lethalman" Bruno for the code to implement a single-instance
application via D-Bus.
http://lethalman.blogspot.ca/2008/11/single-app-instances-python-and-dbus.html

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""

from __future__ import (absolute_import, division, print_function,
                        with_statement, unicode_literals)

__author__ = "Stephan Sokolow (deitarion/SSokolow) & Amadren"
__appname__ = "URI handler for lgogdownloader"
__version__ = "MK II"
__license__ = "MIT"

SVC_NAME = "com.ssokolow.lgogd_uri"
LGOGD_CFG_PATH = "~/.config/lgogdownloader/config.cfg"
GOGD_URI_CFG_PATH = "~/.config/lgogd_uri"
PLAT_WIN = 1
PLAT_MAC = 2
PLAT_LIN = 4
PLAT_DEF = 4  # Default
PLAT_ALL = 7
SUBDIR_GAME = '%gamename%'
SUBDIR_EXTRAS = 'extras'

import logging, os, subprocess, sys, time
from xml.etree import cElementTree as ET  # NOQA
log = logging.getLogger(__name__)

# Resolve the path to bundled icons only once
RES_DIR = os.path.dirname(os.path.realpath(__file__))

# ---=== Begin Imports ===---

try:
    import pygtk
    pygtk.require("2.0")
except ImportError:
    pass  # Apparently some PyGTK installs are missing this but still work

try:
    import gtk, gtk.gdk  # pylint: disable=import-error
except ImportError:
    sys.stderr.write("Missing PyGTK! Exiting.\n")
    sys.exit(1)

# Present tracebacks as non-fatal errors in the GUI for more user-friendliness
# TODO: In concert with this, I'll probably want some kind of failsafe
#       for re-enabling the Save button if necessary.
from lgogd_uri import gtkexcepthook
gtkexcepthook.enable()

try:
    import dbus, dbus.bus, dbus.service, dbus.mainloop.glib
except ImportError:
    sys.stderr.write("Missing dbus-python! Exiting.\n")
    sys.exit(1)

try:
    import notify2 as notify
except ImportError:
    try:
        import pynotify as notify
    except ImportError:
        log.warning("Missing libnotify bindings! Notifications will not show.")
        notify = None

try:
    import vte
except ImportError:
    vte = None

# ---=== Begin Functions ===---

def get_lgogd_conf():
    """Read and parse lgogdownloader's config file."""
    results = {}
    path = os.path.expanduser(LGOGD_CFG_PATH)
    if not os.path.exists(path):
        log.error("Cannot find LGOGDownloader config file at %s. "
                  "Falling back to internal defaults.", path)
        return results

    with open(os.path.expanduser(LGOGD_CFG_PATH)) as fobj:
        for line in fobj:
            try:
                key, val = [i.strip() for i in line.strip().split('=', 1)]
            except ValueError:
                continue  # Fail safely if a line doesn't contain =

            if val.lower() == 'true':
                val = True
            elif val.lower() == 'false':
                key = False
            else:
                try:
                    val = int(val)
                except ValueError:
                    pass

            results[key] = val
    return results

def parse_uri(uri):
    """Parse a gogdownloader:// URI into a list of (game,file_id) tuples"""
    scheme_prefix = 'gogdownloader://'

    if not uri.lower().startswith(scheme_prefix):
        raise ValueError("Not a valid GOGDownloader URI: %s" % uri)

    results = []
    for filepath in uri[len(scheme_prefix):].strip().split(','):
        filepath = filepath.strip()
        if '/' not in filepath:
            filepath += '/'
        results.append(tuple(filepath.split('/', 1)))
    return results

def which(execname, execpath=None):
    """Like the UNIX which command, this function attempts to find the given
    executable in the system's search path. Returns C{None} if it cannot find
    anything.
    """

    if isinstance(execpath, basestring):
        execpath = execpath.split(os.pathsep)
    elif not execpath:
        execpath = os.environ.get('PATH', os.defpath).split(os.pathsep)

    for path in execpath:
        fullpath = os.path.join(os.path.expanduser(path), execname)
        if os.path.exists(fullpath):
            return fullpath
    return None  # Couldn't find anything.

# ---=== Begin Application Class ===---

class Application(dbus.service.Object):  # pylint: disable=C0111,R0902
    def __init__(self, bus, path, name):
        dbus.service.Object.__init__(self, bus, path, name)
        self.running = False

        # Shut up PyLint about defining members in _init_gui
        self.builder = gtk.Builder()
        self.data = None
        self.toggle_map = {}
        self.lgd_conf = None
        self.notification = None
        self.term = None
        self.save_dir_store = os.path.expanduser(
            os.path.join(GOGD_URI_CFG_PATH, 'save_dir'))

    def _check_deps(self):  # pylint: disable=no-self-use
        """Return a message describing missing dependencies or C{None}."""
        if not vte:
            return "Missing python-vte! Exiting."
        if not which("lgogdownloader"):
            return "Cannot find lgogdownloader in $PATH! Exiting."

    def _init_gui(self):
        """Parts of __init__ that should only run in the single instance."""
        # Check for some deps late enough to display a GUI error message
        dep_err = self._check_deps()
        if dep_err:
            dlg = gtk.MessageDialog(None, gtk.DIALOG_MODAL,
                                    gtk.MESSAGE_ERROR, gtk.BUTTONS_OK, dep_err)
            dlg.set_title("GOG Downloader")
            dlg.run()
            sys.exit(1)

        self.gtkbuilder_load('lgogd_uri.glade')
        self.data = self.builder.get_object('store_dlqueue')

        # Load the lgogdownloader settings
        self.lgd_conf = get_lgogd_conf()

        # Prepare a libnotify notification we can reuse
        if notify and notify.init("lgogd_uri"):
            self.notification = notify.Notification("GOG Downloads Complete",
                                                    icon='document-save')

        try:
            parent = os.path.dirname(self.save_dir_store)
            if not os.path.exists(parent):
                os.makedirs(parent)
        except OSError, err:
            log.error("Cannot prepare to remember target directory: %s", err)

        # Set the default target directory to the user's Downloads folder
        try:
            tgt_path = subprocess.check_output(
                ["xdg-user-dir", "DOWNLOAD"]).strip()
        except (OSError, subprocess.CalledProcessError):
            tgt_path = os.path.expanduser("~")
        # Remember the user's customized choice
        if os.path.exists(self.save_dir_store):
            with open(self.save_dir_store, 'r') as fobj:
                tgt_path = fobj.read().strip()
        self.builder.get_object('btn_target').set_filename(tgt_path)

        # FIXME: Figure out how to preserve multi-select during drag-start
        tview = self.builder.get_object("view_dlqueue")
        tview.get_selection().set_mode(gtk.SELECTION_MULTIPLE)

        # VTE widgets aren't offered by Glade. Add and config at runtime.
        self.term = vte.Terminal()
        self.term.connect("child-exited", self.on_child_exited)
        self.builder.get_object("vbox_term").add(self.term)

        mainwin = self.builder.get_object('mainwin')
        mainwin.set_title('%s %s' % (mainwin.get_title(), __version__))
        mainwin.show_all()

        # Fix for libvte resize bug
        # See https://github.com/ssokolow/lgogd_uri/issues/4
        self.term.set_size_request(0, 0)

    def gtkbuilder_load(self, path):
        """Wrapper to work around the brokenness of
        GtkCellRendererToggle.set_active() and the inability to retrieve
        attributes from columns once set.
        """
        path = os.path.join(RES_DIR, path)
        self.builder.add_from_file(os.path.join(RES_DIR, path))
        self.builder.connect_signals(self)

        # Retrieve the view-store mappings from the raw GTK Builder XML
        # because PyGTK lacks an API for retrieving them from the widgets.
        columns = ET.parse(path).findall(".//*[@class='GtkTreeViewColumn']")
        for column in columns:
            renderer = column.find(".//*[@class='GtkCellRendererToggle']")
            # ElementTree doesn't implement parent traversal or we could find
            # this and then walk up
            if renderer:
                obj = self.builder.get_object(renderer.get('id'))
                idx = column.find(".//attributes/attribute[@name='active']")
                if idx is not None:
                    self.toggle_map[obj] = int(idx.text)

    @dbus.service.method(SVC_NAME, in_signature='', out_signature='b')
    def is_running(self):
        """D-Bus method required to implement startup notification."""
        return self.running

    def enqueue_uris(self, arguments):
        """Code common to launch and raise sides of single instancing."""
        platforms = self.lgd_conf.get('platform', PLAT_DEF)

        iter_last = None
        for arg in arguments:
            for game_id, file_id in parse_uri(arg):
                is_patch = 'patch' in file_id

                # is_installer triggers the UI and code for allowing Win/Mac
                # links to result in Linux downloads, so patches are also
                # installers for our purposes.
                is_installer = ('installer' in file_id) or is_patch

                # Used by cell visibility binding
                not_installer = is_patch or (not is_installer)

                iter_last = self.data.append((
                    game_id,
                    file_id,
                    is_installer,
                    PLAT_WIN,
                    PLAT_LIN,
                    PLAT_MAC,
                    not_installer,
                    is_patch))

        # Ensure that all added entries are made visible unless the window
        # isn't tall enough.
        if iter_last:
            path_last = self.data.get_path(iter_last)
            self.builder.get_object('view_dlqueue').scroll_to_cell(path_last)

    def gtk_main_quit(self, widget, event):  # pylint: disable=R0201,W0613
        """Helper for Builder.connect_signals"""
        gtk.main_quit()

    def next_download(self):
        """Common code for the Save button and lgogdownloader exit handler"""
        queue_iter = self.data.get_iter_first()
        if not queue_iter:
            # Do this as early as possible to minimize the chance that
            # an exception caught by gtkexcepthook.py will leave btn_go grayed
            self.builder.get_object('btn_go').set_sensitive(True)
            self.term.feed("\r\n** Done. (Queue emptied) **\r\n")
            try:
                self.notification.show()
            except dbus.DBusException, err:
                log.error("Could not show notification: %s", err)
            return

        no_subdirs = self.lgd_conf.get('no-subdirectories', False)

        # TODO: Rather than popping it off the store, use a status column
        #       so that users can easily see and retry failed downloads.
        (game_id, file_id, is_inst, win, lin, mac, is_patch
         ) = self.data.get(queue_iter, 0, 1, 2, 3, 4, 5, 7)
        self.data.remove(queue_iter)

        subdirs = {}
        for key, default in (
                ('subdir-game', SUBDIR_GAME),
                ('subdir-extras', SUBDIR_EXTRAS)):
            subdirs[key] = self.lgd_conf.get(key, default)

            # Limited support for --subdir-game and --subdir-extras
            subdirs[key] = subdirs[key].replace('%gamename%', game_id)

        tgt = self.builder.get_object('btn_target').get_filename()
        cmd = ['lgogdownloader']
        if is_inst:
            self.term.feed("\rRetrieving your shelf. This may take time.\r\n")
       #     if is_patch:
        #        cmd.extend(['--platform',
         #                   str((win * PLAT_WIN) +
          #                     (lin * PLAT_LIN) +
           #                    (mac * PLAT_MAC)),
            #                '--download', '--no-installers',
             #               '--game', '^%s$' % game_id])
            #else:
            cmd.extend(['--platform',
                 str((win * PLAT_WIN) +
                 (lin * PLAT_LIN) +
                 (mac * PLAT_MAC)),
                 '--download',
                 '--game',  '^%s$' % game_id, ' --exclude extras'])
        else:
            do_fix = self.builder.get_object("chk_path_fixup").get_active()
            if do_fix and not no_subdirs:
                tgt = os.path.join(tgt,
                                   subdirs['subdir-game'],
                                   subdirs['subdir-extras'])
                if not os.path.exists(tgt):
                    # TODO: Decide how to handle failure here gracefully
                    os.makedirs(tgt)

            path = "%s/%s" % (game_id, file_id)
            cmd.extend(['--download-file', path, ' --exclude extras'])

        self.term.fork_command(cmd[0], cmd, None, tgt)

    def on_btn_go_clicked(self, widget, event=None):  # pylint: disable=W0613
        """Callback for the 'Save' button"""
        nbook = self.builder.get_object('nbook_main')
        nbook.set_show_tabs(True)
        nbook.set_current_page(nbook.page_num(
            self.builder.get_object("vbox_term")))
        self.next_download()

        # Do this as late as possible to minimize the chance that an exception
        # could leave it un-sensitive
        widget.set_sensitive(False)

    def on_btn_target_file_set(self, widget):
        """Handler to persist changes to the target directory"""
        try:
            with open(self.save_dir_store, 'w') as fobj:
                fobj.write(widget.get_filename())
        except IOError, err:
            log.error("Cannot save target directory: %s", err)

    def on_cell_toggled(self, cellrenderer, path):
        """Handler for enabling clicks on checkbox cells."""
        idx = self.toggle_map[cellrenderer]
        self.data[path][idx] = not self.data[path][idx]

    def on_child_exited(self, widget):
        """Handler to start next download when lgogdownloader exits."""
        if widget.get_child_exit_status() != 0:
            # TODO: Redesign the queue so this can be indicated by icons
            widget.feed("\r\n-- DOWNLOAD FAILED --")
        self.next_download()

    # pylint: disable=no-self-use
    def on_view_dlqueue_key_press_event(self, widget, event):
        """Handler for enabling the Delete key"""
        if (event.type == gtk.gdk.KEY_PRESS and
                event.keyval == gtk.keysyms.Delete):
            model, rows = widget.get_selection().get_selected_rows()
            rows = [gtk.TreeRowReference(model, x) for x in rows]
            for ref in rows:
                model.remove(model.get_iter(ref.get_path()))

    # pylint: disable=unused-argument,invalid-name
    def on_view_dlqueue_button_press_event(self, widget, event=None):
        """Right-click and Menu button handler for the TreeView.

        Source: http://faq.pygtk.org/index.py?req=show&file=faq13.017.htp
        """
        treeview = self.builder.get_object('view_dlqueue')
        if event and event.button == 3:  # Right Click
            btn = event.button
            x, y, time = int(event.x), int(event.y), event.time
        elif event:                      # Non-right Click
            return None
        elif not event:                  # Menu key on the keyboard
            cursor = treeview.get_cursor()
            if cursor[0] is None:
                return None

        # Code to handle right-clicking on a non-selected entry
        # Source: http://www.daa.com.au/pipermail/pygtk/2005-June/010465.html
        path = treeview.get_path_at_pos(int(event.x), int(event.y))
        selection = treeview.get_selection()
        rows = selection.get_selected_rows()
        if path[0] not in rows[1]:
            selection.unselect_all()
            selection.select_path(path[0])

        self.builder.get_object("popup_dlqueue").popup(
            None, None, None, btn, time)
        return True

    # pylint: disable=no-self-use
    def on_view_dlqueue_scroll_event(self, widget, event):
        """Generic handler to enable the scroll-wheel in a TreeView"""
        adj = widget.get_vadjustment()
        cur, incr = adj.get_value(), adj.get_page_increment() / 2.0
        if event.direction == gtk.gdk.SCROLL_UP:
            adj.set_value(cur - incr)
        elif event.direction == gtk.gdk.SCROLL_DOWN:
            adj.set_value(min(
                cur + incr, adj.get_upper() - adj.get_page_increment()))

    def on_dlqueue_delete_activate(self, _):
        """Handler to allow TreeView entry deletion"""
        widget = self.builder.get_object('view_dlqueue')
        model, rows = widget.get_selection().get_selected_rows()
        rows = [gtk.TreeRowReference(model, x) for x in rows]
        for ref in rows:
            model.remove(model.get_iter(ref.get_path()))

    @dbus.service.method(SVC_NAME, in_signature='a{sv}asi', out_signature='')
    def start(self, options, arguments, timestamp):
        """The part of main() which should run in the single instance"""
        if self.is_running():
            self.enqueue_uris(arguments)
            nbook = self.builder.get_object('nbook_main')
            nbook.set_current_page(nbook.page_num(
                self.builder.get_object("vbox_queue")))
            self.builder.get_object('mainwin').present_with_time(timestamp)
        else:
            self.running = True
            self._init_gui()
            self.enqueue_uris(arguments)
            gtk.main()
            self.running = False

def main():
    """The main entry point, compatible with setuptools entry points."""
    from optparse import OptionParser
    parser = OptionParser(version="%%prog v%s" % __version__,
            usage="%prog [options] <gogdownloader URI> ...",
            description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0])
    parser.add_option('-v', '--verbose', action="count", dest="verbose",
        default=2, help="Increase the verbosity. Use twice for extra effect")
    parser.add_option('-q', '--quiet', action="count", dest="quiet",
        default=0, help="Decrease the verbosity. Use twice for extra effect")
    # Reminder: %default can be used in help strings.

    # Allow pre-formatted descriptions
    parser.formatter.format_description = lambda description: description

    opts, args = parser.parse_args()

    # Set up clean logging to stderr
    log_levels = [logging.CRITICAL, logging.ERROR, logging.WARNING,
                  logging.INFO, logging.DEBUG]
    opts.verbose = min(opts.verbose - opts.quiet, len(log_levels) - 1)
    opts.verbose = max(opts.verbose, 0)
    logging.basicConfig(level=log_levels[opts.verbose],
                        format='%(levelname)s: %(message)s')

    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    bus = dbus.SessionBus()
    request = bus.request_name(SVC_NAME, dbus.bus.NAME_FLAG_DO_NOT_QUEUE)
    if request != dbus.bus.REQUEST_NAME_REPLY_EXISTS:
        app = Application(bus, '/', SVC_NAME)
    else:
        obj = bus.get_object(SVC_NAME, "/")
        app = dbus.Interface(obj, SVC_NAME)

    app.start(vars(opts), args, int(time.time()))
    if app.is_running():
        gtk.gdk.notify_startup_complete()

if __name__ == '__main__':
    main()

# vim: set sw=4 sts=4 expandtab :