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