+++ /dev/null
-#! /usr/bin/python
-# -*- coding: utf-8 -*-
-
-import sys as SYS
-import os as OS
-from cStringIO import StringIO
-
-import gobject as G
-import gtk as GTK
-GDK = GTK.gdk
-import cairo as XR
-
-import urllib as U
-import urllib2 as U2
-import json as JS
-
-THUMBSZ = 96
-
-class ImageCache (object):
-
- THRESH = 128*1024*1024
-
- def __init__(me):
- me._total = 0
- me._first = me._last = None
-
- def add(me, img):
- me._total += img.size
- while me._first and me._total > me.THRESH:
- me._first.evict()
- 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):
- 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
- me._total -= img.size
-
-CACHE = ImageCache()
-
-class CacheableImage (object):
-
- def __init__(me):
- me._pixbuf = None
- me._prev = me._next = None
- me._thumb = None
-
- @property
- def pixbuf(me):
- 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):
- me._pixbuf = None
- CACHE.rm(me)
-
- def flush(me):
- me.evict()
- me._thumb = None
-
- @property
- def thumbnail(me):
- if not me._thumb:
- me._thumb = Thumbnail(me)
- return me._thumb
-
-class Thumbnail (object):
-
- def __init__(me, 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)
-
-class NullImage (CacheableImage):
-
- MAP = {}
-
- def __init__(me, size, text):
- CacheableImage.__init__(me)
- me._size = size
- me._text = text
-
- @staticmethod
- def get(cls, size):
- try:
- return cls.MAP[size]
- except KeyError:
- img = cls.MAP[size] = cls(size)
- return img
-
- def _acquire(me):
-
- surf = XR.ImageSurface(XR.FORMAT_ARGB32, me._size, me._size)
- xr = XR.Context(surf)
-
- xr.set_source_rgb(0.3, 0.3, 0.3)
- xr.paint()
-
- xr.move_to(me._size/2.0, me._size/2.0)
- xr.select_font_face('sans-serif',
- XR.FONT_SLANT_NORMAL, XR.FONT_WEIGHT_BOLD)
- 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)
-
- xr.set_source_rgb(0.8, 0.8, 0.8)
- 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)
-
- 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):
-
- def __init__(me, file):
- CacheableImage.__init__(me)
- me._file = file
-
- def _acquire(me):
- return GDK.pixbuf_new_from_file(me._file)
-
-def fetch_url(url):
- 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):
- 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)
-
-class BaseCoverViewer (object):
-
- def __init__(me):
- me.scr = GTK.ScrolledWindow()
- me.scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
- 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.scr.add(me.iv)
- me.reset()
-
- def reset(me):
- me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT)
- me.iv.set_model(me.list)
- me.iv.unselect_all()
-
- def add(me, item):
- item.it = me.list.append([item.img.thumbnail.pixbuf,
- item.text,
- item])
-
- def _frompath(me, path):
- return me.list[path][2]
-
- def _select(me, iv):
- sel = me.iv.get_selected_items()
- if len(sel) != 1:
- me.select(None)
- else:
- me.select(me._frompath(sel[0]))
-
-class SearchCover (object):
- def __init__(me, img):
- me.img = img
- pix = img.pixbuf
- me.text = '%d×%d*' % (pix.get_width(), pix.get_height())
-
-class SearchViewer (BaseCoverViewer):
-
- def __init__(me, chooser):
- BaseCoverViewer.__init__(me)
- me._chooser = chooser
-
- def switch(me, current):
- me.reset()
- if current:
- cov = SearchCover(current)
- me.add(cov)
- me.iv.select_path(me.list.get_path(cov.it))
-
- def activate(me, cov):
- me._chooser.activated(cov)
-
- def select(me, cov):
- me._chooser.selected(cov)
-
-class RemoteImage (CacheableImage):
-
- ERRIMG = NullImage(256, '!')
-
- def __init__(me, url, ref = None):
- CacheableImage.__init__(me)
- me._url = url
- me._ref = ref
- me._data = None
-
- def _fetch(me):
- if me._data:
- return
- 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()
- 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 'image/gif' in f['mime_types']:
- raise ValueError, 'boycotting GIF image'
- finally:
- try:
- ld.close()
- except G.GError:
- pass
-
- def _acquire(me):
- 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):
- exts = me._format['extensions']
- for i in ['jpg']:
- if i in exts:
- return i
- return exts[0]
-
-class SearchImage (RemoteImage):
-
- def __init__(me, url, ref, tburl):
- RemoteImage.__init__(me, url, ref)
- me._tburl = tburl
-
- @property
- def thumbnail(me):
- if not me._thumb:
- me._thumb = Thumbnail(RemoteImage(me._tburl))
- return me._thumb
-
-class SearchResult (SearchCover):
-
- def __init__(me, r):
- w = int(r['width'])
- h = int(r['height'])
- url = r['unescapedUrl']
- ref = r['originalContextUrl']
- tburl = r['tbUrl']
- me.img = SearchImage(url, ref, tburl)
- me.text = '%d×%d' % (w, h)
-
-class SearchFail (Exception):
- pass
-
-class CoverChooser (object):
-
- SEARCHURL = \
- 'http://ajax.googleapis.com/ajax/services/search/images?v=1.0&rsz=8&q='
-
- def __init__(me):
- me.win = GTK.Window()
- box = GTK.VBox()
- 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)
- me.sv = SearchViewer(me)
- panes = GTK.HPaned()
- panes.pack1(me.sv.scr, False, True)
- scr = GTK.ScrolledWindow()
- scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
- me.img = GTK.Image()
- evb = GTK.EventBox()
- 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)
- me.win.connect('destroy', me.destroyed)
- me.win.set_default_size(800, 550)
- srch.grab_default()
-
- def update(me, view, which, dir, current):
- me.view = view
- me.dir = dir
- 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):
- q = me.query.get_text()
- try:
- try:
- rq = U2.Request(me.SEARCHURL + U.quote_plus(q),
- None,
- { 'Referer':
- 'http://www.distorted.org.uk/~mdw/coverart' })
- rs = U2.urlopen(rq)
- except U2.URLError, e:
- raise SearchFail(e.reason)
- result = JS.load(rs)
- if result['responseStatus'] != 200:
- raise SearchFail('%s (status = %d)' %
- (result['responseDetails'],
- result['responseStatus']))
- d = result['responseData']
- me.sv.switch(me.current)
- for r in d['results']:
- try:
- me.sv.add(SearchResult(r))
- except (U2.URLError, U2.HTTPError):
- pass
- except SearchFail, e:
- print e.args[0]
-
- def makequery(me, path):
- bits = path.split(OS.path.sep)
- return ' '.join(['"%s"' % p for p in bits[-2:]])
-
- def selected(me, cov):
- if cov:
- me.img.set_from_pixbuf(cov.img.pixbuf)
- else:
- me.img.clear()
-
- def activated(me, cov):
- if isinstance(cov, SearchCover):
- me.view.replace(me.which, cov.img)
-
- def destroyed(me, w):
- global CHOOSER
- CHOOSER = None
-
-CHOOSER = None
-
-class ViewCover (object):
-
- NULLIMG = NullImage(THUMBSZ, '?')
-
- def __init__(me, dir, path, leaf):
- 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):
-
- ITERATTR = 'vit'
-
- def __init__(me, root):
- BaseCoverViewer.__init__(me)
- me.root = root
- me.walk('')
-
- def walk(me, dir):
- leafp = True
- b = OS.path.join(me.root, dir)
- imgfile = None
- for l in sorted(OS.listdir(b)):
- if OS.path.isdir(OS.path.join(b, l)):
- leafp = False
- me.walk(OS.path.join(dir, l))
- else:
- base, ext = OS.path.splitext(l)
- if base == 'cover' and ext in ['.jpg', '.png', '.gif']:
- imgfile = l
- if leafp:
- me.add(ViewCover(dir, OS.path.join(me.root, dir), imgfile))
-
- def select(me, cov):
- pass
-
- def activate(me, cov):
- global CHOOSER
- if not CHOOSER:
- CHOOSER = CoverChooser()
- CHOOSER.update(me, cov, cov.text, cov.covimg)
-
- def replace(me, cov, img):
- 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)
-
-ROOT = SYS.argv[1]
-
-LOOP = G.MainLoop()
-
-BLACK = GDK.Color(0, 0, 0)
-WHITE = GDK.Color(65535, 65535, 65535)
-
-WIN = GTK.Window()
-VIEW = MainViewer(ROOT)
-WIN.add(VIEW.scr)
-WIN.set_default_size(814, 660)
-WIN.set_title('coverart')
-WIN.connect('destroy', lambda _: LOOP.quit())
-WIN.show_all()
-
-LOOP.run()
--- /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 --------------------------------------------------