coverart/: Prepare for proper release.
[autoys] / coverart / coverart.in
diff --git a/coverart/coverart.in b/coverart/coverart.in
new file mode 100644 (file)
index 0000000..9c1072c
--- /dev/null
@@ -0,0 +1,944 @@
+#! @PYTHON@
+### -*- mode: python; coding: utf-8 -*-
+###
+### Manage and update cover art for a music collection
+###
+### (c) 2014 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of the `autoys' audio tools collection.
+###
+### `autoys' is free software; you can redistribute it and/or modify
+### it under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 2 of the License, or
+### (at your option) any later version.
+###
+### `autoys' is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with `autoys'; if not, write to the Free Software Foundation,
+### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+###--------------------------------------------------------------------------
+### External dependencies.
+
+## Language features.
+from __future__ import with_statement
+
+## Standard Python libraries.
+from cStringIO import StringIO
+import errno as E
+import json as JS
+import os as OS; ENV = OS.environ
+import sys as SYS
+import urllib as U
+import urllib2 as U2
+
+## GTK and friends.
+import cairo as XR
+import gobject as G
+import gtk as GTK
+GDK = GTK.gdk
+
+###--------------------------------------------------------------------------
+### Theoretically tweakable parameters.
+
+THUMBSZ = 96
+
+###--------------------------------------------------------------------------
+### The image cache.
+
+class ImageCache (object):
+  """
+  I maintain a cache of CacheableImage objects.
+
+  I evict images which haven't been used recently if the total size of the
+  image data I'm holding is larger than the threshold in my `THRESH'
+  attribute.
+  """
+
+  ## Useful attributes:
+  ## _total = total size of image data in the cache, in bytes
+  ## _first, _last = head and tail of a linked list of CacheableImage objects
+  ##
+  ## We make use of the link attributes _first and _next of CacheableImage
+  ## objects.
+
+  ## Notionally configurable parameters.
+  THRESH = 128*1024*1024
+
+  def __init__(me):
+    """Initialize an ImageCache object.  The cache is initially empty."""
+    me._total = 0
+    me._first = me._last = None
+
+  def add(me, img):
+    """Add IMG to the cache, possibly evicting old images."""
+
+    ## Update the cache size, and maybe evict images if we're over budget.
+    me._total += img.size
+    while me._first and me._total > me.THRESH: me._first.evict()
+
+    ## Link the new image into the list.
+    img._prev = me._last
+    img._next = None
+    if me._last: me._last._next = img
+    else: me._first = img
+    me._last = img
+
+  def rm(me, img):
+    """
+    Remove IMG from the cache.
+
+    This is usually a response to an eviction notice received by the image.
+    """
+
+    ## Unlink the image.
+    if img._prev: img._prev._next = img._next
+    else: me._first = img._next
+    if img._next: img._next._prev = img._prev
+    else: img._last = img._prev
+
+    ## Update the cache usage.
+    me._total -= img.size
+
+## We only need one cache, in practice, and here it is.
+CACHE = ImageCache()
+
+class CacheableImage (object):
+  """
+  I represent an image which can be retained in the ImageCache.
+
+  I'm an abstract class.  Subclasses are expected to implement a method
+  `_acquire' which fetches the image's data from wherever it comes from and
+  returns it, as a Gdk Pixbuf object.
+
+  Cacheable images may also retain a thumbnail which is retained
+  until explicitly discarded.
+  """
+
+  ## Useful attributes:
+  ## _pixbuf = the pixbuf of the acquired image, or None
+  ## _thumb = the pixbuf of the thumbnail, or None
+  ## _next, _prev = forward and backward links in the ImageCache list
+
+  def __init__(me):
+    """Initialize the image."""
+    me._pixbuf = None
+    me._thumb = None
+    me._prev = me._next = None
+
+  @property
+  def pixbuf(me):
+    """
+    Return the underlying image data, as a Gdk Pixbuf object.
+
+    The image data is acquired if necessary, and cached for later reuse.
+    """
+    if not me._pixbuf:
+      me._pixbuf = me._acquire()
+      me.size = me._pixbuf.get_pixels_array().nbytes
+      CACHE.add(me)
+    return me._pixbuf
+
+  def evict(me):
+    """Discard the image data.  This is usually a request by the cache."""
+    me._pixbuf = None
+    CACHE.rm(me)
+
+  def flush(me):
+    """
+    Discard the image data and thumbnail.
+
+    They will be regenerated again on demand.
+    """
+    me.evict()
+    me._thumb = None
+
+  @property
+  def thumbnail(me):
+    """Return a Thumbnail object for the image."""
+    if not me._thumb: me._thumb = Thumbnail(me)
+    return me._thumb
+
+class Thumbnail (object):
+  """
+  I represent a reduced-size view of an image, suitable for showing in a big
+  gallery.
+
+  My `pixbuf' attribute stores a Gdk Pixbuf of the thumbnail image.
+  """
+
+  def __init__(me, img):
+    """
+    Initialize the Thumbnail.
+
+    The thumbnail will contain a reduced-size view of the CacheableImage IMG.
+    """
+    pix = img.pixbuf
+    wd, ht = pix.get_width(), pix.get_height()
+    m = max(wd, ht)
+    if m <= THUMBSZ:
+      me.pixbuf = pix
+    else:
+      twd, tht = [(x*THUMBSZ + m//2)//m for x in [wd, ht]]
+      me.pixbuf = pix.scale_simple(twd, tht, GDK.INTERP_HYPER)
+
+###--------------------------------------------------------------------------
+### Various kinds of image.
+
+class NullImage (CacheableImage):
+  """
+  I represent a placeholder image.
+
+  I'm usually used because the proper image you actually wanted is
+  unavailable for some reason.
+  """
+
+  ## Useful attributes:
+  ## _size = size of the (square) image, in pixels
+  ## _text = the text to display in the image.
+
+  def __init__(me, size, text):
+    """
+    Construct a new NullImage.
+
+    The image will be a square, SIZE pixels along each side, and will display
+    the given TEXT.
+    """
+    CacheableImage.__init__(me)
+    me._size = size
+    me._text = text
+
+  def _acquire(me):
+    """Render the placeholder image."""
+
+    ## Set up a drawing surface and context.
+    surf = XR.ImageSurface(XR.FORMAT_ARGB32, me._size, me._size)
+    xr = XR.Context(surf)
+
+    ## Choose an appropriate background colour and fill it in.
+    xr.set_source_rgb(0.3, 0.3, 0.3)
+    xr.paint()
+
+    ## Choose an appropriately nondescript font and foreground colour.
+    xr.select_font_face('sans-serif',
+                        XR.FONT_SLANT_NORMAL, XR.FONT_WEIGHT_BOLD)
+    xr.set_source_rgb(0.8, 0.8, 0.8)
+
+    ## Figure out how big the text is going to be, and therefore how to scale
+    ## it so that it fills the available space pleasingly.
+    xb, yb, wd, ht, xa, ya = xr.text_extents(me._text)
+    m = max(wd, ht)
+    z = me._size/float(m) * 2.0/3.0
+    xr.scale(z, z)
+
+    ## Render the text in the middle of the image.
+    xr.move_to(3.0*m/4.0 - wd/2.0 - xb, 3.0*m/4.0 - ht/2.0 - yb)
+    xr.show_text(me._text)
+
+    ## We're done drawing.  Collect the image and capture it as a Pixbuf so
+    ## that everyone else can use it.
+    surf.flush()
+    pix = GDK.pixbuf_new_from_data(surf.get_data(),
+                                   GDK.COLORSPACE_RGB, True, 8,
+                                   me._size, me._size, surf.get_stride())
+    return pix
+
+class FileImage (CacheableImage):
+  """
+  I represent an image fetched from a (local disk) file.
+  """
+
+  ## Useful attributes:
+  ## _file = filename to read image data from
+
+  def __init__(me, file):
+    """Initialize a FileImage which reads its image data from FILE."""
+    CacheableImage.__init__(me)
+    me._file = file
+
+  def _acquire(me):
+    """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.
+  """
+
+  ## Useful attributes:
+  ## _url = the URL to use to fetch the image
+  ## _ref = the referrer to set when fetching the image
+  ## _data = the image content from the server
+  ## _format = a PixbufFormat object describing the image format
+  ##
+  ## On first acquire, we fetch the image data from the server the hard way,
+  ## but we retain this indefinitely, even if the pixbuf is evicted as a
+  ## result of the cache filling.  The idea is that JPEG files (say) are
+  ## rather smaller than uncompressed pixbufs, and it's better to use up
+  ## local memory storing a JPEG than to have to fetch the whole thing over
+  ## the network again.
+
+  ## A dummy image used in place of the real thing if we encounter an error.
+  ERRIMG = NullImage(256, '!')
+
+  def __init__(me, url, ref = None):
+    """
+    Initialize the RemoteImage object.
+
+    Image data will be fetched from URL; if a REF is provided (and is not
+    None), then it will be presented as the `referer' when fetching the
+    image.
+    """
+    CacheableImage.__init__(me)
+    me._url = url
+    me._ref = ref
+    me._data = None
+
+  def _fetch(me):
+    """
+    Fetch the image data from the server, if necessary.
+
+    On success, the raw image data is stored in the `_data' attribute.  Many
+    things can go wrong, though.
+    """
+
+    ## If we already have the image data, then use what we've got already.
+    if me._data: return
+
+    ## Fetch the image data from the server.
+    d = StringIO()
+    rq = U2.Request(me._url)
+    if me._ref: rq.add_header('Referer', me._ref)
+    rs = U2.urlopen(rq)
+    while True:
+      stuff = rs.read(16384)
+      if not stuff: break
+      d.write(stuff)
+    me._data = d.getvalue()
+
+    ## Try to figure out what kind of image this is.  With a bit of luck, we
+    ## can figure this out by spooning a bit of image data into a
+    ## PixbufLoader.
+    ld = GDK.PixbufLoader()
+    try:
+      o = 0
+      n = len(me._data)
+      while True:
+        if o >= n: raise ValueError, 'not going to work'
+        l = min(n, o + 16384)
+        ld.write(me._data[o:l])
+        o = l
+        f = ld.get_format()
+        if f: break
+      me._format = f
+
+      ## If this is a GIF then bail now.  They look terrible.
+      if 'image/gif' in f['mime_types']:
+        me._data = None
+        raise ValueError, 'boycotting GIF image'
+
+    finally:
+      ## Tidy up: we don't want the loader any more.
+      try: ld.close()
+      except G.GError: pass
+
+  def _acquire(me):
+    """
+    Return a decoded image from the image data.
+
+    If this isn't going to work, return a dummy image.
+    """
+    try:
+      me._fetch()
+      ld = GDK.PixbufLoader()
+      try: ld.write(me._data)
+      finally: ld.close()
+      return ld.get_pixbuf()
+    except Exception, e:
+      print e
+      return me.ERRIMG.pixbuf
+
+  @property
+  def ext(me):
+    """Return a file extension for the image, in case we want to save it."""
+    ## If we can use `.jpg' then do that; otherwise, take whatever Gdk-Pixbuf
+    ## suggests.
+    exts = me._format['extensions']
+    for i in ['jpg']:
+      if i in exts: return i
+    return exts[0]
+
+class SearchImage (RemoteImage):
+  """
+  I represent an image found by searching the web.
+  """
+
+  ## Useful attributes:
+  ## _tburl = the thumbnail url
+
+  def __init__(me, url, ref, tburl):
+    """
+    Initialize a SearchImage object.
+
+    The URL and REF arguments are as for the base RemoteImage object; TBURL
+    is the location of a thumbnail image which (so the thinking goes) will be
+    smaller and hence faster to fetch than the main one.
+    """
+    RemoteImage.__init__(me, url, ref)
+    me._tburl = tburl
+
+  @property
+  def thumbnail(me):
+    """Fetch a thumbnail separately, using the thumbnail URL provided."""
+    if not me._thumb: me._thumb = Thumbnail(RemoteImage(me._tburl))
+    return me._thumb
+
+class SearchResult (SearchCover):
+  """
+  I represent information about an image found while searching the web.
+
+  I'm responsible for parsing information about individual search hits from
+  the result.
+  """
+  def __init__(me, r):
+    """Initialize a SearchResult, given a decoded JSON fragment R."""
+    i = r['image']
+    w = int(i['width'])
+    h = int(i['height'])
+    url = r['link']
+    ref = i['contextLink']
+    tburl = i['thumbnailLink']
+    SearchCover.__init__(me, SearchImage(url, ref, tburl),
+                         width = w, height = h)
+
+class SearchFail (Exception):
+  """An exception found while trying to search for images."""
+  pass
+
+class CoverChooser (object):
+  """
+  I represent a window for choosing one of a number of cover art images.
+
+  I allow the user to choose one of a number of alternative images for an
+  album, and to search the Internet for more options.
+
+  I work on behalf of some other client object; I am responsible for
+  collecting the user's ultimate choice, but that responsibility ends when I
+  tell my client which image the user picked.
+
+  During my lifetime, I can be used to choosing images for different purposes
+  (possibly on behalf of different clients), though I can only work on one
+  thing at a time.  I switch between jobs when `update' is called; see the
+  documentation of that method for details of the protocol.
+
+  I try to arrange that there's at most one instance of me, in the variable
+  `CHOOSER'.
+  """
+
+  ## Important attributes:
+  ## query = the entry widget for typing search terms
+  ## sv = the search viewer pane showing thumbnails of search results
+  ## img = the image preview pane showing a selected image at full size
+  ## win = our top-level window
+  ## view = the object which invoked us
+  ## which = the client's `which' parameter
+  ## current = the currently chosen image
+
+  ## This is a bit grim because search APIs and keys.
+  SEARCHURL = None
+  SEARCHID = '016674315927968770913:8blyelgp3wu'
+  REFERER = 'https://www.distorted.org.uk/~mdw/coverart'
+
+  @classmethod
+  def set_apikey(cls, apikey):
+    """Inform the class that it can use APIKEY to authorize its search."""
+    cls.SEARCHURL = \
+      'https://www.googleapis.com/customsearch/v1?' \
+      'key=%s&cx=%s&searchType=image&q=' % (apikey, cls.SEARCHID)
+
+  def __init__(me):
+    """Initialize the window, but don't try to show it yet."""
+
+    ## Make a window.
+    me.win = GTK.Window()
+
+    ## The window layout is like this.
+    ##
+    ##           -----------------------------------
+    ##          | [...                   ] [Search] |
+    ##          |-----------------------------------|
+    ##          | thumbs  |                         |
+    ##          |    .    |                         |
+    ##          |    .    |          image          |
+    ##          |    .    |                         |
+    ##          |         |         preview         |
+    ##          |         |                         |
+    ##          |         |                         |
+    ##          |         |                         |
+    ##          |         |                         |
+    ##           -----------------------------------
+
+    ## Main layout box, with search stuff at the top and selection stuff
+    ## below.
+    box = GTK.VBox()
+
+    ## First, fill in the search stuff.
+    top = GTK.HBox()
+    me.query = GTK.Entry()
+    top.pack_start(me.query, True, True, 2)
+    srch = GTK.Button('_Search')
+    srch.set_flags(GTK.CAN_DEFAULT)
+    srch.connect('clicked', me.search)
+    top.pack_start(srch, False, False, 2)
+    box.pack_start(top, False, False, 2)
+
+    ## Now the thumbnail viewer and preview below.
+    panes = GTK.HPaned()
+    me.sv = SearchViewer(me)
+    panes.pack1(me.sv, False, True)
+    scr = GTK.ScrolledWindow()
+    scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
+    evb = GTK.EventBox()
+    me.img = GTK.Image()
+    evb.add(me.img)
+    fix_background(evb)
+    scr.add_with_viewport(evb)
+    panes.pack2(scr, True, True)
+    panes.set_position(THUMBSZ + 64)
+    box.pack_start(panes, True, True, 0)
+    me.win.add(box)
+
+    ## Finally, configure some signal handlers.
+    me.win.connect('destroy', me.destroyed)
+    me.win.set_default_size(800, 550)
+
+    ## Set the default button.  (Gtk makes us wait until the button has a
+    ## top-level window to live in.)
+    srch.grab_default()
+
+  def update(me, view, which, dir, current):
+    """
+    Update the CoverChooser to choose a different album's cover image.
+
+    The VIEW is the client object.  We will later call its `replace' method,
+    passing it WHICH and the newly chosen image (as a RemoteImage object).
+    WHICH is simply remembered as a context value for `update'.  DIR is the
+    path to the album whose cover is to be chosen, used for constructing a
+    suitable search string.  CURRENT is some kind of CacheableImage object
+    representing the currently chosen image we may be replacing.
+    """
+    me.view = view
+    me.which = which
+    me.current = current
+    me.img.clear()
+    me.sv.switch(current)
+    me.query.set_text(me.makequery(dir))
+    me.win.show_all()
+
+  def search(me, w):
+    """
+    Instigate a web search for a replacement image.
+
+    W is the widget which was frobbed to invoke the search, but it's not very
+    interesting.
+    """
+
+    ## Collect the search query.
+    q = me.query.get_text()
+
+    ## Try to ask a search provider for some likely images.
+    try:
+
+      ## We won't get far if we've not been given an API key.
+      if me.SEARCHURL is None: raise SearchFail('no search key')
+
+      ## Collect the search result.
+      try:
+        rq = U2.Request(me.SEARCHURL + U.quote_plus(q), None,
+                        { 'Referer': me.REFERER })
+        rs = U2.urlopen(rq)
+      except U2.URLError, e:
+        raise SearchFail(e.reason)
+      result = JS.load(rs)
+
+      ## Clear out all of the images from the last search, leaving only the
+      ## incumbent choice, and then add the new images from the search
+      ## results.
+      me.sv.switch(me.current)
+      for r in result['items']:
+        try: me.sv.addcover(SearchResult(r))
+        except (U2.URLError, U2.HTTPError): pass
+
+    ## Maybe that didn't work.
+    except SearchFail, e:
+      print e.args[0]
+
+  def makequery(me, path):
+    """Construct a default search query string for the chosen album."""
+    bits = path.split(OS.path.sep)
+    return ' '.join(['"%s"' % p for p in bits[-2:]])
+
+  def selected(me, cov):
+    """
+    Show a full-size version of COV in the preview pane.
+
+    Called by the SearchViewer when a thumbnail is selected.
+    """
+    if cov: me.img.set_from_pixbuf(cov.img.pixbuf)
+    else: me.img.clear()
+
+  def activated(me, cov):
+    """
+    Inform our client that COV has been chosen as the replacement image.
+
+    Called by the SearchViewer when a thumbnail is activated.
+    """
+    if isinstance(cov, SearchCover): me.view.replace(me.which, cov.img)
+
+  def destroyed(me, w):
+    """
+    Our widget has been destroyed.
+
+    We're going away, so clear out the reference to us.
+    """
+    global CHOOSER
+    CHOOSER = None
+
+## 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
+  in the search tree.
+  """
+
+  ## Useful attributes:
+  ## root = the root of the directory tree to manage
+
+  def __init__(me, root):
+    """
+    Initialize a viewer for choosing cover art in the tree headed by ROOT.
+    """
+    BaseCoverViewer.__init__(me)
+    me.root = root
+    me.walk('')
+
+  def walk(me, dir):
+    """
+    Walk the directory tree from DIR down, adding icons for cover art I find
+    (or don't find).
+    """
+
+    ## Assume this is a leaf directory for now.
+    leafp = True
+
+    ## Figure out the actual pathname we're looking at.
+    b = OS.path.join(me.root, dir)
+
+    ## The name of any image file we find.
+    imgfile = None
+
+    ## Work through the items in the directory.
+    for l in sorted(OS.listdir(b)):
+
+      if OS.path.isdir(OS.path.join(b, l)):
+        ## If this item is a directory, then we're not leaf, but need to
+        ## descend recursively.
+        leafp = False
+        me.walk(OS.path.join(dir, l))
+      else:
+        ## If this smells like a cover image then remember it.  (If there are
+        ## multiple plausible options, just remember one more or less
+        ## arbitrarily.)
+        base, ext = OS.path.splitext(l)
+        if base == 'cover' and ext in ['.jpg', '.png', '.gif']: imgfile = l
+
+    ## If this is a leaf directory, hopefully representing an album rather
+    ## than an artist or higher-level grouping, then add an icon representing
+    ## it, including any cover image that we found.
+    if leafp:
+      me.addcover(ViewCover(dir, OS.path.join(me.root, dir), imgfile))
+
+  def select(me, cov):
+    """The user selected a cover icon, but we don't care."""
+    pass
+
+  def activate(me, cov):
+    """
+    A cover icon was activated.
+
+    Allow the user to choose a replacement cover.
+    """
+    global CHOOSER
+    if not CHOOSER:
+      CHOOSER = CoverChooser()
+    CHOOSER.update(me, cov, cov.text, cov.covimg)
+
+  def replace(me, cov, img):
+    """
+    Replace the cover COV by the newly chosen image IMG.
+
+    This is called by the CoverChooser.
+    """
+    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)
+    OS.rename(new, out)
+    if cov.leaf not in [None, leaf]:
+      OS.unlink(OS.path.join(cov.path, cov.leaf))
+    ncov = ViewCover(cov.text, cov.path, leaf)
+    ncov.it = cov.it
+    me.list[ncov.it] = [ncov.img.thumbnail.pixbuf, ncov.text, ncov]
+    me.activate(ncov)
+
+###--------------------------------------------------------------------------
+### Main program.
+
+if __name__ == '__main__':
+
+  ## Try to find an API key for searching.
+  CONFROOT = ENV.get('XDG_CONFIG_HOME', None)
+  if CONFROOT is None:
+    CONFROOT = OS.path.join(ENV['HOME'], '.config')
+  CONFDIR = OS.path.join(CONFROOT, 'autoys')
+  try:
+    f = open(OS.path.join(CONFDIR, 'apikey'))
+  except IOError, e:
+    if e.errno == E.ENOENT: pass
+    else: raise
+  else:
+    with f:
+      apikey = f.readline().strip()
+      CoverChooser.set_apikey(apikey)
+
+  ## Set things up.
+  ROOT = SYS.argv[1]
+  LOOP = G.MainLoop()
+  BLACK = GDK.Color(0, 0, 0)
+  WHITE = GDK.Color(65535, 65535, 65535)
+
+  ## Make a top-level window showing a MainView and display it.
+  WIN = GTK.Window()
+  VIEW = MainViewer(ROOT)
+  WIN.add(VIEW)
+  WIN.set_default_size(814, 660)
+  WIN.set_title('coverart')
+  WIN.connect('destroy', lambda _: LOOP.quit())
+  WIN.show_all()
+
+  ## Carry on until there's nothing left to do.
+  LOOP.run()
+
+###----- That's all, folks --------------------------------------------------