#! @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 --------------------------------------------------