not needed with 20Mi
[pyTivo/wgw.git] / plugins / photo / photo.py
blobc4e46a16d0cb56d4da13d770ae0f303e66ddf24d
1 # Photo module for pyTivo by William McBrine <wmcbrine@users.sf.net>
2 # based partly on music.py and plugin.py
4 # After version 0.15, see git for the history
6 # Version 0.15, Dec. 29 -- allow Unicode; better error messages
7 # Version 0.14, Dec. 26 -- fix Random sort; handle ItemCount == 0
8 # Version 0.13, Dec. 19 -- more thread-safe; use draft mode always
9 # Version 0.12, Dec. 18 -- get date and orientation from Exif
10 # Version 0.11, Dec. 16 -- handle ItemCount, AnchorItem etc. correctly
11 # Version 0.10, Dec. 14 -- give full list if no ItemCount; use antialias
12 # mode always; allow larger thumbnails
13 # Version 0.9, Dec. 13 -- different sort types
14 # Version 0.8, Dec. 12 -- faster thumbnails, better quality full views
15 # Version 0.7, Dec. 11 -- fix missing item on thumbnail scroll up,
16 # better anchor and path handling
17 # Version 0.6, Dec. 10 -- cache recursive lookups for faster slide shows
18 # Version 0.5, Dec. 10 -- fix reboot problem by keeping directory names
19 # (vs. contents) out of "Recurse=Yes" lists
20 # Version 0.4, Dec. 10 -- drop the use of playable_cache, add path
21 # separator kludges for Windows
22 # Version 0.3, Dec. 8 -- revert to using PixelShape, workaround for
23 # Image.save() under Windows
24 # Version 0.2, Dec. 8 -- thumbnail caching, faster thumbnails
25 # Version 0.1, Dec. 7, 2007
27 import cgi
28 import os
29 import re
30 import random
31 import threading
32 import time
33 import urllib
34 from cStringIO import StringIO
35 from xml.sax.saxutils import escape
37 try:
38 import Image
39 except ImportError:
40 print 'Photo Plugin Error: The Python Imaging Library is not installed'
42 from Cheetah.Template import Template
43 from Cheetah.Filters import Filter
44 from lrucache import LRUCache
45 from plugin import Plugin, quote, unquote
47 SCRIPTDIR = os.path.dirname(__file__)
49 CLASS_NAME = 'Photo'
51 # Match Exif date -- YYYY:MM:DD HH:MM:SS
52 exif_date = re.compile(r'(\d{4}):(\d\d):(\d\d) (\d\d):(\d\d):(\d\d)').search
54 # Match Exif orientation, Intel and Motorola versions
55 exif_orient_i = \
56 re.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
57 exif_orient_m = \
58 re.compile('\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00').search
60 # Preload the template
61 tname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
62 PHOTO_TEMPLATE = file(tname, 'rb').read()
64 class EncodeUnicode(Filter):
65 def filter(self, val, **kw):
66 """Encode Unicode strings, by default in UTF-8"""
68 encoding = kw.get('encoding', 'utf8')
70 if type(val) == type(u''):
71 filtered = val.encode(encoding)
72 else:
73 filtered = str(val)
74 return filtered
76 class Photo(Plugin):
78 CONTENT_TYPE = 'x-container/tivo-photos'
80 class LockedLRUCache(LRUCache):
81 def __init__(self, num):
82 LRUCache.__init__(self, num)
83 self.lock = threading.RLock()
85 def acquire(self, blocking=1):
86 return self.lock.acquire(blocking)
88 def release(self):
89 self.lock.release()
91 def __setitem__(self, key, obj):
92 self.acquire()
93 try:
94 LRUCache.__setitem__(self, key, obj)
95 finally:
96 self.release()
98 def __getitem__(self, key):
99 item = None
100 self.acquire()
101 try:
102 item = LRUCache.__getitem__(self, key)
103 finally:
104 self.release()
105 return item
107 media_data_cache = LockedLRUCache(300) # info and thumbnails
108 recurse_cache = LockedLRUCache(5) # recursive directory lists
109 dir_cache = LockedLRUCache(10) # non-recursive lists
111 def send_file(self, handler, container, name):
113 def send_jpeg(data):
114 handler.send_response(200)
115 handler.send_header('Content-Type', 'image/jpeg')
116 handler.send_header('Content-Length', len(data))
117 handler.send_header('Connection', 'close')
118 handler.end_headers()
119 handler.wfile.write(data)
121 path, query = handler.path.split('?')
122 infile = os.path.join(os.path.normpath(container['path']),
123 unquote(path)[len(name) + 2:])
124 opts = cgi.parse_qs(query)
126 if 'Format' in opts and opts['Format'][0] != 'image/jpeg':
127 handler.send_error(415)
128 return
130 try:
131 attrs = self.media_data_cache[infile]
132 except:
133 attrs = None
135 # Set rotation
136 if attrs:
137 rot = attrs['rotation']
138 else:
139 rot = 0
141 if 'Rotation' in opts:
142 rot = (rot - int(opts['Rotation'][0])) % 360
143 if attrs:
144 attrs['rotation'] = rot
145 if 'thumb' in attrs:
146 del attrs['thumb']
148 # Requested size
149 width = int(opts.get('Width', ['0'])[0])
150 height = int(opts.get('Height', ['0'])[0])
152 # Return saved thumbnail?
153 if attrs and 'thumb' in attrs and 0 < width < 100 and 0 < height < 100:
154 send_jpeg(attrs['thumb'])
155 return
157 # Load
158 try:
159 pic = Image.open(unicode(infile, 'utf-8'))
160 except Exception, msg:
161 print 'Could not open', infile, '--', msg
162 handler.send_error(404)
163 return
165 # Set draft mode
166 try:
167 pic.draft('RGB', (width, height))
168 except Exception, msg:
169 print 'Failed to set draft mode for', infile, '--', msg
170 handler.send_error(404)
171 return
173 # Read Exif data if possible
174 if 'exif' in pic.info:
175 exif = pic.info['exif']
177 # Capture date
178 if attrs and not 'odate' in attrs:
179 date = exif_date(exif)
180 if date:
181 year, month, day, hour, minute, second = (int(x)
182 for x in date.groups())
183 if year:
184 odate = time.mktime((year, month, day, hour,
185 minute, second, -1, -1, -1))
186 attrs['odate'] = '%#x' % int(odate)
188 # Orientation
189 if attrs and 'exifrot' in attrs:
190 rot = (rot + attrs['exifrot']) % 360
191 else:
192 if exif[6] == 'I':
193 orient = exif_orient_i(exif)
194 else:
195 orient = exif_orient_m(exif)
197 if orient:
198 exifrot = {
199 1: 0,
200 2: 0,
201 3: 180,
202 4: 180,
203 5: 90,
204 6: -90,
205 7: -90,
206 8: 90}.get(ord(orient.group(1)), 0)
208 rot = (rot + exifrot) % 360
209 if attrs:
210 attrs['exifrot'] = exifrot
212 # Rotate
213 try:
214 if rot:
215 pic = pic.rotate(rot)
216 except Exception, msg:
217 print 'Rotate failed on', infile, '--', msg
218 handler.send_error(404)
219 return
221 # De-palletize
222 try:
223 if pic.mode == 'P':
224 pic = pic.convert()
225 except Exception, msg:
226 print 'Palette conversion failed on', infile, '--', msg
227 handler.send_error(404)
228 return
230 # Old size
231 oldw, oldh = pic.size
233 if not width: width = oldw
234 if not height: height = oldh
236 # Correct aspect ratio
237 if 'PixelShape' in opts:
238 pixw, pixh = opts['PixelShape'][0].split(':')
239 oldw *= int(pixh)
240 oldh *= int(pixw)
242 # Resize
243 ratio = float(oldw) / oldh
245 if float(width) / height < ratio:
246 height = int(width / ratio)
247 else:
248 width = int(height * ratio)
250 try:
251 pic = pic.resize((width, height), Image.ANTIALIAS)
252 except Exception, msg:
253 print 'Resize failed on', infile, '--', msg
254 handler.send_error(404)
255 return
257 # Re-encode
258 try:
259 out = StringIO()
260 pic.save(out, 'JPEG')
261 encoded = out.getvalue()
262 out.close()
263 except Exception, msg:
264 print 'Encode failed on', infile, '--', msg
265 handler.send_error(404)
266 return
268 # Save thumbnails
269 if attrs and width < 100 and height < 100:
270 attrs['thumb'] = encoded
272 # Send it
273 send_jpeg(encoded)
275 def QueryContainer(self, handler, query):
277 # Reject a malformed request -- these attributes should only
278 # appear in requests to send_file, but sometimes appear here
279 badattrs = ('Rotation', 'Width', 'Height', 'PixelShape')
280 for i in badattrs:
281 if i in query:
282 handler.send_error(404)
283 return
285 subcname = query['Container'][0]
286 cname = subcname.split('/')[0]
287 local_base_path = self.get_local_base_path(handler, query)
288 if (not cname in handler.server.containers or
289 not self.get_local_path(handler, query)):
290 handler.send_error(404)
291 return
293 def ImageFileFilter(f):
294 goodexts = ('.jpg', '.gif', '.png', '.bmp', '.tif', '.xbm',
295 '.xpm', '.pgm', '.pbm', '.ppm', '.pcx', '.tga',
296 '.fpx', '.ico', '.pcd', '.jpeg', '.tiff')
297 return os.path.splitext(f)[1].lower() in goodexts
299 def media_data(f):
300 if f.name in self.media_data_cache:
301 return self.media_data_cache[f.name]
303 item = {}
304 item['path'] = f.name
305 item['part_path'] = f.name.replace(local_base_path, '', 1)
306 item['name'] = os.path.split(f.name)[1]
307 item['is_dir'] = f.isdir
308 item['rotation'] = 0
309 item['cdate'] = '%#x' % f.cdate
310 item['mdate'] = '%#x' % f.mdate
312 self.media_data_cache[f.name] = item
313 return item
315 t = Template(PHOTO_TEMPLATE, filter=EncodeUnicode)
316 t.name = subcname
317 t.container = cname
318 t.files, t.total, t.start = self.get_files(handler, query,
319 ImageFileFilter)
320 t.files = map(media_data, t.files)
321 t.quote = quote
322 t.escape = escape
323 page = str(t)
325 handler.send_response(200)
326 handler.send_header('Content-Type', 'text/xml')
327 handler.send_header('Content-Length', len(page))
328 handler.send_header('Connection', 'close')
329 handler.end_headers()
330 handler.wfile.write(page)
332 def get_files(self, handler, query, filterFunction):
334 class FileData:
335 def __init__(self, name, isdir):
336 self.name = name
337 self.isdir = isdir
338 st = os.stat(name)
339 self.cdate = int(st.st_ctime)
340 self.mdate = int(st.st_mtime)
342 class SortList:
343 def __init__(self, files):
344 self.files = files
345 self.unsorted = True
346 self.sortby = None
347 self.last_start = 0
348 self.lock = threading.RLock()
350 def acquire(self, blocking=1):
351 return self.lock.acquire(blocking)
353 def release(self):
354 self.lock.release()
356 def build_recursive_list(path, recurse=True):
357 files = []
358 path = unicode(path, 'utf-8')
359 try:
360 for f in os.listdir(path):
361 if f.startswith('.'):
362 continue
363 f = os.path.join(path, f)
364 isdir = os.path.isdir(f)
365 f = f.encode('utf-8')
366 if recurse and isdir:
367 files.extend(build_recursive_list(f))
368 else:
369 if isdir or filterFunction(f):
370 files.append(FileData(f, isdir))
371 except:
372 pass
374 return files
376 def name_sort(x, y):
377 return cmp(x.name, y.name)
379 def cdate_sort(x, y):
380 return cmp(x.cdate, y.cdate)
382 def mdate_sort(x, y):
383 return cmp(x.mdate, y.mdate)
385 def dir_sort(x, y):
386 if x.isdir == y.isdir:
387 return sortfunc(x, y)
388 else:
389 return y.isdir - x.isdir
391 subcname = query['Container'][0]
392 cname = subcname.split('/')[0]
393 path = self.get_local_path(handler, query)
395 # Build the list
396 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
398 if recurse and path in self.recurse_cache:
399 filelist = self.recurse_cache[path]
400 elif not recurse and path in self.dir_cache:
401 filelist = self.dir_cache[path]
402 else:
403 filelist = SortList(build_recursive_list(path, recurse))
405 if recurse:
406 self.recurse_cache[path] = filelist
407 else:
408 self.dir_cache[path] = filelist
410 filelist.acquire()
412 # Sort it
413 seed = ''
414 start = ''
415 sortby = query.get('SortOrder', ['Normal'])[0]
416 if 'Random' in sortby:
417 if 'RandomSeed' in query:
418 seed = query['RandomSeed'][0]
419 sortby += seed
420 if 'RandomStart' in query:
421 start = query['RandomStart'][0]
422 sortby += start
424 if filelist.unsorted or filelist.sortby != sortby:
425 if 'Random' in sortby:
426 self.random_lock.acquire()
427 if seed:
428 random.seed(seed)
429 random.shuffle(filelist.files)
430 self.random_lock.release()
431 if start:
432 local_base_path = self.get_local_base_path(handler, query)
433 start = unquote(start)
434 start = start.replace(os.path.sep + cname,
435 local_base_path, 1)
436 filenames = [x.name for x in filelist.files]
437 try:
438 index = filenames.index(start)
439 i = filelist.files.pop(index)
440 filelist.files.insert(0, i)
441 except ValueError:
442 print 'Start not found:', start
443 else:
444 if 'CaptureDate' in sortby:
445 sortfunc = cdate_sort
446 elif 'LastChangeDate' in sortby:
447 sortfunc = mdate_sort
448 else:
449 sortfunc = name_sort
451 if 'Type' in sortby:
452 filelist.files.sort(dir_sort)
453 else:
454 filelist.files.sort(sortfunc)
456 filelist.sortby = sortby
457 filelist.unsorted = False
459 files = filelist.files[:]
461 # Filter it -- this section needs work
462 if 'Filter' in query:
463 usedir = 'folder' in query['Filter'][0]
464 useimg = 'image' in query['Filter'][0]
465 if not usedir:
466 files = [x for x in files if not x.isdir]
467 elif usedir and not useimg:
468 files = [x for x in files if x.isdir]
470 files, total, start = self.item_count(handler, query, cname, files,
471 filelist.last_start)
472 filelist.last_start = start
473 filelist.release()
474 return files, total, start