coverart/coverart.in: Reorder, reformat, and document.
authorMark Wooding <mdw@distorted.org.uk>
Thu, 5 May 2016 09:11:17 +0000 (10:11 +0100)
committerMark Wooding <mdw@distorted.org.uk>
Thu, 19 Apr 2018 11:22:49 +0000 (12:22 +0100)
Lots of other minor changes mixed in.  This was a mess I found in my
working tree, and didn't bother to untangle.

coverart/coverart.in

index 9c1072c..eca9a72 100644 (file)
@@ -34,6 +34,7 @@ from __future__ import with_statement
 from cStringIO import StringIO
 import errno as E
 import json as JS
+import optparse as OP
 import os as OS; ENV = OS.environ
 import sys as SYS
 import urllib as U
@@ -46,11 +47,45 @@ import gtk as GTK
 GDK = GTK.gdk
 
 ###--------------------------------------------------------------------------
+### Build-time configuration.
+
+VERSION = '@VERSION@'
+
+###--------------------------------------------------------------------------
 ### Theoretically tweakable parameters.
 
 THUMBSZ = 96
 
 ###--------------------------------------------------------------------------
+### Utilities.
+
+def whinge(msg):
+  """Tell the user of some unfortunate circumstance described by MSG."""
+  dlg = GTK.MessageDialog(None, 0, GTK.MESSAGE_ERROR, GTK.BUTTONS_NONE, msg)
+  dlg.set_title('%s Error' % PROG)
+  dlg.add_button('_Bummer', 0)
+  dlg.run()
+  dlg.destroy()
+
+def fetch_url(url):
+  """Fetch the resource named by URL, returning its content as a string."""
+  out = StringIO()
+  with U.urlopen(url) as u:
+    while True:
+      stuff = u.read(16384)
+      if not stuff: break
+      out.write(stuff)
+  return out.getvalue()
+
+def fix_background(w):
+  """Hack the style of the window W so that it shows white-on-black."""
+  style = w.get_style().copy()
+  style.base[GTK.STATE_NORMAL] = BLACK
+  style.bg[GTK.STATE_NORMAL] = BLACK
+  style.text[GTK.STATE_NORMAL] = WHITE
+  w.set_style(style)
+
+###--------------------------------------------------------------------------
 ### The image cache.
 
 class ImageCache (object):
@@ -267,173 +302,6 @@ class FileImage (CacheableImage):
     """Acquire the image data."""
     return GDK.pixbuf_new_from_file(me._file)
 
-###--------------------------------------------------------------------------
-### Miscellaneous utilities.
-
-def fetch_url(url):
-  """Fetch the resource named by URL, returning its content as a string."""
-  out = StringIO()
-  with U.urlopen(url) as u:
-    while True:
-      stuff = u.read(16384)
-      if not stuff: break
-      out.write(stuff)
-  return out.getvalue()
-
-def fix_background(w):
-  """Hack the style of the window W so that it shows white-on-black."""
-  style = w.get_style().copy()
-  style.base[GTK.STATE_NORMAL] = BLACK
-  style.bg[GTK.STATE_NORMAL] = BLACK
-  style.text[GTK.STATE_NORMAL] = WHITE
-  w.set_style(style)
-
-###--------------------------------------------------------------------------
-### The windows.
-
-class BaseCoverViewer (GTK.ScrolledWindow):
-  """
-  I represent a viewer for a collection of cover images, shown as thumbnails.
-
-  The image objects should have the following attributes.
-
-  img                   The actual image, as a CacheableImage.
-
-  text                  Some text to associate with the image, as a Python
-                        string.
-
-  I will store an `iterator' in the image object's `it' attribute, which will
-  let others identify it later to the underlying Gtk machinery.
-
-  Subclasses should implement two methods:
-
-  activate(COVER)       The COVER has been activated by the user (e.g.,
-                        double-clicked).
-
-  select(COVER)         The COVER has been selected by the user (e.g.,
-                        clicked) in a temporary manner; if COVER is None,
-                        then a previously selected cover has become
-                        unselected.
-  """
-
-  ## Useful attributes:
-  ## iv = an IconView widget used to show the thumbnails
-  ## list = a ListStore object used to keep track of the cover images; each
-  ##    item is a list of the form [PIXBUF, TEXT, COVER], where the PIXBUF
-  ##    and TEXT are extracted from the cover object in the obvious way
-
-  def __init__(me):
-    """Initialize a BaseCoverViewer."""
-
-    ## Initialize myself, as a scrollable thingy.
-    GTK.ScrolledWindow.__init__(me)
-    me.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
-
-    ## Set up an IconView to actually show the cover thumbnails.
-    me.iv = GTK.IconView()
-    me.iv.connect('item-activated',
-                  lambda iv, p: me.activate(me._frompath(p)))
-    me.iv.connect('selection-changed', me._select)
-    me.iv.set_pixbuf_column(0)
-    me.iv.set_text_column(1)
-    me.iv.set_orientation(GTK.ORIENTATION_VERTICAL)
-    me.iv.set_item_width(THUMBSZ + 32)
-    fix_background(me.iv)
-    me.add(me.iv)
-
-    ## Clear the list ready for cover images to be added.
-    me.reset()
-
-  def reset(me):
-    """
-    Clear the viewer of cover images.
-
-    This does /not/ clear the `it' attribute of previously attached cover
-    object.
-    """
-    me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT)
-    me.iv.set_model(me.list)
-    me.iv.unselect_all()
-
-  def addcover(me, cov):
-    """
-    Add the cover image COV to the viewer.
-
-    `COV.it' is filled in with the object's iterator.
-    """
-    cov.it = me.list.append([cov.img.thumbnail.pixbuf, cov.text, cov])
-
-  def _frompath(me, path):
-    """Convert a PATH to a cover image, and return it."""
-    return me.list[path][2]
-
-  def _select(me, iv):
-    """Handle a selection event, calling the subclass `select' method."""
-    sel = me.iv.get_selected_items()
-    if len(sel) != 1: me.select(None)
-    else: me.select(me._frompath(sel[0]))
-
-class SearchCover (object):
-  """
-  A base class for images in the SearchViewer window.
-  """
-  def __init__(me, img, width = None, height = None, marker = ''):
-    """
-    Initialize a SearchCover object.
-
-    The IMG is a CacheableImage of some kind.  If the WIDTH and HEIGHT are
-    omitted, the image data will be acquired and both dimensions calculated
-    (since, after all, if we have to go to the bother of fetching the image,
-    we may as well get an accurate size).
-    """
-    me.img = img
-    if width is None or height is None:
-      pix = img.pixbuf
-      width = pix.get_width()
-      height = pix.get_height()
-    me.text = '%d×%d%s' % (width, height, marker)
-
-class SearchViewer (BaseCoverViewer):
-  """
-  I'm a BaseCoverViewer subclass for showing search results.
-
-  I'll be found within a CoverChooser window, showing the thumbnails
-  resulting from a search.  I need to keep track of my parent CoverChooser,
-  so that I can tell it to show a large version of a selected image.
-  """
-
-  ## Useful attributes:
-  ## _chooser = the containing CoverChooser object
-
-  def __init__(me, chooser):
-    """
-    Initialize the SearchViewer, associating it with its parent CoverChooser.
-    """
-    BaseCoverViewer.__init__(me)
-    me._chooser = chooser
-
-  def switch(me, current):
-    """
-    Switch to a different album chosen in the CoverChooser.
-
-    CURRENT is either None, in which case the viewer is simply cleared, or
-    the current cover image (some CacheableImage object) for the newly chosen
-    album, which should be shown as the initial selection.
-    """
-    me.reset()
-    if current:
-      cov = SearchCover(current, marker = '*')
-      me.addcover(cov)
-      me.iv.select_path(me.list.get_path(cov.it))
-
-  def activate(me, cov):
-    """Inform the CoverChooser that the user activated COV."""
-    me._chooser.activated(cov)
-
-  def select(me, cov):
-    """Inform the CoverChooser that the user selected COV."""
-    me._chooser.selected(cov)
-
 class RemoteImage (CacheableImage):
   """
   I represent an image whose data can be fetched over the network.
@@ -516,6 +384,12 @@ class RemoteImage (CacheableImage):
       try: ld.close()
       except G.GError: pass
 
+  @property
+  def raw(me):
+    """The raw image data fetched from the remote source."""
+    me._fetch()
+    return me._data
+
   def _acquire(me):
     """
     Return a decoded image from the image data.
@@ -523,13 +397,13 @@ class RemoteImage (CacheableImage):
     If this isn't going to work, return a dummy image.
     """
     try:
-      me._fetch()
       ld = GDK.PixbufLoader()
-      try: ld.write(me._data)
+      try: ld.write(me.raw)
       finally: ld.close()
       return ld.get_pixbuf()
     except Exception, e:
-      print e
+      SYS.stderr.write("%s: failed to decode image from `%s': %s'" %
+                       (PROG, me._url, str(e)))
       return me.ERRIMG.pixbuf
 
   @property
@@ -567,6 +441,34 @@ class SearchImage (RemoteImage):
     if not me._thumb: me._thumb = Thumbnail(RemoteImage(me._tburl))
     return me._thumb
 
+###--------------------------------------------------------------------------
+### The windows.
+
+class SearchCover (object):
+  """
+  A base class for icons in the SearchViewer window.
+  """
+
+  ## Useful attributes:
+  ## img = a CacheableImage for the image to display
+  ## text = a string containing the text to show
+
+  def __init__(me, img, width = None, height = None, marker = ''):
+    """
+    Initialize a SearchCover object.
+
+    The IMG is a CacheableImage of some kind.  If the WIDTH and HEIGHT are
+    omitted, the image data will be acquired and both dimensions calculated
+    (since, after all, if we have to go to the bother of fetching the image,
+    we may as well get an accurate size).
+    """
+    me.img = img
+    if width is None or height is None:
+      pix = img.pixbuf
+      width = pix.get_width()
+      height = pix.get_height()
+    me.text = '%d×%d%s' % (width, height, marker)
+
 class SearchResult (SearchCover):
   """
   I represent information about an image found while searching the web.
@@ -585,6 +487,165 @@ class SearchResult (SearchCover):
     SearchCover.__init__(me, SearchImage(url, ref, tburl),
                          width = w, height = h)
 
+class ViewCover (object):
+  """
+  I represent an album cover image found already in the filesystem.
+
+  I can be found in MainViewer windows.
+  """
+
+  ## Useful attributes:
+  ## img = a CacheableImage for the image to display
+  ## text = the text (directory name) associated with the cover
+  ## path = the full pathname to this directory
+  ## leaf = the basename of the cover image, or None
+
+  ## An error image, for use when there isn't a usable image.
+  NULLIMG = NullImage(THUMBSZ, '?')
+
+  def __init__(me, dir, path, leaf):
+    """
+    Initialize a new image.
+
+    DIR is the directory containing the image, relative to the root of the
+    search; it's presented to the user to help them distinguish the similar
+    icons (or identical ones, if multiple directories lack cover images).
+    PATH is the full pathname to the directory, and is used when installing a
+    replacement cover.  LEAF is the basename of the cover image in that
+    directory, or None if there currently isn't an image there.
+    """
+    me.text = dir
+    me.path = path
+    me.leaf = leaf
+    if me.leaf:
+      me.img = me.covimg = FileImage(OS.path.join(me.path, me.leaf))
+    else:
+      me.img = me.NULLIMG
+      me.covimg = None
+
+class BaseCoverViewer (GTK.ScrolledWindow):
+  """
+  I represent a viewer for a collection of cover images, shown as thumbnails.
+
+  The image objects should have the following attributes.
+
+  img                   The actual image, as a CacheableImage.
+
+  text                  Some text to associate with the image, as a Python
+                        string.
+
+  I will store an `iterator' in the image object's `it' attribute, which will
+  let others identify it later to the underlying Gtk machinery.
+
+  Subclasses should implement two methods:
+
+  activate(COVER)       The COVER has been activated by the user (e.g.,
+                        double-clicked).
+
+  select(COVER)         The COVER has been selected by the user (e.g.,
+                        clicked) in a temporary manner; if COVER is None,
+                        then a previously selected cover has become
+                        unselected.
+  """
+
+  ## Useful attributes:
+  ## iv = an IconView widget used to show the thumbnails
+  ## list = a ListStore object used to keep track of the cover images; each
+  ##    item is a list of the form [PIXBUF, TEXT, COVER], where the PIXBUF
+  ##    and TEXT are extracted from the cover object in the obvious way
+
+  def __init__(me):
+    """Initialize a BaseCoverViewer."""
+
+    ## Initialize myself, as a scrollable thingy.
+    GTK.ScrolledWindow.__init__(me)
+    me.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
+
+    ## Set up an IconView to actually show the cover thumbnails.
+    me.iv = GTK.IconView()
+    me.iv.connect('item-activated',
+                  lambda iv, p: me.activate(me._frompath(p)))
+    me.iv.connect('selection-changed', me._select)
+    me.iv.set_pixbuf_column(0)
+    me.iv.set_text_column(1)
+    me.iv.set_orientation(GTK.ORIENTATION_VERTICAL)
+    me.iv.set_item_width(THUMBSZ + 32)
+    fix_background(me.iv)
+    me.add(me.iv)
+
+    ## Clear the list ready for cover images to be added.
+    me.reset()
+
+  def reset(me):
+    """
+    Clear the viewer of cover images.
+
+    This does /not/ clear the `it' attribute of previously attached cover
+    object.
+    """
+    me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT)
+    me.iv.set_model(me.list)
+    me.iv.unselect_all()
+
+  def addcover(me, cov):
+    """
+    Add the cover image COV to the viewer.
+
+    `COV.it' is filled in with the object's iterator.
+    """
+    cov.it = me.list.append([cov.img.thumbnail.pixbuf, cov.text, cov])
+
+  def _frompath(me, path):
+    """Convert a PATH to a cover image, and return it."""
+    return me.list[path][2]
+
+  def _select(me, iv):
+    """Handle a selection event, calling the subclass `select' method."""
+    sel = me.iv.get_selected_items()
+    if len(sel) != 1: me.select(None)
+    else: me.select(me._frompath(sel[0]))
+
+class SearchViewer (BaseCoverViewer):
+  """
+  I'm a BaseCoverViewer subclass for showing search results.
+
+  I'll be found within a CoverChooser window, showing the thumbnails
+  resulting from a search.  I need to keep track of my parent CoverChooser,
+  so that I can tell it to show a large version of a selected image.
+  """
+
+  ## Useful attributes:
+  ## _chooser = the containing CoverChooser object
+
+  def __init__(me, chooser):
+    """
+    Initialize the SearchViewer, associating it with its parent CoverChooser.
+    """
+    BaseCoverViewer.__init__(me)
+    me._chooser = chooser
+
+  def switch(me, current):
+    """
+    Switch to a different album chosen in the CoverChooser.
+
+    CURRENT is either None, in which case the viewer is simply cleared, or
+    the current cover image (some CacheableImage object) for the newly chosen
+    album, which should be shown as the initial selection.
+    """
+    me.reset()
+    if current:
+      cov = SearchCover(current, marker = '*')
+      me.addcover(cov)
+      me.iv.select_path(me.list.get_path(cov.it))
+
+  def activate(me, cov):
+    """Inform the CoverChooser that the user activated COV."""
+    me._chooser.activated(cov)
+
+  def select(me, cov):
+    """Inform the CoverChooser that the user selected COV."""
+    me._chooser.selected(cov)
+
 class SearchFail (Exception):
   """An exception found while trying to search for images."""
   pass
@@ -678,7 +739,7 @@ class CoverChooser (object):
     fix_background(evb)
     scr.add_with_viewport(evb)
     panes.pack2(scr, True, True)
-    panes.set_position(THUMBSZ + 64)
+    panes.set_position(THUMBSZ + 48)
     box.pack_start(panes, True, True, 0)
     me.win.add(box)
 
@@ -745,7 +806,7 @@ class CoverChooser (object):
 
     ## Maybe that didn't work.
     except SearchFail, e:
-      print e.args[0]
+      whinge('search failed: %s' % e.args[0])
 
   def makequery(me, path):
     """Construct a default search query string for the chosen album."""
@@ -781,41 +842,6 @@ class CoverChooser (object):
 ## There's currently no chooser.
 CHOOSER = None
 
-class ViewCover (object):
-  """
-  I represent an album cover image found already in the filesystem.
-
-  I can be found in MainViewer windows.
-  """
-
-  ## Useful attributes:
-  ## text = the text (directory name) associated with the cover
-  ## path = the full pathname to this directory
-  ## leaf = the basename of the cover image, or None
-
-  ## An error image, for use when there isn't a usable image.
-  NULLIMG = NullImage(THUMBSZ, '?')
-
-  def __init__(me, dir, path, leaf):
-    """
-    Initialize a new image.
-
-    DIR is the directory containing the image, relative to the root of the
-    search; it's presented to the user to help them distinguish the similar
-    icons (or identical ones, if multiple directories lack cover images).
-    PATH is the full pathname to the directory, and is used when installing a
-    replacement cover.  LEAF is the basename of the cover image in that
-    directory, or None if there currently isn't an image there.
-    """
-    me.text = dir
-    me.path = path
-    me.leaf = leaf
-    if me.leaf:
-      me.img = me.covimg = FileImage(OS.path.join(me.path, me.leaf))
-    else:
-      me.img = me.NULLIMG
-      me.covimg = None
-
 class MainViewer (BaseCoverViewer):
   """
   I'm a top-level cover viewer, showing thumbnails for the albums I can find
@@ -880,8 +906,7 @@ class MainViewer (BaseCoverViewer):
     Allow the user to choose a replacement cover.
     """
     global CHOOSER
-    if not CHOOSER:
-      CHOOSER = CoverChooser()
+    if not CHOOSER: CHOOSER = CoverChooser()
     CHOOSER.update(me, cov, cov.text, cov.covimg)
 
   def replace(me, cov, img):
@@ -893,8 +918,7 @@ class MainViewer (BaseCoverViewer):
     leaf = 'cover.%s' % img.ext
     out = OS.path.join(cov.path, leaf)
     new = out + '.new'
-    with open(new, 'wb') as f:
-      f.write(img._data)
+    with open(new, 'wb') as f: f.write(img.raw)
     OS.rename(new, out)
     if cov.leaf not in [None, leaf]:
       OS.unlink(OS.path.join(cov.path, cov.leaf))
@@ -908,6 +932,9 @@ class MainViewer (BaseCoverViewer):
 
 if __name__ == '__main__':
 
+  ## Set the program name.
+  PROG = OS.path.basename(SYS.argv[0])
+
   ## Try to find an API key for searching.
   CONFROOT = ENV.get('XDG_CONFIG_HOME', None)
   if CONFROOT is None:
@@ -923,8 +950,18 @@ if __name__ == '__main__':
       apikey = f.readline().strip()
       CoverChooser.set_apikey(apikey)
 
+  ## Parse the command line.
+  op = OP.OptionParser(prog = PROG, version = VERSION,
+                       usage = '%prog ROOT',
+                       description = """\
+Browse the cover-art images in the directory tree headed by ROOT, and allow
+the images to be replaced by others found by searching the Internet.
+""")
+  opts, args = op.parse_args(SYS.argv[1:])
+  if len(args) != 1: op.error('wrong number of arguments')
+  ROOT, = args
+
   ## Set things up.
-  ROOT = SYS.argv[1]
   LOOP = G.MainLoop()
   BLACK = GDK.Color(0, 0, 0)
   WHITE = GDK.Color(65535, 65535, 65535)
@@ -933,7 +970,7 @@ if __name__ == '__main__':
   WIN = GTK.Window()
   VIEW = MainViewer(ROOT)
   WIN.add(VIEW)
-  WIN.set_default_size(814, 660)
+  WIN.set_default_size(6*(THUMBSZ + 48), 660)
   WIN.set_title('coverart')
   WIN.connect('destroy', lambda _: LOOP.quit())
   WIN.show_all()