| 1 | #! /usr/bin/python |
| 2 | # -*- coding: utf-8 -*- |
| 3 | |
| 4 | import sys as SYS |
| 5 | import os as OS |
| 6 | from cStringIO import StringIO |
| 7 | |
| 8 | import gobject as G |
| 9 | import gtk as GTK |
| 10 | GDK = GTK.gdk |
| 11 | import cairo as XR |
| 12 | |
| 13 | import urllib as U |
| 14 | import urllib2 as U2 |
| 15 | import json as JS |
| 16 | |
| 17 | THUMBSZ = 96 |
| 18 | |
| 19 | class ImageCache (object): |
| 20 | |
| 21 | THRESH = 128*1024*1024 |
| 22 | |
| 23 | def __init__(me): |
| 24 | me._total = 0 |
| 25 | me._first = me._last = None |
| 26 | |
| 27 | def add(me, img): |
| 28 | me._total += img.size |
| 29 | while me._first and me._total > me.THRESH: |
| 30 | me._first.evict() |
| 31 | img._prev = me._last |
| 32 | img._next = None |
| 33 | if me._last: |
| 34 | me._last._next = img |
| 35 | else: |
| 36 | me._first = img |
| 37 | me._last = img |
| 38 | |
| 39 | def rm(me, img): |
| 40 | if img._prev: |
| 41 | img._prev._next = img._next |
| 42 | else: |
| 43 | me._first = img._next |
| 44 | if img._next: |
| 45 | img._next._prev = img._prev |
| 46 | else: |
| 47 | img._last = img._prev |
| 48 | me._total -= img.size |
| 49 | |
| 50 | CACHE = ImageCache() |
| 51 | |
| 52 | class CacheableImage (object): |
| 53 | |
| 54 | def __init__(me): |
| 55 | me._pixbuf = None |
| 56 | me._prev = me._next = None |
| 57 | me._thumb = None |
| 58 | |
| 59 | @property |
| 60 | def pixbuf(me): |
| 61 | if not me._pixbuf: |
| 62 | me._pixbuf = me._acquire() |
| 63 | me.size = me._pixbuf.get_pixels_array().nbytes |
| 64 | CACHE.add(me) |
| 65 | return me._pixbuf |
| 66 | |
| 67 | def evict(me): |
| 68 | me._pixbuf = None |
| 69 | CACHE.rm(me) |
| 70 | |
| 71 | def flush(me): |
| 72 | me.evict() |
| 73 | me._thumb = None |
| 74 | |
| 75 | @property |
| 76 | def thumbnail(me): |
| 77 | if not me._thumb: |
| 78 | me._thumb = Thumbnail(me) |
| 79 | return me._thumb |
| 80 | |
| 81 | class Thumbnail (object): |
| 82 | |
| 83 | def __init__(me, img): |
| 84 | pix = img.pixbuf |
| 85 | wd, ht = pix.get_width(), pix.get_height() |
| 86 | m = max(wd, ht) |
| 87 | if m <= THUMBSZ: |
| 88 | me.pixbuf = pix |
| 89 | else: |
| 90 | twd, tht = [(x*THUMBSZ + m//2)//m for x in [wd, ht]] |
| 91 | me.pixbuf = pix.scale_simple(twd, tht, GDK.INTERP_HYPER) |
| 92 | |
| 93 | class NullImage (CacheableImage): |
| 94 | |
| 95 | MAP = {} |
| 96 | |
| 97 | def __init__(me, size, text): |
| 98 | CacheableImage.__init__(me) |
| 99 | me._size = size |
| 100 | me._text = text |
| 101 | |
| 102 | @staticmethod |
| 103 | def get(cls, size): |
| 104 | try: |
| 105 | return cls.MAP[size] |
| 106 | except KeyError: |
| 107 | img = cls.MAP[size] = cls(size) |
| 108 | return img |
| 109 | |
| 110 | def _acquire(me): |
| 111 | |
| 112 | surf = XR.ImageSurface(XR.FORMAT_ARGB32, me._size, me._size) |
| 113 | xr = XR.Context(surf) |
| 114 | |
| 115 | xr.set_source_rgb(0.3, 0.3, 0.3) |
| 116 | xr.paint() |
| 117 | |
| 118 | xr.move_to(me._size/2.0, me._size/2.0) |
| 119 | xr.select_font_face('sans-serif', |
| 120 | XR.FONT_SLANT_NORMAL, XR.FONT_WEIGHT_BOLD) |
| 121 | xb, yb, wd, ht, xa, ya = xr.text_extents(me._text) |
| 122 | m = max(wd, ht) |
| 123 | z = me._size/float(m) * 2.0/3.0 |
| 124 | xr.scale(z, z) |
| 125 | |
| 126 | xr.set_source_rgb(0.8, 0.8, 0.8) |
| 127 | xr.move_to(3.0*m/4.0 - wd/2.0 - xb, 3.0*m/4.0 - ht/2.0 - yb) |
| 128 | xr.show_text(me._text) |
| 129 | |
| 130 | surf.flush() |
| 131 | pix = GDK.pixbuf_new_from_data(surf.get_data(), |
| 132 | GDK.COLORSPACE_RGB, True, 8, |
| 133 | me._size, me._size, surf.get_stride()) |
| 134 | return pix |
| 135 | |
| 136 | class FileImage (CacheableImage): |
| 137 | |
| 138 | def __init__(me, file): |
| 139 | CacheableImage.__init__(me) |
| 140 | me._file = file |
| 141 | |
| 142 | def _acquire(me): |
| 143 | return GDK.pixbuf_new_from_file(me._file) |
| 144 | |
| 145 | def fetch_url(url): |
| 146 | out = StringIO() |
| 147 | with U.urlopen(url) as u: |
| 148 | while True: |
| 149 | stuff = u.read(16384) |
| 150 | if not stuff: |
| 151 | break |
| 152 | out.write(stuff) |
| 153 | return out.getvalue() |
| 154 | |
| 155 | def fix_background(w): |
| 156 | style = w.get_style().copy() |
| 157 | style.base[GTK.STATE_NORMAL] = BLACK |
| 158 | style.bg[GTK.STATE_NORMAL] = BLACK |
| 159 | style.text[GTK.STATE_NORMAL] = WHITE |
| 160 | w.set_style(style) |
| 161 | |
| 162 | class BaseCoverViewer (object): |
| 163 | |
| 164 | def __init__(me): |
| 165 | me.scr = GTK.ScrolledWindow() |
| 166 | me.scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC) |
| 167 | me.iv = GTK.IconView() |
| 168 | me.iv.connect('item-activated', |
| 169 | lambda iv, p: me.activate(me._frompath(p))) |
| 170 | me.iv.connect('selection-changed', me._select) |
| 171 | me.iv.set_pixbuf_column(0) |
| 172 | me.iv.set_text_column(1) |
| 173 | me.iv.set_orientation(GTK.ORIENTATION_VERTICAL) |
| 174 | me.iv.set_item_width(THUMBSZ + 32) |
| 175 | fix_background(me.iv) |
| 176 | me.scr.add(me.iv) |
| 177 | me.reset() |
| 178 | |
| 179 | def reset(me): |
| 180 | me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT) |
| 181 | me.iv.set_model(me.list) |
| 182 | me.iv.unselect_all() |
| 183 | |
| 184 | def add(me, item): |
| 185 | item.it = me.list.append([item.img.thumbnail.pixbuf, |
| 186 | item.text, |
| 187 | item]) |
| 188 | |
| 189 | def _frompath(me, path): |
| 190 | return me.list[path][2] |
| 191 | |
| 192 | def _select(me, iv): |
| 193 | sel = me.iv.get_selected_items() |
| 194 | if len(sel) != 1: |
| 195 | me.select(None) |
| 196 | else: |
| 197 | me.select(me._frompath(sel[0])) |
| 198 | |
| 199 | class SearchCover (object): |
| 200 | def __init__(me, img): |
| 201 | me.img = img |
| 202 | pix = img.pixbuf |
| 203 | me.text = '%d×%d*' % (pix.get_width(), pix.get_height()) |
| 204 | |
| 205 | class SearchViewer (BaseCoverViewer): |
| 206 | |
| 207 | def __init__(me, chooser): |
| 208 | BaseCoverViewer.__init__(me) |
| 209 | me._chooser = chooser |
| 210 | |
| 211 | def switch(me, current): |
| 212 | me.reset() |
| 213 | if current: |
| 214 | cov = SearchCover(current) |
| 215 | me.add(cov) |
| 216 | me.iv.select_path(me.list.get_path(cov.it)) |
| 217 | |
| 218 | def activate(me, cov): |
| 219 | me._chooser.activated(cov) |
| 220 | |
| 221 | def select(me, cov): |
| 222 | me._chooser.selected(cov) |
| 223 | |
| 224 | class RemoteImage (CacheableImage): |
| 225 | |
| 226 | ERRIMG = NullImage(256, '!') |
| 227 | |
| 228 | def __init__(me, url, ref = None): |
| 229 | CacheableImage.__init__(me) |
| 230 | me._url = url |
| 231 | me._ref = ref |
| 232 | me._data = None |
| 233 | |
| 234 | def _fetch(me): |
| 235 | if me._data: |
| 236 | return |
| 237 | d = StringIO() |
| 238 | rq = U2.Request(me._url) |
| 239 | if me._ref: |
| 240 | rq.add_header('Referer', me._ref) |
| 241 | rs = U2.urlopen(rq) |
| 242 | while True: |
| 243 | stuff = rs.read(16384) |
| 244 | if not stuff: |
| 245 | break |
| 246 | d.write(stuff) |
| 247 | me._data = d.getvalue() |
| 248 | ld = GDK.PixbufLoader() |
| 249 | try: |
| 250 | o = 0 |
| 251 | n = len(me._data) |
| 252 | while True: |
| 253 | if o >= n: |
| 254 | raise ValueError, 'not going to work' |
| 255 | l = min(n, o + 16384) |
| 256 | ld.write(me._data[o:l]) |
| 257 | o = l |
| 258 | f = ld.get_format() |
| 259 | if f: |
| 260 | break |
| 261 | me._format = f |
| 262 | if 'image/gif' in f['mime_types']: |
| 263 | raise ValueError, 'boycotting GIF image' |
| 264 | finally: |
| 265 | try: |
| 266 | ld.close() |
| 267 | except G.GError: |
| 268 | pass |
| 269 | |
| 270 | def _acquire(me): |
| 271 | try: |
| 272 | me._fetch() |
| 273 | ld = GDK.PixbufLoader() |
| 274 | try: |
| 275 | ld.write(me._data) |
| 276 | finally: |
| 277 | ld.close() |
| 278 | return ld.get_pixbuf() |
| 279 | except Exception, e: |
| 280 | print e |
| 281 | return me.ERRIMG.pixbuf |
| 282 | |
| 283 | @property |
| 284 | def ext(me): |
| 285 | exts = me._format['extensions'] |
| 286 | for i in ['jpg']: |
| 287 | if i in exts: |
| 288 | return i |
| 289 | return exts[0] |
| 290 | |
| 291 | class SearchImage (RemoteImage): |
| 292 | |
| 293 | def __init__(me, url, ref, tburl): |
| 294 | RemoteImage.__init__(me, url, ref) |
| 295 | me._tburl = tburl |
| 296 | |
| 297 | @property |
| 298 | def thumbnail(me): |
| 299 | if not me._thumb: |
| 300 | me._thumb = Thumbnail(RemoteImage(me._tburl)) |
| 301 | return me._thumb |
| 302 | |
| 303 | class SearchResult (SearchCover): |
| 304 | |
| 305 | def __init__(me, r): |
| 306 | w = int(r['width']) |
| 307 | h = int(r['height']) |
| 308 | url = r['unescapedUrl'] |
| 309 | ref = r['originalContextUrl'] |
| 310 | tburl = r['tbUrl'] |
| 311 | me.img = SearchImage(url, ref, tburl) |
| 312 | me.text = '%d×%d' % (w, h) |
| 313 | |
| 314 | class SearchFail (Exception): |
| 315 | pass |
| 316 | |
| 317 | class CoverChooser (object): |
| 318 | |
| 319 | SEARCHURL = \ |
| 320 | 'http://ajax.googleapis.com/ajax/services/search/images?v=1.0&rsz=8&q=' |
| 321 | |
| 322 | def __init__(me): |
| 323 | me.win = GTK.Window() |
| 324 | box = GTK.VBox() |
| 325 | top = GTK.HBox() |
| 326 | me.query = GTK.Entry() |
| 327 | top.pack_start(me.query, True, True, 2) |
| 328 | srch = GTK.Button('_Search') |
| 329 | srch.set_flags(GTK.CAN_DEFAULT) |
| 330 | srch.connect('clicked', me.search) |
| 331 | top.pack_start(srch, False, False, 2) |
| 332 | box.pack_start(top, False, False, 2) |
| 333 | me.sv = SearchViewer(me) |
| 334 | panes = GTK.HPaned() |
| 335 | panes.pack1(me.sv.scr, False, True) |
| 336 | scr = GTK.ScrolledWindow() |
| 337 | scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC) |
| 338 | me.img = GTK.Image() |
| 339 | evb = GTK.EventBox() |
| 340 | evb.add(me.img) |
| 341 | fix_background(evb) |
| 342 | scr.add_with_viewport(evb) |
| 343 | panes.pack2(scr, True, True) |
| 344 | panes.set_position(THUMBSZ + 64) |
| 345 | box.pack_start(panes, True, True, 0) |
| 346 | me.win.add(box) |
| 347 | me.win.connect('destroy', me.destroyed) |
| 348 | me.win.set_default_size(800, 550) |
| 349 | srch.grab_default() |
| 350 | |
| 351 | def update(me, view, which, dir, current): |
| 352 | me.view = view |
| 353 | me.dir = dir |
| 354 | me.which = which |
| 355 | me.current = current |
| 356 | me.img.clear() |
| 357 | me.sv.switch(current) |
| 358 | me.query.set_text(me.makequery(dir)) |
| 359 | me.win.show_all() |
| 360 | |
| 361 | def search(me, w): |
| 362 | q = me.query.get_text() |
| 363 | try: |
| 364 | try: |
| 365 | rq = U2.Request(me.SEARCHURL + U.quote_plus(q), |
| 366 | None, |
| 367 | { 'Referer': |
| 368 | 'http://www.distorted.org.uk/~mdw/coverart' }) |
| 369 | rs = U2.urlopen(rq) |
| 370 | except U2.URLError, e: |
| 371 | raise SearchFail(e.reason) |
| 372 | result = JS.load(rs) |
| 373 | if result['responseStatus'] != 200: |
| 374 | raise SearchFail('%s (status = %d)' % |
| 375 | (result['responseDetails'], |
| 376 | result['responseStatus'])) |
| 377 | d = result['responseData'] |
| 378 | me.sv.switch(me.current) |
| 379 | for r in d['results']: |
| 380 | try: |
| 381 | me.sv.add(SearchResult(r)) |
| 382 | except (U2.URLError, U2.HTTPError): |
| 383 | pass |
| 384 | except SearchFail, e: |
| 385 | print e.args[0] |
| 386 | |
| 387 | def makequery(me, path): |
| 388 | bits = path.split(OS.path.sep) |
| 389 | return ' '.join(['"%s"' % p for p in bits[-2:]]) |
| 390 | |
| 391 | def selected(me, cov): |
| 392 | if cov: |
| 393 | me.img.set_from_pixbuf(cov.img.pixbuf) |
| 394 | else: |
| 395 | me.img.clear() |
| 396 | |
| 397 | def activated(me, cov): |
| 398 | if isinstance(cov, SearchCover): |
| 399 | me.view.replace(me.which, cov.img) |
| 400 | |
| 401 | def destroyed(me, w): |
| 402 | global CHOOSER |
| 403 | CHOOSER = None |
| 404 | |
| 405 | CHOOSER = None |
| 406 | |
| 407 | class ViewCover (object): |
| 408 | |
| 409 | NULLIMG = NullImage(THUMBSZ, '?') |
| 410 | |
| 411 | def __init__(me, dir, path, leaf): |
| 412 | me.text = dir |
| 413 | me.path = path |
| 414 | me.leaf = leaf |
| 415 | if me.leaf: |
| 416 | me.img = me.covimg = FileImage(OS.path.join(me.path, me.leaf)) |
| 417 | else: |
| 418 | me.img = me.NULLIMG |
| 419 | me.covimg = None |
| 420 | |
| 421 | class MainViewer (BaseCoverViewer): |
| 422 | |
| 423 | ITERATTR = 'vit' |
| 424 | |
| 425 | def __init__(me, root): |
| 426 | BaseCoverViewer.__init__(me) |
| 427 | me.root = root |
| 428 | me.walk('') |
| 429 | |
| 430 | def walk(me, dir): |
| 431 | leafp = True |
| 432 | b = OS.path.join(me.root, dir) |
| 433 | imgfile = None |
| 434 | for l in sorted(OS.listdir(b)): |
| 435 | if OS.path.isdir(OS.path.join(b, l)): |
| 436 | leafp = False |
| 437 | me.walk(OS.path.join(dir, l)) |
| 438 | else: |
| 439 | base, ext = OS.path.splitext(l) |
| 440 | if base == 'cover' and ext in ['.jpg', '.png', '.gif']: |
| 441 | imgfile = l |
| 442 | if leafp: |
| 443 | me.add(ViewCover(dir, OS.path.join(me.root, dir), imgfile)) |
| 444 | |
| 445 | def select(me, cov): |
| 446 | pass |
| 447 | |
| 448 | def activate(me, cov): |
| 449 | global CHOOSER |
| 450 | if not CHOOSER: |
| 451 | CHOOSER = CoverChooser() |
| 452 | CHOOSER.update(me, cov, cov.text, cov.covimg) |
| 453 | |
| 454 | def replace(me, cov, img): |
| 455 | leaf = 'cover.%s' % img.ext |
| 456 | out = OS.path.join(cov.path, leaf) |
| 457 | new = out + '.new' |
| 458 | with open(new, 'wb') as f: |
| 459 | f.write(img._data) |
| 460 | OS.rename(new, out) |
| 461 | if cov.leaf not in [None, leaf]: |
| 462 | OS.unlink(OS.path.join(cov.path, cov.leaf)) |
| 463 | ncov = ViewCover(cov.text, cov.path, leaf) |
| 464 | ncov.it = cov.it |
| 465 | me.list[ncov.it] = [ncov.img.thumbnail.pixbuf, ncov.text, ncov] |
| 466 | me.activate(ncov) |
| 467 | |
| 468 | ROOT = SYS.argv[1] |
| 469 | |
| 470 | LOOP = G.MainLoop() |
| 471 | |
| 472 | BLACK = GDK.Color(0, 0, 0) |
| 473 | WHITE = GDK.Color(65535, 65535, 65535) |
| 474 | |
| 475 | WIN = GTK.Window() |
| 476 | VIEW = MainViewer(ROOT) |
| 477 | WIN.add(VIEW.scr) |
| 478 | WIN.set_default_size(814, 660) |
| 479 | WIN.set_title('coverart') |
| 480 | WIN.connect('destroy', lambda _: LOOP.quit()) |
| 481 | WIN.show_all() |
| 482 | |
| 483 | LOOP.run() |