chiark / gitweb /
789b06dfa629ab9aa75153193c56a5b0978bef61
[autoys] / coverart / coverart
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()