2 ### -*- mode: python; coding: utf-8 -*-
4 ### Manage and update cover art for a music collection
6 ### (c) 2014 Mark Wooding
9 ###----- Licensing notice ---------------------------------------------------
11 ### This file is part of the `autoys' audio tools collection.
13 ### `autoys' is free software; you can redistribute it and/or modify
14 ### it under the terms of the GNU General Public License as published by
15 ### the Free Software Foundation; either version 2 of the License, or
16 ### (at your option) any later version.
18 ### `autoys' is distributed in the hope that it will be useful,
19 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
20 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 ### GNU General Public License for more details.
23 ### You should have received a copy of the GNU General Public License
24 ### along with `autoys'; if not, write to the Free Software Foundation,
25 ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
27 ###--------------------------------------------------------------------------
28 ### External dependencies.
31 from __future__ import with_statement
33 ## Standard Python libraries.
34 from cStringIO import StringIO
37 import os as OS; ENV = OS.environ
48 ###--------------------------------------------------------------------------
49 ### Theoretically tweakable parameters.
53 ###--------------------------------------------------------------------------
56 class ImageCache (object):
58 I maintain a cache of CacheableImage objects.
60 I evict images which haven't been used recently if the total size of the
61 image data I'm holding is larger than the threshold in my `THRESH'
66 ## _total = total size of image data in the cache, in bytes
67 ## _first, _last = head and tail of a linked list of CacheableImage objects
69 ## We make use of the link attributes _first and _next of CacheableImage
72 ## Notionally configurable parameters.
73 THRESH = 128*1024*1024
76 """Initialize an ImageCache object. The cache is initially empty."""
78 me._first = me._last = None
81 """Add IMG to the cache, possibly evicting old images."""
83 ## Update the cache size, and maybe evict images if we're over budget.
85 while me._first and me._total > me.THRESH: me._first.evict()
87 ## Link the new image into the list.
90 if me._last: me._last._next = img
96 Remove IMG from the cache.
98 This is usually a response to an eviction notice received by the image.
102 if img._prev: img._prev._next = img._next
103 else: me._first = img._next
104 if img._next: img._next._prev = img._prev
105 else: img._last = img._prev
107 ## Update the cache usage.
108 me._total -= img.size
110 ## We only need one cache, in practice, and here it is.
113 class CacheableImage (object):
115 I represent an image which can be retained in the ImageCache.
117 I'm an abstract class. Subclasses are expected to implement a method
118 `_acquire' which fetches the image's data from wherever it comes from and
119 returns it, as a Gdk Pixbuf object.
121 Cacheable images may also retain a thumbnail which is retained
122 until explicitly discarded.
125 ## Useful attributes:
126 ## _pixbuf = the pixbuf of the acquired image, or None
127 ## _thumb = the pixbuf of the thumbnail, or None
128 ## _next, _prev = forward and backward links in the ImageCache list
131 """Initialize the image."""
134 me._prev = me._next = None
139 Return the underlying image data, as a Gdk Pixbuf object.
141 The image data is acquired if necessary, and cached for later reuse.
144 me._pixbuf = me._acquire()
145 me.size = me._pixbuf.get_pixels_array().nbytes
150 """Discard the image data. This is usually a request by the cache."""
156 Discard the image data and thumbnail.
158 They will be regenerated again on demand.
165 """Return a Thumbnail object for the image."""
166 if not me._thumb: me._thumb = Thumbnail(me)
169 class Thumbnail (object):
171 I represent a reduced-size view of an image, suitable for showing in a big
174 My `pixbuf' attribute stores a Gdk Pixbuf of the thumbnail image.
177 def __init__(me, img):
179 Initialize the Thumbnail.
181 The thumbnail will contain a reduced-size view of the CacheableImage IMG.
184 wd, ht = pix.get_width(), pix.get_height()
189 twd, tht = [(x*THUMBSZ + m//2)//m for x in [wd, ht]]
190 me.pixbuf = pix.scale_simple(twd, tht, GDK.INTERP_HYPER)
192 ###--------------------------------------------------------------------------
193 ### Various kinds of image.
195 class NullImage (CacheableImage):
197 I represent a placeholder image.
199 I'm usually used because the proper image you actually wanted is
200 unavailable for some reason.
203 ## Useful attributes:
204 ## _size = size of the (square) image, in pixels
205 ## _text = the text to display in the image.
207 def __init__(me, size, text):
209 Construct a new NullImage.
211 The image will be a square, SIZE pixels along each side, and will display
214 CacheableImage.__init__(me)
219 """Render the placeholder image."""
221 ## Set up a drawing surface and context.
222 surf = XR.ImageSurface(XR.FORMAT_ARGB32, me._size, me._size)
223 xr = XR.Context(surf)
225 ## Choose an appropriate background colour and fill it in.
226 xr.set_source_rgb(0.3, 0.3, 0.3)
229 ## Choose an appropriately nondescript font and foreground colour.
230 xr.select_font_face('sans-serif',
231 XR.FONT_SLANT_NORMAL, XR.FONT_WEIGHT_BOLD)
232 xr.set_source_rgb(0.8, 0.8, 0.8)
234 ## Figure out how big the text is going to be, and therefore how to scale
235 ## it so that it fills the available space pleasingly.
236 xb, yb, wd, ht, xa, ya = xr.text_extents(me._text)
238 z = me._size/float(m) * 2.0/3.0
241 ## Render the text in the middle of the image.
242 xr.move_to(3.0*m/4.0 - wd/2.0 - xb, 3.0*m/4.0 - ht/2.0 - yb)
243 xr.show_text(me._text)
245 ## We're done drawing. Collect the image and capture it as a Pixbuf so
246 ## that everyone else can use it.
248 pix = GDK.pixbuf_new_from_data(surf.get_data(),
249 GDK.COLORSPACE_RGB, True, 8,
250 me._size, me._size, surf.get_stride())
253 class FileImage (CacheableImage):
255 I represent an image fetched from a (local disk) file.
258 ## Useful attributes:
259 ## _file = filename to read image data from
261 def __init__(me, file):
262 """Initialize a FileImage which reads its image data from FILE."""
263 CacheableImage.__init__(me)
267 """Acquire the image data."""
268 return GDK.pixbuf_new_from_file(me._file)
270 ###--------------------------------------------------------------------------
271 ### Miscellaneous utilities.
274 """Fetch the resource named by URL, returning its content as a string."""
276 with U.urlopen(url) as u:
278 stuff = u.read(16384)
281 return out.getvalue()
283 def fix_background(w):
284 """Hack the style of the window W so that it shows white-on-black."""
285 style = w.get_style().copy()
286 style.base[GTK.STATE_NORMAL] = BLACK
287 style.bg[GTK.STATE_NORMAL] = BLACK
288 style.text[GTK.STATE_NORMAL] = WHITE
291 ###--------------------------------------------------------------------------
294 class BaseCoverViewer (GTK.ScrolledWindow):
296 I represent a viewer for a collection of cover images, shown as thumbnails.
298 The image objects should have the following attributes.
300 img The actual image, as a CacheableImage.
302 text Some text to associate with the image, as a Python
305 I will store an `iterator' in the image object's `it' attribute, which will
306 let others identify it later to the underlying Gtk machinery.
308 Subclasses should implement two methods:
310 activate(COVER) The COVER has been activated by the user (e.g.,
313 select(COVER) The COVER has been selected by the user (e.g.,
314 clicked) in a temporary manner; if COVER is None,
315 then a previously selected cover has become
319 ## Useful attributes:
320 ## iv = an IconView widget used to show the thumbnails
321 ## list = a ListStore object used to keep track of the cover images; each
322 ## item is a list of the form [PIXBUF, TEXT, COVER], where the PIXBUF
323 ## and TEXT are extracted from the cover object in the obvious way
326 """Initialize a BaseCoverViewer."""
328 ## Initialize myself, as a scrollable thingy.
329 GTK.ScrolledWindow.__init__(me)
330 me.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
332 ## Set up an IconView to actually show the cover thumbnails.
333 me.iv = GTK.IconView()
334 me.iv.connect('item-activated',
335 lambda iv, p: me.activate(me._frompath(p)))
336 me.iv.connect('selection-changed', me._select)
337 me.iv.set_pixbuf_column(0)
338 me.iv.set_text_column(1)
339 me.iv.set_orientation(GTK.ORIENTATION_VERTICAL)
340 me.iv.set_item_width(THUMBSZ + 32)
341 fix_background(me.iv)
344 ## Clear the list ready for cover images to be added.
349 Clear the viewer of cover images.
351 This does /not/ clear the `it' attribute of previously attached cover
354 me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT)
355 me.iv.set_model(me.list)
358 def addcover(me, cov):
360 Add the cover image COV to the viewer.
362 `COV.it' is filled in with the object's iterator.
364 cov.it = me.list.append([cov.img.thumbnail.pixbuf, cov.text, cov])
366 def _frompath(me, path):
367 """Convert a PATH to a cover image, and return it."""
368 return me.list[path][2]
371 """Handle a selection event, calling the subclass `select' method."""
372 sel = me.iv.get_selected_items()
373 if len(sel) != 1: me.select(None)
374 else: me.select(me._frompath(sel[0]))
376 class SearchCover (object):
378 A base class for images in the SearchViewer window.
380 def __init__(me, img, width = None, height = None, marker = ''):
382 Initialize a SearchCover object.
384 The IMG is a CacheableImage of some kind. If the WIDTH and HEIGHT are
385 omitted, the image data will be acquired and both dimensions calculated
386 (since, after all, if we have to go to the bother of fetching the image,
387 we may as well get an accurate size).
390 if width is None or height is None:
392 width = pix.get_width()
393 height = pix.get_height()
394 me.text = '%d×%d%s' % (width, height, marker)
396 class SearchViewer (BaseCoverViewer):
398 I'm a BaseCoverViewer subclass for showing search results.
400 I'll be found within a CoverChooser window, showing the thumbnails
401 resulting from a search. I need to keep track of my parent CoverChooser,
402 so that I can tell it to show a large version of a selected image.
405 ## Useful attributes:
406 ## _chooser = the containing CoverChooser object
408 def __init__(me, chooser):
410 Initialize the SearchViewer, associating it with its parent CoverChooser.
412 BaseCoverViewer.__init__(me)
413 me._chooser = chooser
415 def switch(me, current):
417 Switch to a different album chosen in the CoverChooser.
419 CURRENT is either None, in which case the viewer is simply cleared, or
420 the current cover image (some CacheableImage object) for the newly chosen
421 album, which should be shown as the initial selection.
425 cov = SearchCover(current, marker = '*')
427 me.iv.select_path(me.list.get_path(cov.it))
429 def activate(me, cov):
430 """Inform the CoverChooser that the user activated COV."""
431 me._chooser.activated(cov)
434 """Inform the CoverChooser that the user selected COV."""
435 me._chooser.selected(cov)
437 class RemoteImage (CacheableImage):
439 I represent an image whose data can be fetched over the network.
442 ## Useful attributes:
443 ## _url = the URL to use to fetch the image
444 ## _ref = the referrer to set when fetching the image
445 ## _data = the image content from the server
446 ## _format = a PixbufFormat object describing the image format
448 ## On first acquire, we fetch the image data from the server the hard way,
449 ## but we retain this indefinitely, even if the pixbuf is evicted as a
450 ## result of the cache filling. The idea is that JPEG files (say) are
451 ## rather smaller than uncompressed pixbufs, and it's better to use up
452 ## local memory storing a JPEG than to have to fetch the whole thing over
453 ## the network again.
455 ## A dummy image used in place of the real thing if we encounter an error.
456 ERRIMG = NullImage(256, '!')
458 def __init__(me, url, ref = None):
460 Initialize the RemoteImage object.
462 Image data will be fetched from URL; if a REF is provided (and is not
463 None), then it will be presented as the `referer' when fetching the
466 CacheableImage.__init__(me)
473 Fetch the image data from the server, if necessary.
475 On success, the raw image data is stored in the `_data' attribute. Many
476 things can go wrong, though.
479 ## If we already have the image data, then use what we've got already.
482 ## Fetch the image data from the server.
484 rq = U2.Request(me._url)
485 if me._ref: rq.add_header('Referer', me._ref)
488 stuff = rs.read(16384)
491 me._data = d.getvalue()
493 ## Try to figure out what kind of image this is. With a bit of luck, we
494 ## can figure this out by spooning a bit of image data into a
496 ld = GDK.PixbufLoader()
501 if o >= n: raise ValueError, 'not going to work'
502 l = min(n, o + 16384)
503 ld.write(me._data[o:l])
509 ## If this is a GIF then bail now. They look terrible.
510 if 'image/gif' in f['mime_types']:
512 raise ValueError, 'boycotting GIF image'
515 ## Tidy up: we don't want the loader any more.
517 except G.GError: pass
521 Return a decoded image from the image data.
523 If this isn't going to work, return a dummy image.
527 ld = GDK.PixbufLoader()
528 try: ld.write(me._data)
530 return ld.get_pixbuf()
533 return me.ERRIMG.pixbuf
537 """Return a file extension for the image, in case we want to save it."""
538 ## If we can use `.jpg' then do that; otherwise, take whatever Gdk-Pixbuf
540 exts = me._format['extensions']
542 if i in exts: return i
545 class SearchImage (RemoteImage):
547 I represent an image found by searching the web.
550 ## Useful attributes:
551 ## _tburl = the thumbnail url
553 def __init__(me, url, ref, tburl):
555 Initialize a SearchImage object.
557 The URL and REF arguments are as for the base RemoteImage object; TBURL
558 is the location of a thumbnail image which (so the thinking goes) will be
559 smaller and hence faster to fetch than the main one.
561 RemoteImage.__init__(me, url, ref)
566 """Fetch a thumbnail separately, using the thumbnail URL provided."""
567 if not me._thumb: me._thumb = Thumbnail(RemoteImage(me._tburl))
570 class SearchResult (SearchCover):
572 I represent information about an image found while searching the web.
574 I'm responsible for parsing information about individual search hits from
578 """Initialize a SearchResult, given a decoded JSON fragment R."""
583 ref = i['contextLink']
584 tburl = i['thumbnailLink']
585 SearchCover.__init__(me, SearchImage(url, ref, tburl),
586 width = w, height = h)
588 class SearchFail (Exception):
589 """An exception found while trying to search for images."""
592 class CoverChooser (object):
594 I represent a window for choosing one of a number of cover art images.
596 I allow the user to choose one of a number of alternative images for an
597 album, and to search the Internet for more options.
599 I work on behalf of some other client object; I am responsible for
600 collecting the user's ultimate choice, but that responsibility ends when I
601 tell my client which image the user picked.
603 During my lifetime, I can be used to choosing images for different purposes
604 (possibly on behalf of different clients), though I can only work on one
605 thing at a time. I switch between jobs when `update' is called; see the
606 documentation of that method for details of the protocol.
608 I try to arrange that there's at most one instance of me, in the variable
612 ## Important attributes:
613 ## query = the entry widget for typing search terms
614 ## sv = the search viewer pane showing thumbnails of search results
615 ## img = the image preview pane showing a selected image at full size
616 ## win = our top-level window
617 ## view = the object which invoked us
618 ## which = the client's `which' parameter
619 ## current = the currently chosen image
621 ## This is a bit grim because search APIs and keys.
623 SEARCHID = '016674315927968770913:8blyelgp3wu'
624 REFERER = 'https://www.distorted.org.uk/~mdw/coverart'
627 def set_apikey(cls, apikey):
628 """Inform the class that it can use APIKEY to authorize its search."""
630 'https://www.googleapis.com/customsearch/v1?' \
631 'key=%s&cx=%s&searchType=image&q=' % (apikey, cls.SEARCHID)
634 """Initialize the window, but don't try to show it yet."""
637 me.win = GTK.Window()
639 ## The window layout is like this.
641 ## -----------------------------------
642 ## | [... ] [Search] |
643 ## |-----------------------------------|
653 ## -----------------------------------
655 ## Main layout box, with search stuff at the top and selection stuff
659 ## First, fill in the search stuff.
661 me.query = GTK.Entry()
662 top.pack_start(me.query, True, True, 2)
663 srch = GTK.Button('_Search')
664 srch.set_flags(GTK.CAN_DEFAULT)
665 srch.connect('clicked', me.search)
666 top.pack_start(srch, False, False, 2)
667 box.pack_start(top, False, False, 2)
669 ## Now the thumbnail viewer and preview below.
671 me.sv = SearchViewer(me)
672 panes.pack1(me.sv, False, True)
673 scr = GTK.ScrolledWindow()
674 scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
679 scr.add_with_viewport(evb)
680 panes.pack2(scr, True, True)
681 panes.set_position(THUMBSZ + 64)
682 box.pack_start(panes, True, True, 0)
685 ## Finally, configure some signal handlers.
686 me.win.connect('destroy', me.destroyed)
687 me.win.set_default_size(800, 550)
689 ## Set the default button. (Gtk makes us wait until the button has a
690 ## top-level window to live in.)
693 def update(me, view, which, dir, current):
695 Update the CoverChooser to choose a different album's cover image.
697 The VIEW is the client object. We will later call its `replace' method,
698 passing it WHICH and the newly chosen image (as a RemoteImage object).
699 WHICH is simply remembered as a context value for `update'. DIR is the
700 path to the album whose cover is to be chosen, used for constructing a
701 suitable search string. CURRENT is some kind of CacheableImage object
702 representing the currently chosen image we may be replacing.
708 me.sv.switch(current)
709 me.query.set_text(me.makequery(dir))
714 Instigate a web search for a replacement image.
716 W is the widget which was frobbed to invoke the search, but it's not very
720 ## Collect the search query.
721 q = me.query.get_text()
723 ## Try to ask a search provider for some likely images.
726 ## We won't get far if we've not been given an API key.
727 if me.SEARCHURL is None: raise SearchFail('no search key')
729 ## Collect the search result.
731 rq = U2.Request(me.SEARCHURL + U.quote_plus(q), None,
732 { 'Referer': me.REFERER })
734 except U2.URLError, e:
735 raise SearchFail(e.reason)
738 ## Clear out all of the images from the last search, leaving only the
739 ## incumbent choice, and then add the new images from the search
741 me.sv.switch(me.current)
742 for r in result['items']:
743 try: me.sv.addcover(SearchResult(r))
744 except (U2.URLError, U2.HTTPError): pass
746 ## Maybe that didn't work.
747 except SearchFail, e:
750 def makequery(me, path):
751 """Construct a default search query string for the chosen album."""
752 bits = path.split(OS.path.sep)
753 return ' '.join(['"%s"' % p for p in bits[-2:]])
755 def selected(me, cov):
757 Show a full-size version of COV in the preview pane.
759 Called by the SearchViewer when a thumbnail is selected.
761 if cov: me.img.set_from_pixbuf(cov.img.pixbuf)
764 def activated(me, cov):
766 Inform our client that COV has been chosen as the replacement image.
768 Called by the SearchViewer when a thumbnail is activated.
770 if isinstance(cov, SearchCover): me.view.replace(me.which, cov.img)
772 def destroyed(me, w):
774 Our widget has been destroyed.
776 We're going away, so clear out the reference to us.
781 ## There's currently no chooser.
784 class ViewCover (object):
786 I represent an album cover image found already in the filesystem.
788 I can be found in MainViewer windows.
791 ## Useful attributes:
792 ## text = the text (directory name) associated with the cover
793 ## path = the full pathname to this directory
794 ## leaf = the basename of the cover image, or None
796 ## An error image, for use when there isn't a usable image.
797 NULLIMG = NullImage(THUMBSZ, '?')
799 def __init__(me, dir, path, leaf):
801 Initialize a new image.
803 DIR is the directory containing the image, relative to the root of the
804 search; it's presented to the user to help them distinguish the similar
805 icons (or identical ones, if multiple directories lack cover images).
806 PATH is the full pathname to the directory, and is used when installing a
807 replacement cover. LEAF is the basename of the cover image in that
808 directory, or None if there currently isn't an image there.
814 me.img = me.covimg = FileImage(OS.path.join(me.path, me.leaf))
819 class MainViewer (BaseCoverViewer):
821 I'm a top-level cover viewer, showing thumbnails for the albums I can find
825 ## Useful attributes:
826 ## root = the root of the directory tree to manage
828 def __init__(me, root):
830 Initialize a viewer for choosing cover art in the tree headed by ROOT.
832 BaseCoverViewer.__init__(me)
838 Walk the directory tree from DIR down, adding icons for cover art I find
842 ## Assume this is a leaf directory for now.
845 ## Figure out the actual pathname we're looking at.
846 b = OS.path.join(me.root, dir)
848 ## The name of any image file we find.
851 ## Work through the items in the directory.
852 for l in sorted(OS.listdir(b)):
854 if OS.path.isdir(OS.path.join(b, l)):
855 ## If this item is a directory, then we're not leaf, but need to
856 ## descend recursively.
858 me.walk(OS.path.join(dir, l))
860 ## If this smells like a cover image then remember it. (If there are
861 ## multiple plausible options, just remember one more or less
863 base, ext = OS.path.splitext(l)
864 if base == 'cover' and ext in ['.jpg', '.png', '.gif']: imgfile = l
866 ## If this is a leaf directory, hopefully representing an album rather
867 ## than an artist or higher-level grouping, then add an icon representing
868 ## it, including any cover image that we found.
870 me.addcover(ViewCover(dir, OS.path.join(me.root, dir), imgfile))
873 """The user selected a cover icon, but we don't care."""
876 def activate(me, cov):
878 A cover icon was activated.
880 Allow the user to choose a replacement cover.
884 CHOOSER = CoverChooser()
885 CHOOSER.update(me, cov, cov.text, cov.covimg)
887 def replace(me, cov, img):
889 Replace the cover COV by the newly chosen image IMG.
891 This is called by the CoverChooser.
893 leaf = 'cover.%s' % img.ext
894 out = OS.path.join(cov.path, leaf)
896 with open(new, 'wb') as f:
899 if cov.leaf not in [None, leaf]:
900 OS.unlink(OS.path.join(cov.path, cov.leaf))
901 ncov = ViewCover(cov.text, cov.path, leaf)
903 me.list[ncov.it] = [ncov.img.thumbnail.pixbuf, ncov.text, ncov]
906 ###--------------------------------------------------------------------------
909 if __name__ == '__main__':
911 ## Try to find an API key for searching.
912 CONFROOT = ENV.get('XDG_CONFIG_HOME', None)
914 CONFROOT = OS.path.join(ENV['HOME'], '.config')
915 CONFDIR = OS.path.join(CONFROOT, 'autoys')
917 f = open(OS.path.join(CONFDIR, 'apikey'))
919 if e.errno == E.ENOENT: pass
923 apikey = f.readline().strip()
924 CoverChooser.set_apikey(apikey)
929 BLACK = GDK.Color(0, 0, 0)
930 WHITE = GDK.Color(65535, 65535, 65535)
932 ## Make a top-level window showing a MainView and display it.
934 VIEW = MainViewer(ROOT)
936 WIN.set_default_size(814, 660)
937 WIN.set_title('coverart')
938 WIN.connect('destroy', lambda _: LOOP.quit())
941 ## Carry on until there's nothing left to do.
944 ###----- That's all, folks --------------------------------------------------