chiark / gitweb /
coverart/: Prepare for proper release.
[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
37import os as OS; ENV = OS.environ
38import sys as SYS
39import urllib as U
40import urllib2 as U2
41
42## GTK and friends.
43import cairo as XR
44import gobject as G
45import gtk as GTK
46GDK = GTK.gdk
47
48###--------------------------------------------------------------------------
49### Theoretically tweakable parameters.
50
51THUMBSZ = 96
52
53###--------------------------------------------------------------------------
54### The image cache.
55
56class 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.
111CACHE = ImageCache()
112
113class 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
169class 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
195class 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
253class 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
273def 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
283def 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
294class 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
376class 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
396class 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
437class 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
545class 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
570class 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
588class SearchFail (Exception):
589 """An exception found while trying to search for images."""
590 pass
591
592class 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.
782CHOOSER = None
783
784class 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
819class 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
909if __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 --------------------------------------------------