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