coverart/: Prepare for proper release.
authorMark Wooding <mdw@distorted.org.uk>
Sat, 13 Feb 2016 19:33:40 +0000 (19:33 +0000)
committerMark Wooding <mdw@distorted.org.uk>
Tue, 16 Feb 2016 19:56:01 +0000 (19:56 +0000)
Substantial reorganization, and a lot of internal documentation.

Makefile.am
configure.ac
coverart/Makefile.am [new file with mode: 0644]
coverart/coverart [deleted file]
coverart/coverart.in [new file with mode: 0644]
debian/control
debian/coverart.install [new file with mode: 0644]

index c5cc0b8..c53ffcd 100644 (file)
@@ -30,6 +30,7 @@ SUBDIRS                        =
 ###--------------------------------------------------------------------------
 ### Subdirectories.
 
+SUBDIRS                        += coverart
 SUBDIRS                        += gremlin
 
 ###--------------------------------------------------------------------------
@@ -53,6 +54,7 @@ EXTRA_DIST            += debian/copyright
 EXTRA_DIST             += debian/compat
 EXTRA_DIST             += debian/source/format
 
+EXTRA_DIST             += debian/coverart.install
 EXTRA_DIST             += debian/gremlin.install
 
 ###----- That's all, folks --------------------------------------------------
index b20a56d..e690d6d 100644 (file)
@@ -66,6 +66,7 @@ dnl Output.
 
 AC_CONFIG_FILES(
   [Makefile]
+  [coverart/Makefile]
   [gremlin/Makefile])
 AC_OUTPUT
 
diff --git a/coverart/Makefile.am b/coverart/Makefile.am
new file mode 100644 (file)
index 0000000..350cc25
--- /dev/null
@@ -0,0 +1,45 @@
+### -*-makefile-*-
+###
+### Build script for the coverart selector
+###
+### (c) 2016 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.
+
+include $(top_srcdir)/vars.am
+
+###--------------------------------------------------------------------------
+### The gremlin.
+
+if HAVE_PYTHON
+
+bin_SCRIPTS            += coverart
+CLEANFILES             += coverart
+EXTRA_DIST             += coverart.in
+
+coverart: coverart.in
+       $(SUBST) $(srcdir)/coverart.in >$@.new $(SUBSTITUTIONS) && \
+               chmod +x $@.new && mv $@.new $@
+
+##dist_man_MANS                += coverart.1
+
+endif
+
+###----- That's all, folks --------------------------------------------------
diff --git a/coverart/coverart b/coverart/coverart
deleted file mode 100755 (executable)
index 789b06d..0000000
+++ /dev/null
@@ -1,483 +0,0 @@
-#! /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()
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 --------------------------------------------------
index 9181280..458b77f 100644 (file)
@@ -9,9 +9,20 @@ Package: autoys
 Architecture: all
 Section: sound
 Depends:
+       coverart,
        gremlin
 Description: A convenience package which depends on the other `autoys' packages.
 
+Package: coverart
+Architecture: all
+Section: sound
+Depends: ${python:Depends},
+       python-cairo, python-gobject-2, python-gtk2
+Description: Choose cover art for albums stored in a directory tree.
+ The `coverart' program is a simple graphical tool for browsing the existing
+ cover art files in a music collection, and for searching the Internet for
+ (replacement or new) cover art.
+
 Package: gremlin
 Architecture: all
 Section: sound
diff --git a/debian/coverart.install b/debian/coverart.install
new file mode 100644 (file)
index 0000000..912932e
--- /dev/null
@@ -0,0 +1 @@
+usr/bin/coverart