chiark / gitweb /
coverart/: Prepare for proper release.
[autoys] / coverart / coverart.in
1 #! @PYTHON@
2 ### -*- mode: python; coding: utf-8 -*-
3 ###
4 ### Manage and update cover art for a music collection
5 ###
6 ### (c) 2014 Mark Wooding
7 ###
8
9 ###----- Licensing notice ---------------------------------------------------
10 ###
11 ### This file is part of the `autoys' audio tools collection.
12 ###
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.
17 ###
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.
22 ###
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.
26
27 ###--------------------------------------------------------------------------
28 ### External dependencies.
29
30 ## Language features.
31 from __future__ import with_statement
32
33 ## Standard Python libraries.
34 from cStringIO import StringIO
35 import errno as E
36 import json as JS
37 import os as OS; ENV = OS.environ
38 import sys as SYS
39 import urllib as U
40 import urllib2 as U2
41
42 ## GTK and friends.
43 import cairo as XR
44 import gobject as G
45 import gtk as GTK
46 GDK = GTK.gdk
47
48 ###--------------------------------------------------------------------------
49 ### Theoretically tweakable parameters.
50
51 THUMBSZ = 96
52
53 ###--------------------------------------------------------------------------
54 ### The image cache.
55
56 class ImageCache (object):
57   """
58   I maintain a cache of CacheableImage objects.
59
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'
62   attribute.
63   """
64
65   ## Useful attributes:
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
68   ##
69   ## We make use of the link attributes _first and _next of CacheableImage
70   ## objects.
71
72   ## Notionally configurable parameters.
73   THRESH = 128*1024*1024
74
75   def __init__(me):
76     """Initialize an ImageCache object.  The cache is initially empty."""
77     me._total = 0
78     me._first = me._last = None
79
80   def add(me, img):
81     """Add IMG to the cache, possibly evicting old images."""
82
83     ## Update the cache size, and maybe evict images if we're over budget.
84     me._total += img.size
85     while me._first and me._total > me.THRESH: me._first.evict()
86
87     ## Link the new image into the list.
88     img._prev = me._last
89     img._next = None
90     if me._last: me._last._next = img
91     else: me._first = img
92     me._last = img
93
94   def rm(me, img):
95     """
96     Remove IMG from the cache.
97
98     This is usually a response to an eviction notice received by the image.
99     """
100
101     ## Unlink 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
106
107     ## Update the cache usage.
108     me._total -= img.size
109
110 ## We only need one cache, in practice, and here it is.
111 CACHE = ImageCache()
112
113 class CacheableImage (object):
114   """
115   I represent an image which can be retained in the ImageCache.
116
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.
120
121   Cacheable images may also retain a thumbnail which is retained
122   until explicitly discarded.
123   """
124
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
129
130   def __init__(me):
131     """Initialize the image."""
132     me._pixbuf = None
133     me._thumb = None
134     me._prev = me._next = None
135
136   @property
137   def pixbuf(me):
138     """
139     Return the underlying image data, as a Gdk Pixbuf object.
140
141     The image data is acquired if necessary, and cached for later reuse.
142     """
143     if not me._pixbuf:
144       me._pixbuf = me._acquire()
145       me.size = me._pixbuf.get_pixels_array().nbytes
146       CACHE.add(me)
147     return me._pixbuf
148
149   def evict(me):
150     """Discard the image data.  This is usually a request by the cache."""
151     me._pixbuf = None
152     CACHE.rm(me)
153
154   def flush(me):
155     """
156     Discard the image data and thumbnail.
157
158     They will be regenerated again on demand.
159     """
160     me.evict()
161     me._thumb = None
162
163   @property
164   def thumbnail(me):
165     """Return a Thumbnail object for the image."""
166     if not me._thumb: me._thumb = Thumbnail(me)
167     return me._thumb
168
169 class Thumbnail (object):
170   """
171   I represent a reduced-size view of an image, suitable for showing in a big
172   gallery.
173
174   My `pixbuf' attribute stores a Gdk Pixbuf of the thumbnail image.
175   """
176
177   def __init__(me, img):
178     """
179     Initialize the Thumbnail.
180
181     The thumbnail will contain a reduced-size view of the CacheableImage IMG.
182     """
183     pix = img.pixbuf
184     wd, ht = pix.get_width(), pix.get_height()
185     m = max(wd, ht)
186     if m <= THUMBSZ:
187       me.pixbuf = pix
188     else:
189       twd, tht = [(x*THUMBSZ + m//2)//m for x in [wd, ht]]
190       me.pixbuf = pix.scale_simple(twd, tht, GDK.INTERP_HYPER)
191
192 ###--------------------------------------------------------------------------
193 ### Various kinds of image.
194
195 class NullImage (CacheableImage):
196   """
197   I represent a placeholder image.
198
199   I'm usually used because the proper image you actually wanted is
200   unavailable for some reason.
201   """
202
203   ## Useful attributes:
204   ## _size = size of the (square) image, in pixels
205   ## _text = the text to display in the image.
206
207   def __init__(me, size, text):
208     """
209     Construct a new NullImage.
210
211     The image will be a square, SIZE pixels along each side, and will display
212     the given TEXT.
213     """
214     CacheableImage.__init__(me)
215     me._size = size
216     me._text = text
217
218   def _acquire(me):
219     """Render the placeholder image."""
220
221     ## Set up a drawing surface and context.
222     surf = XR.ImageSurface(XR.FORMAT_ARGB32, me._size, me._size)
223     xr = XR.Context(surf)
224
225     ## Choose an appropriate background colour and fill it in.
226     xr.set_source_rgb(0.3, 0.3, 0.3)
227     xr.paint()
228
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)
233
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)
237     m = max(wd, ht)
238     z = me._size/float(m) * 2.0/3.0
239     xr.scale(z, z)
240
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)
244
245     ## We're done drawing.  Collect the image and capture it as a Pixbuf so
246     ## that everyone else can use it.
247     surf.flush()
248     pix = GDK.pixbuf_new_from_data(surf.get_data(),
249                                    GDK.COLORSPACE_RGB, True, 8,
250                                    me._size, me._size, surf.get_stride())
251     return pix
252
253 class FileImage (CacheableImage):
254   """
255   I represent an image fetched from a (local disk) file.
256   """
257
258   ## Useful attributes:
259   ## _file = filename to read image data from
260
261   def __init__(me, file):
262     """Initialize a FileImage which reads its image data from FILE."""
263     CacheableImage.__init__(me)
264     me._file = file
265
266   def _acquire(me):
267     """Acquire the image data."""
268     return GDK.pixbuf_new_from_file(me._file)
269
270 ###--------------------------------------------------------------------------
271 ### Miscellaneous utilities.
272
273 def fetch_url(url):
274   """Fetch the resource named by URL, returning its content as a string."""
275   out = StringIO()
276   with U.urlopen(url) as u:
277     while True:
278       stuff = u.read(16384)
279       if not stuff: break
280       out.write(stuff)
281   return out.getvalue()
282
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
289   w.set_style(style)
290
291 ###--------------------------------------------------------------------------
292 ### The windows.
293
294 class BaseCoverViewer (GTK.ScrolledWindow):
295   """
296   I represent a viewer for a collection of cover images, shown as thumbnails.
297
298   The image objects should have the following attributes.
299
300   img                   The actual image, as a CacheableImage.
301
302   text                  Some text to associate with the image, as a Python
303                         string.
304
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.
307
308   Subclasses should implement two methods:
309
310   activate(COVER)       The COVER has been activated by the user (e.g.,
311                         double-clicked).
312
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
316                         unselected.
317   """
318
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
324
325   def __init__(me):
326     """Initialize a BaseCoverViewer."""
327
328     ## Initialize myself, as a scrollable thingy.
329     GTK.ScrolledWindow.__init__(me)
330     me.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
331
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)
342     me.add(me.iv)
343
344     ## Clear the list ready for cover images to be added.
345     me.reset()
346
347   def reset(me):
348     """
349     Clear the viewer of cover images.
350
351     This does /not/ clear the `it' attribute of previously attached cover
352     object.
353     """
354     me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT)
355     me.iv.set_model(me.list)
356     me.iv.unselect_all()
357
358   def addcover(me, cov):
359     """
360     Add the cover image COV to the viewer.
361
362     `COV.it' is filled in with the object's iterator.
363     """
364     cov.it = me.list.append([cov.img.thumbnail.pixbuf, cov.text, cov])
365
366   def _frompath(me, path):
367     """Convert a PATH to a cover image, and return it."""
368     return me.list[path][2]
369
370   def _select(me, iv):
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]))
375
376 class SearchCover (object):
377   """
378   A base class for images in the SearchViewer window.
379   """
380   def __init__(me, img, width = None, height = None, marker = ''):
381     """
382     Initialize a SearchCover object.
383
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).
388     """
389     me.img = img
390     if width is None or height is None:
391       pix = img.pixbuf
392       width = pix.get_width()
393       height = pix.get_height()
394     me.text = '%d×%d%s' % (width, height, marker)
395
396 class SearchViewer (BaseCoverViewer):
397   """
398   I'm a BaseCoverViewer subclass for showing search results.
399
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.
403   """
404
405   ## Useful attributes:
406   ## _chooser = the containing CoverChooser object
407
408   def __init__(me, chooser):
409     """
410     Initialize the SearchViewer, associating it with its parent CoverChooser.
411     """
412     BaseCoverViewer.__init__(me)
413     me._chooser = chooser
414
415   def switch(me, current):
416     """
417     Switch to a different album chosen in the CoverChooser.
418
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.
422     """
423     me.reset()
424     if current:
425       cov = SearchCover(current, marker = '*')
426       me.addcover(cov)
427       me.iv.select_path(me.list.get_path(cov.it))
428
429   def activate(me, cov):
430     """Inform the CoverChooser that the user activated COV."""
431     me._chooser.activated(cov)
432
433   def select(me, cov):
434     """Inform the CoverChooser that the user selected COV."""
435     me._chooser.selected(cov)
436
437 class RemoteImage (CacheableImage):
438   """
439   I represent an image whose data can be fetched over the network.
440   """
441
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
447   ##
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.
454
455   ## A dummy image used in place of the real thing if we encounter an error.
456   ERRIMG = NullImage(256, '!')
457
458   def __init__(me, url, ref = None):
459     """
460     Initialize the RemoteImage object.
461
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
464     image.
465     """
466     CacheableImage.__init__(me)
467     me._url = url
468     me._ref = ref
469     me._data = None
470
471   def _fetch(me):
472     """
473     Fetch the image data from the server, if necessary.
474
475     On success, the raw image data is stored in the `_data' attribute.  Many
476     things can go wrong, though.
477     """
478
479     ## If we already have the image data, then use what we've got already.
480     if me._data: return
481
482     ## Fetch the image data from the server.
483     d = StringIO()
484     rq = U2.Request(me._url)
485     if me._ref: rq.add_header('Referer', me._ref)
486     rs = U2.urlopen(rq)
487     while True:
488       stuff = rs.read(16384)
489       if not stuff: break
490       d.write(stuff)
491     me._data = d.getvalue()
492
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
495     ## PixbufLoader.
496     ld = GDK.PixbufLoader()
497     try:
498       o = 0
499       n = len(me._data)
500       while True:
501         if o >= n: raise ValueError, 'not going to work'
502         l = min(n, o + 16384)
503         ld.write(me._data[o:l])
504         o = l
505         f = ld.get_format()
506         if f: break
507       me._format = f
508
509       ## If this is a GIF then bail now.  They look terrible.
510       if 'image/gif' in f['mime_types']:
511         me._data = None
512         raise ValueError, 'boycotting GIF image'
513
514     finally:
515       ## Tidy up: we don't want the loader any more.
516       try: ld.close()
517       except G.GError: pass
518
519   def _acquire(me):
520     """
521     Return a decoded image from the image data.
522
523     If this isn't going to work, return a dummy image.
524     """
525     try:
526       me._fetch()
527       ld = GDK.PixbufLoader()
528       try: ld.write(me._data)
529       finally: ld.close()
530       return ld.get_pixbuf()
531     except Exception, e:
532       print e
533       return me.ERRIMG.pixbuf
534
535   @property
536   def ext(me):
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
539     ## suggests.
540     exts = me._format['extensions']
541     for i in ['jpg']:
542       if i in exts: return i
543     return exts[0]
544
545 class SearchImage (RemoteImage):
546   """
547   I represent an image found by searching the web.
548   """
549
550   ## Useful attributes:
551   ## _tburl = the thumbnail url
552
553   def __init__(me, url, ref, tburl):
554     """
555     Initialize a SearchImage object.
556
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.
560     """
561     RemoteImage.__init__(me, url, ref)
562     me._tburl = tburl
563
564   @property
565   def thumbnail(me):
566     """Fetch a thumbnail separately, using the thumbnail URL provided."""
567     if not me._thumb: me._thumb = Thumbnail(RemoteImage(me._tburl))
568     return me._thumb
569
570 class SearchResult (SearchCover):
571   """
572   I represent information about an image found while searching the web.
573
574   I'm responsible for parsing information about individual search hits from
575   the result.
576   """
577   def __init__(me, r):
578     """Initialize a SearchResult, given a decoded JSON fragment R."""
579     i = r['image']
580     w = int(i['width'])
581     h = int(i['height'])
582     url = r['link']
583     ref = i['contextLink']
584     tburl = i['thumbnailLink']
585     SearchCover.__init__(me, SearchImage(url, ref, tburl),
586                          width = w, height = h)
587
588 class SearchFail (Exception):
589   """An exception found while trying to search for images."""
590   pass
591
592 class CoverChooser (object):
593   """
594   I represent a window for choosing one of a number of cover art images.
595
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.
598
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.
602
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.
607
608   I try to arrange that there's at most one instance of me, in the variable
609   `CHOOSER'.
610   """
611
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
620
621   ## This is a bit grim because search APIs and keys.
622   SEARCHURL = None
623   SEARCHID = '016674315927968770913:8blyelgp3wu'
624   REFERER = 'https://www.distorted.org.uk/~mdw/coverart'
625
626   @classmethod
627   def set_apikey(cls, apikey):
628     """Inform the class that it can use APIKEY to authorize its search."""
629     cls.SEARCHURL = \
630       'https://www.googleapis.com/customsearch/v1?' \
631       'key=%s&cx=%s&searchType=image&q=' % (apikey, cls.SEARCHID)
632
633   def __init__(me):
634     """Initialize the window, but don't try to show it yet."""
635
636     ## Make a window.
637     me.win = GTK.Window()
638
639     ## The window layout is like this.
640     ##
641     ##           -----------------------------------
642     ##          | [...                   ] [Search] |
643     ##          |-----------------------------------|
644     ##          | thumbs  |                         |
645     ##          |    .    |                         |
646     ##          |    .    |          image          |
647     ##          |    .    |                         |
648     ##          |         |         preview         |
649     ##          |         |                         |
650     ##          |         |                         |
651     ##          |         |                         |
652     ##          |         |                         |
653     ##           -----------------------------------
654
655     ## Main layout box, with search stuff at the top and selection stuff
656     ## below.
657     box = GTK.VBox()
658
659     ## First, fill in the search stuff.
660     top = GTK.HBox()
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)
668
669     ## Now the thumbnail viewer and preview below.
670     panes = GTK.HPaned()
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)
675     evb = GTK.EventBox()
676     me.img = GTK.Image()
677     evb.add(me.img)
678     fix_background(evb)
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)
683     me.win.add(box)
684
685     ## Finally, configure some signal handlers.
686     me.win.connect('destroy', me.destroyed)
687     me.win.set_default_size(800, 550)
688
689     ## Set the default button.  (Gtk makes us wait until the button has a
690     ## top-level window to live in.)
691     srch.grab_default()
692
693   def update(me, view, which, dir, current):
694     """
695     Update the CoverChooser to choose a different album's cover image.
696
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.
703     """
704     me.view = view
705     me.which = which
706     me.current = current
707     me.img.clear()
708     me.sv.switch(current)
709     me.query.set_text(me.makequery(dir))
710     me.win.show_all()
711
712   def search(me, w):
713     """
714     Instigate a web search for a replacement image.
715
716     W is the widget which was frobbed to invoke the search, but it's not very
717     interesting.
718     """
719
720     ## Collect the search query.
721     q = me.query.get_text()
722
723     ## Try to ask a search provider for some likely images.
724     try:
725
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')
728
729       ## Collect the search result.
730       try:
731         rq = U2.Request(me.SEARCHURL + U.quote_plus(q), None,
732                         { 'Referer': me.REFERER })
733         rs = U2.urlopen(rq)
734       except U2.URLError, e:
735         raise SearchFail(e.reason)
736       result = JS.load(rs)
737
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
740       ## results.
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
745
746     ## Maybe that didn't work.
747     except SearchFail, e:
748       print e.args[0]
749
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:]])
754
755   def selected(me, cov):
756     """
757     Show a full-size version of COV in the preview pane.
758
759     Called by the SearchViewer when a thumbnail is selected.
760     """
761     if cov: me.img.set_from_pixbuf(cov.img.pixbuf)
762     else: me.img.clear()
763
764   def activated(me, cov):
765     """
766     Inform our client that COV has been chosen as the replacement image.
767
768     Called by the SearchViewer when a thumbnail is activated.
769     """
770     if isinstance(cov, SearchCover): me.view.replace(me.which, cov.img)
771
772   def destroyed(me, w):
773     """
774     Our widget has been destroyed.
775
776     We're going away, so clear out the reference to us.
777     """
778     global CHOOSER
779     CHOOSER = None
780
781 ## There's currently no chooser.
782 CHOOSER = None
783
784 class ViewCover (object):
785   """
786   I represent an album cover image found already in the filesystem.
787
788   I can be found in MainViewer windows.
789   """
790
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
795
796   ## An error image, for use when there isn't a usable image.
797   NULLIMG = NullImage(THUMBSZ, '?')
798
799   def __init__(me, dir, path, leaf):
800     """
801     Initialize a new image.
802
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.
809     """
810     me.text = dir
811     me.path = path
812     me.leaf = leaf
813     if me.leaf:
814       me.img = me.covimg = FileImage(OS.path.join(me.path, me.leaf))
815     else:
816       me.img = me.NULLIMG
817       me.covimg = None
818
819 class MainViewer (BaseCoverViewer):
820   """
821   I'm a top-level cover viewer, showing thumbnails for the albums I can find
822   in the search tree.
823   """
824
825   ## Useful attributes:
826   ## root = the root of the directory tree to manage
827
828   def __init__(me, root):
829     """
830     Initialize a viewer for choosing cover art in the tree headed by ROOT.
831     """
832     BaseCoverViewer.__init__(me)
833     me.root = root
834     me.walk('')
835
836   def walk(me, dir):
837     """
838     Walk the directory tree from DIR down, adding icons for cover art I find
839     (or don't find).
840     """
841
842     ## Assume this is a leaf directory for now.
843     leafp = True
844
845     ## Figure out the actual pathname we're looking at.
846     b = OS.path.join(me.root, dir)
847
848     ## The name of any image file we find.
849     imgfile = None
850
851     ## Work through the items in the directory.
852     for l in sorted(OS.listdir(b)):
853
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.
857         leafp = False
858         me.walk(OS.path.join(dir, l))
859       else:
860         ## If this smells like a cover image then remember it.  (If there are
861         ## multiple plausible options, just remember one more or less
862         ## arbitrarily.)
863         base, ext = OS.path.splitext(l)
864         if base == 'cover' and ext in ['.jpg', '.png', '.gif']: imgfile = l
865
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.
869     if leafp:
870       me.addcover(ViewCover(dir, OS.path.join(me.root, dir), imgfile))
871
872   def select(me, cov):
873     """The user selected a cover icon, but we don't care."""
874     pass
875
876   def activate(me, cov):
877     """
878     A cover icon was activated.
879
880     Allow the user to choose a replacement cover.
881     """
882     global CHOOSER
883     if not CHOOSER:
884       CHOOSER = CoverChooser()
885     CHOOSER.update(me, cov, cov.text, cov.covimg)
886
887   def replace(me, cov, img):
888     """
889     Replace the cover COV by the newly chosen image IMG.
890
891     This is called by the CoverChooser.
892     """
893     leaf = 'cover.%s' % img.ext
894     out = OS.path.join(cov.path, leaf)
895     new = out + '.new'
896     with open(new, 'wb') as f:
897       f.write(img._data)
898     OS.rename(new, out)
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)
902     ncov.it = cov.it
903     me.list[ncov.it] = [ncov.img.thumbnail.pixbuf, ncov.text, ncov]
904     me.activate(ncov)
905
906 ###--------------------------------------------------------------------------
907 ### Main program.
908
909 if __name__ == '__main__':
910
911   ## Try to find an API key for searching.
912   CONFROOT = ENV.get('XDG_CONFIG_HOME', None)
913   if CONFROOT is None:
914     CONFROOT = OS.path.join(ENV['HOME'], '.config')
915   CONFDIR = OS.path.join(CONFROOT, 'autoys')
916   try:
917     f = open(OS.path.join(CONFDIR, 'apikey'))
918   except IOError, e:
919     if e.errno == E.ENOENT: pass
920     else: raise
921   else:
922     with f:
923       apikey = f.readline().strip()
924       CoverChooser.set_apikey(apikey)
925
926   ## Set things up.
927   ROOT = SYS.argv[1]
928   LOOP = G.MainLoop()
929   BLACK = GDK.Color(0, 0, 0)
930   WHITE = GDK.Color(65535, 65535, 65535)
931
932   ## Make a top-level window showing a MainView and display it.
933   WIN = GTK.Window()
934   VIEW = MainViewer(ROOT)
935   WIN.add(VIEW)
936   WIN.set_default_size(814, 660)
937   WIN.set_title('coverart')
938   WIN.connect('destroy', lambda _: LOOP.quit())
939   WIN.show_all()
940
941   ## Carry on until there's nothing left to do.
942   LOOP.run()
943
944 ###----- That's all, folks --------------------------------------------------