--- /dev/null
+#! @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 --------------------------------------------------