Commit | Line | Data |
---|---|---|
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. | |
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 | |
64096b9c | 37 | import optparse as OP |
86e491b0 MW |
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 | ||
64096b9c MW |
49 | ###-------------------------------------------------------------------------- |
50 | ### Build-time configuration. | |
51 | ||
52 | VERSION = '@VERSION@' | |
53 | ||
86e491b0 MW |
54 | ###-------------------------------------------------------------------------- |
55 | ### Theoretically tweakable parameters. | |
56 | ||
57 | THUMBSZ = 96 | |
58 | ||
64096b9c MW |
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 | ||
86e491b0 MW |
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 | ||
86e491b0 MW |
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 | ||
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 | ||
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 | ||
64096b9c MW |
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 | ||
86e491b0 MW |
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 | ||
64096b9c MW |
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 | ||
86e491b0 MW |
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) | |
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. | |
843 | CHOOSER = None | |
844 | ||
86e491b0 MW |
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 | |
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 | ||
933 | if __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 = """\ | |
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 | ||
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 -------------------------------------------------- |