chiark / gitweb /
coverart/coverart.in: Reorder, reformat, and document.
[autoys] / coverart / coverart.in
CommitLineData
86e491b0
MW
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.
31from __future__ import with_statement
32
33## Standard Python libraries.
34from cStringIO import StringIO
35import errno as E
36import json as JS
64096b9c 37import optparse as OP
86e491b0
MW
38import os as OS; ENV = OS.environ
39import sys as SYS
40import urllib as U
41import urllib2 as U2
42
43## GTK and friends.
44import cairo as XR
45import gobject as G
46import gtk as GTK
47GDK = GTK.gdk
48
64096b9c
MW
49###--------------------------------------------------------------------------
50### Build-time configuration.
51
52VERSION = '@VERSION@'
53
86e491b0
MW
54###--------------------------------------------------------------------------
55### Theoretically tweakable parameters.
56
57THUMBSZ = 96
58
64096b9c
MW
59###--------------------------------------------------------------------------
60### Utilities.
61
62def 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
70def 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
80def 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
86e491b0
MW
88###--------------------------------------------------------------------------
89### The image cache.
90
91class 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.
146CACHE = ImageCache()
147
148class 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
204class 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
230class 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
288class 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
86e491b0
MW
305class 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
64096b9c
MW
387 @property
388 def raw(me):
389 """The raw image data fetched from the remote source."""
390 me._fetch()
391 return me._data
392
86e491b0
MW
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:
86e491b0 400 ld = GDK.PixbufLoader()
64096b9c 401 try: ld.write(me.raw)
86e491b0
MW
402 finally: ld.close()
403 return ld.get_pixbuf()
404 except Exception, e:
64096b9c
MW
405 SYS.stderr.write("%s: failed to decode image from `%s': %s'" %
406 (PROG, me._url, str(e)))
86e491b0
MW
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
419class 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
64096b9c
MW
444###--------------------------------------------------------------------------
445### The windows.
446
447class 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
86e491b0
MW
472class 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
64096b9c
MW
490class 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
526class 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
608class 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
86e491b0
MW
649class SearchFail (Exception):
650 """An exception found while trying to search for images."""
651 pass
652
653class 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)
64096b9c 742 panes.set_position(THUMBSZ + 48)
86e491b0
MW
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:
64096b9c 809 whinge('search failed: %s' % e.args[0])
86e491b0
MW
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.
843CHOOSER = None
844
86e491b0
MW
845class 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
64096b9c 909 if not CHOOSER: CHOOSER = CoverChooser()
86e491b0
MW
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'
64096b9c 921 with open(new, 'wb') as f: f.write(img.raw)
86e491b0
MW
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
933if __name__ == '__main__':
934
64096b9c
MW
935 ## Set the program name.
936 PROG = OS.path.basename(SYS.argv[0])
937
86e491b0
MW
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
64096b9c
MW
953 ## Parse the command line.
954 op = OP.OptionParser(prog = PROG, version = VERSION,
955 usage = '%prog ROOT',
956 description = """\
957Browse the cover-art images in the directory tree headed by ROOT, and allow
958the 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
86e491b0 964 ## Set things up.
86e491b0
MW
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)
64096b9c 973 WIN.set_default_size(6*(THUMBSZ + 48), 660)
86e491b0
MW
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 --------------------------------------------------