chiark / gitweb /
gremlin/gremlin.in: Use `locale.getpreferredencoding'.
[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 optparse as OP
38 import os as OS; ENV = OS.environ
39 import sys as SYS
40 import urllib as U
41 import urllib2 as U2
42
43 ## GTK and friends.
44 import cairo as XR
45 import gobject as G
46 import gtk as GTK
47 GDK = GTK.gdk
48
49 ###--------------------------------------------------------------------------
50 ### Build-time configuration.
51
52 VERSION = '@VERSION@'
53
54 ###--------------------------------------------------------------------------
55 ### Theoretically tweakable parameters.
56
57 THUMBSZ = 96
58
59 ###--------------------------------------------------------------------------
60 ### Utilities.
61
62 def whinge(msg):
63   """Tell the user of some unfortunate circumstance described by MSG."""
64   dlg = GTK.MessageDialog(None, 0, GTK.MESSAGE_ERROR, GTK.BUTTONS_NONE, msg)
65   dlg.set_title('%s Error' % PROG)
66   dlg.add_button('_Bummer', 0)
67   dlg.run()
68   dlg.destroy()
69
70 def fetch_url(url):
71   """Fetch the resource named by URL, returning its content as a string."""
72   out = StringIO()
73   with U.urlopen(url) as u:
74     while True:
75       stuff = u.read(16384)
76       if not stuff: break
77       out.write(stuff)
78   return out.getvalue()
79
80 def fix_background(w):
81   """Hack the style of the window W so that it shows white-on-black."""
82   style = w.get_style().copy()
83   style.base[GTK.STATE_NORMAL] = BLACK
84   style.bg[GTK.STATE_NORMAL] = BLACK
85   style.text[GTK.STATE_NORMAL] = WHITE
86   w.set_style(style)
87
88 ###--------------------------------------------------------------------------
89 ### The image cache.
90
91 class ImageCache (object):
92   """
93   I maintain a cache of CacheableImage objects.
94
95   I evict images which haven't been used recently if the total size of the
96   image data I'm holding is larger than the threshold in my `THRESH'
97   attribute.
98   """
99
100   ## Useful attributes:
101   ## _total = total size of image data in the cache, in bytes
102   ## _first, _last = head and tail of a linked list of CacheableImage objects
103   ##
104   ## We make use of the link attributes _first and _next of CacheableImage
105   ## objects.
106
107   ## Notionally configurable parameters.
108   THRESH = 128*1024*1024
109
110   def __init__(me):
111     """Initialize an ImageCache object.  The cache is initially empty."""
112     me._total = 0
113     me._first = me._last = None
114
115   def add(me, img):
116     """Add IMG to the cache, possibly evicting old images."""
117
118     ## Update the cache size, and maybe evict images if we're over budget.
119     me._total += img.size
120     while me._first and me._total > me.THRESH: me._first.evict()
121
122     ## Link the new image into the list.
123     img._prev = me._last
124     img._next = None
125     if me._last: me._last._next = img
126     else: me._first = img
127     me._last = img
128
129   def rm(me, img):
130     """
131     Remove IMG from the cache.
132
133     This is usually a response to an eviction notice received by the image.
134     """
135
136     ## Unlink the image.
137     if img._prev: img._prev._next = img._next
138     else: me._first = img._next
139     if img._next: img._next._prev = img._prev
140     else: img._last = img._prev
141
142     ## Update the cache usage.
143     me._total -= img.size
144
145 ## We only need one cache, in practice, and here it is.
146 CACHE = ImageCache()
147
148 class CacheableImage (object):
149   """
150   I represent an image which can be retained in the ImageCache.
151
152   I'm an abstract class.  Subclasses are expected to implement a method
153   `_acquire' which fetches the image's data from wherever it comes from and
154   returns it, as a Gdk Pixbuf object.
155
156   Cacheable images may also retain a thumbnail which is retained
157   until explicitly discarded.
158   """
159
160   ## Useful attributes:
161   ## _pixbuf = the pixbuf of the acquired image, or None
162   ## _thumb = the pixbuf of the thumbnail, or None
163   ## _next, _prev = forward and backward links in the ImageCache list
164
165   def __init__(me):
166     """Initialize the image."""
167     me._pixbuf = None
168     me._thumb = None
169     me._prev = me._next = None
170
171   @property
172   def pixbuf(me):
173     """
174     Return the underlying image data, as a Gdk Pixbuf object.
175
176     The image data is acquired if necessary, and cached for later reuse.
177     """
178     if not me._pixbuf:
179       me._pixbuf = me._acquire()
180       me.size = me._pixbuf.get_pixels_array().nbytes
181       CACHE.add(me)
182     return me._pixbuf
183
184   def evict(me):
185     """Discard the image data.  This is usually a request by the cache."""
186     me._pixbuf = None
187     CACHE.rm(me)
188
189   def flush(me):
190     """
191     Discard the image data and thumbnail.
192
193     They will be regenerated again on demand.
194     """
195     me.evict()
196     me._thumb = None
197
198   @property
199   def thumbnail(me):
200     """Return a Thumbnail object for the image."""
201     if not me._thumb: me._thumb = Thumbnail(me)
202     return me._thumb
203
204 class Thumbnail (object):
205   """
206   I represent a reduced-size view of an image, suitable for showing in a big
207   gallery.
208
209   My `pixbuf' attribute stores a Gdk Pixbuf of the thumbnail image.
210   """
211
212   def __init__(me, img):
213     """
214     Initialize the Thumbnail.
215
216     The thumbnail will contain a reduced-size view of the CacheableImage IMG.
217     """
218     pix = img.pixbuf
219     wd, ht = pix.get_width(), pix.get_height()
220     m = max(wd, ht)
221     if m <= THUMBSZ:
222       me.pixbuf = pix
223     else:
224       twd, tht = [(x*THUMBSZ + m//2)//m for x in [wd, ht]]
225       me.pixbuf = pix.scale_simple(twd, tht, GDK.INTERP_HYPER)
226
227 ###--------------------------------------------------------------------------
228 ### Various kinds of image.
229
230 class NullImage (CacheableImage):
231   """
232   I represent a placeholder image.
233
234   I'm usually used because the proper image you actually wanted is
235   unavailable for some reason.
236   """
237
238   ## Useful attributes:
239   ## _size = size of the (square) image, in pixels
240   ## _text = the text to display in the image.
241
242   def __init__(me, size, text):
243     """
244     Construct a new NullImage.
245
246     The image will be a square, SIZE pixels along each side, and will display
247     the given TEXT.
248     """
249     CacheableImage.__init__(me)
250     me._size = size
251     me._text = text
252
253   def _acquire(me):
254     """Render the placeholder image."""
255
256     ## Set up a drawing surface and context.
257     surf = XR.ImageSurface(XR.FORMAT_ARGB32, me._size, me._size)
258     xr = XR.Context(surf)
259
260     ## Choose an appropriate background colour and fill it in.
261     xr.set_source_rgb(0.3, 0.3, 0.3)
262     xr.paint()
263
264     ## Choose an appropriately nondescript font and foreground colour.
265     xr.select_font_face('sans-serif',
266                         XR.FONT_SLANT_NORMAL, XR.FONT_WEIGHT_BOLD)
267     xr.set_source_rgb(0.8, 0.8, 0.8)
268
269     ## Figure out how big the text is going to be, and therefore how to scale
270     ## it so that it fills the available space pleasingly.
271     xb, yb, wd, ht, xa, ya = xr.text_extents(me._text)
272     m = max(wd, ht)
273     z = me._size/float(m) * 2.0/3.0
274     xr.scale(z, z)
275
276     ## Render the text in the middle of the image.
277     xr.move_to(3.0*m/4.0 - wd/2.0 - xb, 3.0*m/4.0 - ht/2.0 - yb)
278     xr.show_text(me._text)
279
280     ## We're done drawing.  Collect the image and capture it as a Pixbuf so
281     ## that everyone else can use it.
282     surf.flush()
283     pix = GDK.pixbuf_new_from_data(surf.get_data(),
284                                    GDK.COLORSPACE_RGB, True, 8,
285                                    me._size, me._size, surf.get_stride())
286     return pix
287
288 class FileImage (CacheableImage):
289   """
290   I represent an image fetched from a (local disk) file.
291   """
292
293   ## Useful attributes:
294   ## _file = filename to read image data from
295
296   def __init__(me, file):
297     """Initialize a FileImage which reads its image data from FILE."""
298     CacheableImage.__init__(me)
299     me._file = file
300
301   def _acquire(me):
302     """Acquire the image data."""
303     return GDK.pixbuf_new_from_file(me._file)
304
305 class RemoteImage (CacheableImage):
306   """
307   I represent an image whose data can be fetched over the network.
308   """
309
310   ## Useful attributes:
311   ## _url = the URL to use to fetch the image
312   ## _ref = the referrer to set when fetching the image
313   ## _data = the image content from the server
314   ## _format = a PixbufFormat object describing the image format
315   ##
316   ## On first acquire, we fetch the image data from the server the hard way,
317   ## but we retain this indefinitely, even if the pixbuf is evicted as a
318   ## result of the cache filling.  The idea is that JPEG files (say) are
319   ## rather smaller than uncompressed pixbufs, and it's better to use up
320   ## local memory storing a JPEG than to have to fetch the whole thing over
321   ## the network again.
322
323   ## A dummy image used in place of the real thing if we encounter an error.
324   ERRIMG = NullImage(256, '!')
325
326   def __init__(me, url, ref = None):
327     """
328     Initialize the RemoteImage object.
329
330     Image data will be fetched from URL; if a REF is provided (and is not
331     None), then it will be presented as the `referer' when fetching the
332     image.
333     """
334     CacheableImage.__init__(me)
335     me._url = url
336     me._ref = ref
337     me._data = None
338
339   def _fetch(me):
340     """
341     Fetch the image data from the server, if necessary.
342
343     On success, the raw image data is stored in the `_data' attribute.  Many
344     things can go wrong, though.
345     """
346
347     ## If we already have the image data, then use what we've got already.
348     if me._data: return
349
350     ## Fetch the image data from the server.
351     d = StringIO()
352     rq = U2.Request(me._url)
353     if me._ref: rq.add_header('Referer', me._ref)
354     rs = U2.urlopen(rq)
355     while True:
356       stuff = rs.read(16384)
357       if not stuff: break
358       d.write(stuff)
359     me._data = d.getvalue()
360
361     ## Try to figure out what kind of image this is.  With a bit of luck, we
362     ## can figure this out by spooning a bit of image data into a
363     ## PixbufLoader.
364     ld = GDK.PixbufLoader()
365     try:
366       o = 0
367       n = len(me._data)
368       while True:
369         if o >= n: raise ValueError, 'not going to work'
370         l = min(n, o + 16384)
371         ld.write(me._data[o:l])
372         o = l
373         f = ld.get_format()
374         if f: break
375       me._format = f
376
377       ## If this is a GIF then bail now.  They look terrible.
378       if 'image/gif' in f['mime_types']:
379         me._data = None
380         raise ValueError, 'boycotting GIF image'
381
382     finally:
383       ## Tidy up: we don't want the loader any more.
384       try: ld.close()
385       except G.GError: pass
386
387   @property
388   def raw(me):
389     """The raw image data fetched from the remote source."""
390     me._fetch()
391     return me._data
392
393   def _acquire(me):
394     """
395     Return a decoded image from the image data.
396
397     If this isn't going to work, return a dummy image.
398     """
399     try:
400       ld = GDK.PixbufLoader()
401       try: ld.write(me.raw)
402       finally: ld.close()
403       return ld.get_pixbuf()
404     except Exception, e:
405       SYS.stderr.write("%s: failed to decode image from `%s': %s'" %
406                        (PROG, me._url, str(e)))
407       return me.ERRIMG.pixbuf
408
409   @property
410   def ext(me):
411     """Return a file extension for the image, in case we want to save it."""
412     ## If we can use `.jpg' then do that; otherwise, take whatever Gdk-Pixbuf
413     ## suggests.
414     exts = me._format['extensions']
415     for i in ['jpg']:
416       if i in exts: return i
417     return exts[0]
418
419 class SearchImage (RemoteImage):
420   """
421   I represent an image found by searching the web.
422   """
423
424   ## Useful attributes:
425   ## _tburl = the thumbnail url
426
427   def __init__(me, url, ref, tburl):
428     """
429     Initialize a SearchImage object.
430
431     The URL and REF arguments are as for the base RemoteImage object; TBURL
432     is the location of a thumbnail image which (so the thinking goes) will be
433     smaller and hence faster to fetch than the main one.
434     """
435     RemoteImage.__init__(me, url, ref)
436     me._tburl = tburl
437
438   @property
439   def thumbnail(me):
440     """Fetch a thumbnail separately, using the thumbnail URL provided."""
441     if not me._thumb: me._thumb = Thumbnail(RemoteImage(me._tburl))
442     return me._thumb
443
444 ###--------------------------------------------------------------------------
445 ### The windows.
446
447 class SearchCover (object):
448   """
449   A base class for icons in the SearchViewer window.
450   """
451
452   ## Useful attributes:
453   ## img = a CacheableImage for the image to display
454   ## text = a string containing the text to show
455
456   def __init__(me, img, width = None, height = None, marker = ''):
457     """
458     Initialize a SearchCover object.
459
460     The IMG is a CacheableImage of some kind.  If the WIDTH and HEIGHT are
461     omitted, the image data will be acquired and both dimensions calculated
462     (since, after all, if we have to go to the bother of fetching the image,
463     we may as well get an accurate size).
464     """
465     me.img = img
466     if width is None or height is None:
467       pix = img.pixbuf
468       width = pix.get_width()
469       height = pix.get_height()
470     me.text = '%d×%d%s' % (width, height, marker)
471
472 class SearchResult (SearchCover):
473   """
474   I represent information about an image found while searching the web.
475
476   I'm responsible for parsing information about individual search hits from
477   the result.
478   """
479   def __init__(me, r):
480     """Initialize a SearchResult, given a decoded JSON fragment R."""
481     i = r['image']
482     w = int(i['width'])
483     h = int(i['height'])
484     url = r['link']
485     ref = i['contextLink']
486     tburl = i['thumbnailLink']
487     SearchCover.__init__(me, SearchImage(url, ref, tburl),
488                          width = w, height = h)
489
490 class ViewCover (object):
491   """
492   I represent an album cover image found already in the filesystem.
493
494   I can be found in MainViewer windows.
495   """
496
497   ## Useful attributes:
498   ## img = a CacheableImage for the image to display
499   ## text = the text (directory name) associated with the cover
500   ## path = the full pathname to this directory
501   ## leaf = the basename of the cover image, or None
502
503   ## An error image, for use when there isn't a usable image.
504   NULLIMG = NullImage(THUMBSZ, '?')
505
506   def __init__(me, dir, path, leaf):
507     """
508     Initialize a new image.
509
510     DIR is the directory containing the image, relative to the root of the
511     search; it's presented to the user to help them distinguish the similar
512     icons (or identical ones, if multiple directories lack cover images).
513     PATH is the full pathname to the directory, and is used when installing a
514     replacement cover.  LEAF is the basename of the cover image in that
515     directory, or None if there currently isn't an image there.
516     """
517     me.text = dir
518     me.path = path
519     me.leaf = leaf
520     if me.leaf:
521       me.img = me.covimg = FileImage(OS.path.join(me.path, me.leaf))
522     else:
523       me.img = me.NULLIMG
524       me.covimg = None
525
526 class BaseCoverViewer (GTK.ScrolledWindow):
527   """
528   I represent a viewer for a collection of cover images, shown as thumbnails.
529
530   The image objects should have the following attributes.
531
532   img                   The actual image, as a CacheableImage.
533
534   text                  Some text to associate with the image, as a Python
535                         string.
536
537   I will store an `iterator' in the image object's `it' attribute, which will
538   let others identify it later to the underlying Gtk machinery.
539
540   Subclasses should implement two methods:
541
542   activate(COVER)       The COVER has been activated by the user (e.g.,
543                         double-clicked).
544
545   select(COVER)         The COVER has been selected by the user (e.g.,
546                         clicked) in a temporary manner; if COVER is None,
547                         then a previously selected cover has become
548                         unselected.
549   """
550
551   ## Useful attributes:
552   ## iv = an IconView widget used to show the thumbnails
553   ## list = a ListStore object used to keep track of the cover images; each
554   ##    item is a list of the form [PIXBUF, TEXT, COVER], where the PIXBUF
555   ##    and TEXT are extracted from the cover object in the obvious way
556
557   def __init__(me):
558     """Initialize a BaseCoverViewer."""
559
560     ## Initialize myself, as a scrollable thingy.
561     GTK.ScrolledWindow.__init__(me)
562     me.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
563
564     ## Set up an IconView to actually show the cover thumbnails.
565     me.iv = GTK.IconView()
566     me.iv.connect('item-activated',
567                   lambda iv, p: me.activate(me._frompath(p)))
568     me.iv.connect('selection-changed', me._select)
569     me.iv.set_pixbuf_column(0)
570     me.iv.set_text_column(1)
571     me.iv.set_orientation(GTK.ORIENTATION_VERTICAL)
572     me.iv.set_item_width(THUMBSZ + 32)
573     fix_background(me.iv)
574     me.add(me.iv)
575
576     ## Clear the list ready for cover images to be added.
577     me.reset()
578
579   def reset(me):
580     """
581     Clear the viewer of cover images.
582
583     This does /not/ clear the `it' attribute of previously attached cover
584     object.
585     """
586     me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT)
587     me.iv.set_model(me.list)
588     me.iv.unselect_all()
589
590   def addcover(me, cov):
591     """
592     Add the cover image COV to the viewer.
593
594     `COV.it' is filled in with the object's iterator.
595     """
596     cov.it = me.list.append([cov.img.thumbnail.pixbuf, cov.text, cov])
597
598   def _frompath(me, path):
599     """Convert a PATH to a cover image, and return it."""
600     return me.list[path][2]
601
602   def _select(me, iv):
603     """Handle a selection event, calling the subclass `select' method."""
604     sel = me.iv.get_selected_items()
605     if len(sel) != 1: me.select(None)
606     else: me.select(me._frompath(sel[0]))
607
608 class SearchViewer (BaseCoverViewer):
609   """
610   I'm a BaseCoverViewer subclass for showing search results.
611
612   I'll be found within a CoverChooser window, showing the thumbnails
613   resulting from a search.  I need to keep track of my parent CoverChooser,
614   so that I can tell it to show a large version of a selected image.
615   """
616
617   ## Useful attributes:
618   ## _chooser = the containing CoverChooser object
619
620   def __init__(me, chooser):
621     """
622     Initialize the SearchViewer, associating it with its parent CoverChooser.
623     """
624     BaseCoverViewer.__init__(me)
625     me._chooser = chooser
626
627   def switch(me, current):
628     """
629     Switch to a different album chosen in the CoverChooser.
630
631     CURRENT is either None, in which case the viewer is simply cleared, or
632     the current cover image (some CacheableImage object) for the newly chosen
633     album, which should be shown as the initial selection.
634     """
635     me.reset()
636     if current:
637       cov = SearchCover(current, marker = '*')
638       me.addcover(cov)
639       me.iv.select_path(me.list.get_path(cov.it))
640
641   def activate(me, cov):
642     """Inform the CoverChooser that the user activated COV."""
643     me._chooser.activated(cov)
644
645   def select(me, cov):
646     """Inform the CoverChooser that the user selected COV."""
647     me._chooser.selected(cov)
648
649 class SearchFail (Exception):
650   """An exception found while trying to search for images."""
651   pass
652
653 class CoverChooser (object):
654   """
655   I represent a window for choosing one of a number of cover art images.
656
657   I allow the user to choose one of a number of alternative images for an
658   album, and to search the Internet for more options.
659
660   I work on behalf of some other client object; I am responsible for
661   collecting the user's ultimate choice, but that responsibility ends when I
662   tell my client which image the user picked.
663
664   During my lifetime, I can be used to choosing images for different purposes
665   (possibly on behalf of different clients), though I can only work on one
666   thing at a time.  I switch between jobs when `update' is called; see the
667   documentation of that method for details of the protocol.
668
669   I try to arrange that there's at most one instance of me, in the variable
670   `CHOOSER'.
671   """
672
673   ## Important attributes:
674   ## query = the entry widget for typing search terms
675   ## sv = the search viewer pane showing thumbnails of search results
676   ## img = the image preview pane showing a selected image at full size
677   ## win = our top-level window
678   ## view = the object which invoked us
679   ## which = the client's `which' parameter
680   ## current = the currently chosen image
681
682   ## This is a bit grim because search APIs and keys.
683   SEARCHURL = None
684   SEARCHID = '016674315927968770913:8blyelgp3wu'
685   REFERER = 'https://www.distorted.org.uk/~mdw/coverart'
686
687   @classmethod
688   def set_apikey(cls, apikey):
689     """Inform the class that it can use APIKEY to authorize its search."""
690     cls.SEARCHURL = \
691       'https://www.googleapis.com/customsearch/v1?' \
692       'key=%s&cx=%s&searchType=image&q=' % (apikey, cls.SEARCHID)
693
694   def __init__(me):
695     """Initialize the window, but don't try to show it yet."""
696
697     ## Make a window.
698     me.win = GTK.Window()
699
700     ## The window layout is like this.
701     ##
702     ##           -----------------------------------
703     ##          | [...                   ] [Search] |
704     ##          |-----------------------------------|
705     ##          | thumbs  |                         |
706     ##          |    .    |                         |
707     ##          |    .    |          image          |
708     ##          |    .    |                         |
709     ##          |         |         preview         |
710     ##          |         |                         |
711     ##          |         |                         |
712     ##          |         |                         |
713     ##          |         |                         |
714     ##           -----------------------------------
715
716     ## Main layout box, with search stuff at the top and selection stuff
717     ## below.
718     box = GTK.VBox()
719
720     ## First, fill in the search stuff.
721     top = GTK.HBox()
722     me.query = GTK.Entry()
723     top.pack_start(me.query, True, True, 2)
724     srch = GTK.Button('_Search')
725     srch.set_flags(GTK.CAN_DEFAULT)
726     srch.connect('clicked', me.search)
727     top.pack_start(srch, False, False, 2)
728     box.pack_start(top, False, False, 2)
729
730     ## Now the thumbnail viewer and preview below.
731     panes = GTK.HPaned()
732     me.sv = SearchViewer(me)
733     panes.pack1(me.sv, False, True)
734     scr = GTK.ScrolledWindow()
735     scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
736     evb = GTK.EventBox()
737     me.img = GTK.Image()
738     evb.add(me.img)
739     fix_background(evb)
740     scr.add_with_viewport(evb)
741     panes.pack2(scr, True, True)
742     panes.set_position(THUMBSZ + 48)
743     box.pack_start(panes, True, True, 0)
744     me.win.add(box)
745
746     ## Finally, configure some signal handlers.
747     me.win.connect('destroy', me.destroyed)
748     me.win.set_default_size(800, 550)
749
750     ## Set the default button.  (Gtk makes us wait until the button has a
751     ## top-level window to live in.)
752     srch.grab_default()
753
754   def update(me, view, which, dir, current):
755     """
756     Update the CoverChooser to choose a different album's cover image.
757
758     The VIEW is the client object.  We will later call its `replace' method,
759     passing it WHICH and the newly chosen image (as a RemoteImage object).
760     WHICH is simply remembered as a context value for `update'.  DIR is the
761     path to the album whose cover is to be chosen, used for constructing a
762     suitable search string.  CURRENT is some kind of CacheableImage object
763     representing the currently chosen image we may be replacing.
764     """
765     me.view = view
766     me.which = which
767     me.current = current
768     me.img.clear()
769     me.sv.switch(current)
770     me.query.set_text(me.makequery(dir))
771     me.win.show_all()
772
773   def search(me, w):
774     """
775     Instigate a web search for a replacement image.
776
777     W is the widget which was frobbed to invoke the search, but it's not very
778     interesting.
779     """
780
781     ## Collect the search query.
782     q = me.query.get_text()
783
784     ## Try to ask a search provider for some likely images.
785     try:
786
787       ## We won't get far if we've not been given an API key.
788       if me.SEARCHURL is None: raise SearchFail('no search key')
789
790       ## Collect the search result.
791       try:
792         rq = U2.Request(me.SEARCHURL + U.quote_plus(q), None,
793                         { 'Referer': me.REFERER })
794         rs = U2.urlopen(rq)
795       except U2.URLError, e:
796         raise SearchFail(e.reason)
797       result = JS.load(rs)
798
799       ## Clear out all of the images from the last search, leaving only the
800       ## incumbent choice, and then add the new images from the search
801       ## results.
802       me.sv.switch(me.current)
803       for r in result['items']:
804         try: me.sv.addcover(SearchResult(r))
805         except (U2.URLError, U2.HTTPError): pass
806
807     ## Maybe that didn't work.
808     except SearchFail, e:
809       whinge('search failed: %s' % e.args[0])
810
811   def makequery(me, path):
812     """Construct a default search query string for the chosen album."""
813     bits = path.split(OS.path.sep)
814     return ' '.join(['"%s"' % p for p in bits[-2:]])
815
816   def selected(me, cov):
817     """
818     Show a full-size version of COV in the preview pane.
819
820     Called by the SearchViewer when a thumbnail is selected.
821     """
822     if cov: me.img.set_from_pixbuf(cov.img.pixbuf)
823     else: me.img.clear()
824
825   def activated(me, cov):
826     """
827     Inform our client that COV has been chosen as the replacement image.
828
829     Called by the SearchViewer when a thumbnail is activated.
830     """
831     if isinstance(cov, SearchCover): me.view.replace(me.which, cov.img)
832
833   def destroyed(me, w):
834     """
835     Our widget has been destroyed.
836
837     We're going away, so clear out the reference to us.
838     """
839     global CHOOSER
840     CHOOSER = None
841
842 ## There's currently no chooser.
843 CHOOSER = None
844
845 class MainViewer (BaseCoverViewer):
846   """
847   I'm a top-level cover viewer, showing thumbnails for the albums I can find
848   in the search tree.
849   """
850
851   ## Useful attributes:
852   ## root = the root of the directory tree to manage
853
854   def __init__(me, root):
855     """
856     Initialize a viewer for choosing cover art in the tree headed by ROOT.
857     """
858     BaseCoverViewer.__init__(me)
859     me.root = root
860     me.walk('')
861
862   def walk(me, dir):
863     """
864     Walk the directory tree from DIR down, adding icons for cover art I find
865     (or don't find).
866     """
867
868     ## Assume this is a leaf directory for now.
869     leafp = True
870
871     ## Figure out the actual pathname we're looking at.
872     b = OS.path.join(me.root, dir)
873
874     ## The name of any image file we find.
875     imgfile = None
876
877     ## Work through the items in the directory.
878     for l in sorted(OS.listdir(b)):
879
880       if OS.path.isdir(OS.path.join(b, l)):
881         ## If this item is a directory, then we're not leaf, but need to
882         ## descend recursively.
883         leafp = False
884         me.walk(OS.path.join(dir, l))
885       else:
886         ## If this smells like a cover image then remember it.  (If there are
887         ## multiple plausible options, just remember one more or less
888         ## arbitrarily.)
889         base, ext = OS.path.splitext(l)
890         if base == 'cover' and ext in ['.jpg', '.png', '.gif']: imgfile = l
891
892     ## If this is a leaf directory, hopefully representing an album rather
893     ## than an artist or higher-level grouping, then add an icon representing
894     ## it, including any cover image that we found.
895     if leafp:
896       me.addcover(ViewCover(dir, OS.path.join(me.root, dir), imgfile))
897
898   def select(me, cov):
899     """The user selected a cover icon, but we don't care."""
900     pass
901
902   def activate(me, cov):
903     """
904     A cover icon was activated.
905
906     Allow the user to choose a replacement cover.
907     """
908     global CHOOSER
909     if not CHOOSER: CHOOSER = CoverChooser()
910     CHOOSER.update(me, cov, cov.text, cov.covimg)
911
912   def replace(me, cov, img):
913     """
914     Replace the cover COV by the newly chosen image IMG.
915
916     This is called by the CoverChooser.
917     """
918     leaf = 'cover.%s' % img.ext
919     out = OS.path.join(cov.path, leaf)
920     new = out + '.new'
921     with open(new, 'wb') as f: f.write(img.raw)
922     OS.rename(new, out)
923     if cov.leaf not in [None, leaf]:
924       OS.unlink(OS.path.join(cov.path, cov.leaf))
925     ncov = ViewCover(cov.text, cov.path, leaf)
926     ncov.it = cov.it
927     me.list[ncov.it] = [ncov.img.thumbnail.pixbuf, ncov.text, ncov]
928     me.activate(ncov)
929
930 ###--------------------------------------------------------------------------
931 ### Main program.
932
933 if __name__ == '__main__':
934
935   ## Set the program name.
936   PROG = OS.path.basename(SYS.argv[0])
937
938   ## Try to find an API key for searching.
939   CONFROOT = ENV.get('XDG_CONFIG_HOME', None)
940   if CONFROOT is None:
941     CONFROOT = OS.path.join(ENV['HOME'], '.config')
942   CONFDIR = OS.path.join(CONFROOT, 'autoys')
943   try:
944     f = open(OS.path.join(CONFDIR, 'apikey'))
945   except IOError, e:
946     if e.errno == E.ENOENT: pass
947     else: raise
948   else:
949     with f:
950       apikey = f.readline().strip()
951       CoverChooser.set_apikey(apikey)
952
953   ## Parse the command line.
954   op = OP.OptionParser(prog = PROG, version = VERSION,
955                        usage = '%prog ROOT',
956                        description = """\
957 Browse the cover-art images in the directory tree headed by ROOT, and allow
958 the images to be replaced by others found by searching the Internet.
959 """)
960   opts, args = op.parse_args(SYS.argv[1:])
961   if len(args) != 1: op.error('wrong number of arguments')
962   ROOT, = args
963
964   ## Set things up.
965   LOOP = G.MainLoop()
966   BLACK = GDK.Color(0, 0, 0)
967   WHITE = GDK.Color(65535, 65535, 65535)
968
969   ## Make a top-level window showing a MainView and display it.
970   WIN = GTK.Window()
971   VIEW = MainViewer(ROOT)
972   WIN.add(VIEW)
973   WIN.set_default_size(6*(THUMBSZ + 48), 660)
974   WIN.set_title('coverart')
975   WIN.connect('destroy', lambda _: LOOP.quit())
976   WIN.show_all()
977
978   ## Carry on until there's nothing left to do.
979   LOOP.run()
980
981 ###----- That's all, folks --------------------------------------------------