Commit | Line | Data |
---|---|---|
583b7e4a MW |
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() |