From 86e491b0afbcd651478d6d9fb1f85ebef20562d6 Mon Sep 17 00:00:00 2001 From: Mark Wooding Date: Sat, 13 Feb 2016 19:33:40 +0000 Subject: [PATCH] coverart/: Prepare for proper release. Substantial reorganization, and a lot of internal documentation. --- Makefile.am | 2 + configure.ac | 1 + coverart/Makefile.am | 45 +++ coverart/coverart | 483 ------------------------- coverart/coverart.in | 944 ++++++++++++++++++++++++++++++++++++++++++++++++ debian/control | 11 + debian/coverart.install | 1 + 7 files changed, 1004 insertions(+), 483 deletions(-) create mode 100644 coverart/Makefile.am delete mode 100755 coverart/coverart create mode 100644 coverart/coverart.in create mode 100644 debian/coverart.install diff --git a/Makefile.am b/Makefile.am index c5cc0b8..c53ffcd 100644 --- a/Makefile.am +++ b/Makefile.am @@ -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 -------------------------------------------------- diff --git a/configure.ac b/configure.ac index b20a56d..e690d6d 100644 --- a/configure.ac +++ b/configure.ac @@ -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 index 0000000..350cc25 --- /dev/null +++ b/coverart/Makefile.am @@ -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 index 789b06d..0000000 --- a/coverart/coverart +++ /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 index 0000000..9c1072c --- /dev/null +++ b/coverart/coverart.in @@ -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 -------------------------------------------------- diff --git a/debian/control b/debian/control index 9181280..458b77f 100644 --- a/debian/control +++ b/debian/control @@ -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 index 0000000..912932e --- /dev/null +++ b/debian/coverart.install @@ -0,0 +1 @@ +usr/bin/coverart -- 2.11.0