Fix case where no PAR is specified by the video file.
[pyTivo/TheBayer.git] / plugins / photo / photo.py
blob199169e29a56310dd021d50942bc7617535642bc
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 lrucache import LRUCache
44 from plugin import EncodeUnicode, Plugin, quote, unquote
46 SCRIPTDIR = os.path.dirname(__file__)
48 CLASS_NAME = 'Photo'
50 # Match Exif date -- YYYY:MM:DD HH:MM:SS
51 exif_date = re.compile(r'(\d{4}):(\d\d):(\d\d) (\d\d):(\d\d):(\d\d)').search
53 # Match Exif orientation, Intel and Motorola versions
54 exif_orient_i = \
55 re.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
56 exif_orient_m = \
57 re.compile('\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00').search
59 # Preload the template
60 tname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
61 PHOTO_TEMPLATE = file(tname, 'rb').read()
63 class Photo(Plugin):
65 CONTENT_TYPE = 'x-container/tivo-photos'
67 class LockedLRUCache(LRUCache):
68 def __init__(self, num):
69 LRUCache.__init__(self, num)
70 self.lock = threading.RLock()
72 def acquire(self, blocking=1):
73 return self.lock.acquire(blocking)
75 def release(self):
76 self.lock.release()
78 def __setitem__(self, key, obj):
79 self.acquire()
80 try:
81 LRUCache.__setitem__(self, key, obj)
82 finally:
83 self.release()
85 def __getitem__(self, key):
86 item = None
87 self.acquire()
88 try:
89 item = LRUCache.__getitem__(self, key)
90 finally:
91 self.release()
92 return item
94 media_data_cache = LockedLRUCache(300) # info and thumbnails
95 recurse_cache = LockedLRUCache(5) # recursive directory lists
96 dir_cache = LockedLRUCache(10) # non-recursive lists
98 def send_file(self, handler, path, query):
100 def send_jpeg(data):
101 handler.send_response(200)
102 handler.send_header('Content-Type', 'image/jpeg')
103 handler.send_header('Content-Length', len(data))
104 handler.send_header('Connection', 'close')
105 handler.end_headers()
106 handler.wfile.write(data)
108 if 'Format' in query and query['Format'][0] != 'image/jpeg':
109 handler.send_error(415)
110 return
112 try:
113 attrs = self.media_data_cache[path]
114 except:
115 attrs = None
117 # Set rotation
118 if attrs:
119 rot = attrs['rotation']
120 else:
121 rot = 0
123 if 'Rotation' in query:
124 rot = (rot - int(query['Rotation'][0])) % 360
125 if attrs:
126 attrs['rotation'] = rot
127 if 'thumb' in attrs:
128 del attrs['thumb']
130 # Requested size
131 width = int(query.get('Width', ['0'])[0])
132 height = int(query.get('Height', ['0'])[0])
134 # Return saved thumbnail?
135 if attrs and 'thumb' in attrs and 0 < width < 100 and 0 < height < 100:
136 send_jpeg(attrs['thumb'])
137 return
139 # Load
140 try:
141 pic = Image.open(unicode(path, 'utf-8'))
142 except Exception, msg:
143 handler.server.logger.error('Could not open %s -- %s' %
144 (path, msg))
145 handler.send_error(404)
146 return
148 # Set draft mode
149 try:
150 pic.draft('RGB', (width, height))
151 except Exception, msg:
152 handler.server.logger.error('Failed to set draft mode ' +
153 'for %s -- %s' % (path, msg))
154 handler.send_error(404)
155 return
157 # Read Exif data if possible
158 if 'exif' in pic.info:
159 exif = pic.info['exif']
161 # Capture date
162 if attrs and not 'odate' in attrs:
163 date = exif_date(exif)
164 if date:
165 year, month, day, hour, minute, second = (int(x)
166 for x in date.groups())
167 if year:
168 odate = time.mktime((year, month, day, hour,
169 minute, second, -1, -1, -1))
170 attrs['odate'] = '%#x' % int(odate)
172 # Orientation
173 if attrs and 'exifrot' in attrs:
174 rot = (rot + attrs['exifrot']) % 360
175 else:
176 if exif[6] == 'I':
177 orient = exif_orient_i(exif)
178 else:
179 orient = exif_orient_m(exif)
181 if orient:
182 exifrot = {
183 1: 0,
184 2: 0,
185 3: 180,
186 4: 180,
187 5: 90,
188 6: -90,
189 7: -90,
190 8: 90}.get(ord(orient.group(1)), 0)
192 rot = (rot + exifrot) % 360
193 if attrs:
194 attrs['exifrot'] = exifrot
196 # Rotate
197 try:
198 if rot:
199 pic = pic.rotate(rot)
200 except Exception, msg:
201 handler.server.logger.error('Rotate failed on %s -- %s' %
202 (path, msg))
203 handler.send_error(404)
204 return
206 # De-palletize
207 try:
208 if pic.mode == 'P':
209 pic = pic.convert()
210 except Exception, msg:
211 handler.server.logger.error('Palette conversion failed ' +
212 'on %s -- %s' % (path, msg))
213 handler.send_error(404)
214 return
216 # Old size
217 oldw, oldh = pic.size
219 if not width: width = oldw
220 if not height: height = oldh
222 # Correct aspect ratio
223 if 'PixelShape' in query:
224 pixw, pixh = query['PixelShape'][0].split(':')
225 oldw *= int(pixh)
226 oldh *= int(pixw)
228 # Resize
229 ratio = float(oldw) / oldh
231 if float(width) / height < ratio:
232 height = int(width / ratio)
233 else:
234 width = int(height * ratio)
236 try:
237 pic = pic.resize((width, height), Image.ANTIALIAS)
238 except Exception, msg:
239 handler.server.logger.error('Resize failed on %s -- %s' %
240 (path, msg))
241 handler.send_error(404)
242 return
244 # Re-encode
245 try:
246 out = StringIO()
247 pic.save(out, 'JPEG')
248 encoded = out.getvalue()
249 out.close()
250 except Exception, msg:
251 handler.server.logger.error('Encode failed on %s -- %s' %
252 (path, msg))
253 handler.send_error(404)
254 return
256 # Save thumbnails
257 if attrs and width < 100 and height < 100:
258 attrs['thumb'] = encoded
260 # Send it
261 send_jpeg(encoded)
263 def QueryContainer(self, handler, query):
265 # Reject a malformed request -- these attributes should only
266 # appear in requests to send_file, but sometimes appear here
267 badattrs = ('Rotation', 'Width', 'Height', 'PixelShape')
268 for i in badattrs:
269 if i in query:
270 handler.send_error(404)
271 return
273 subcname = query['Container'][0]
274 cname = subcname.split('/')[0]
275 local_base_path = self.get_local_base_path(handler, query)
276 if (not cname in handler.server.containers or
277 not self.get_local_path(handler, query)):
278 handler.send_error(404)
279 return
281 def ImageFileFilter(f):
282 goodexts = ('.jpg', '.gif', '.png', '.bmp', '.tif', '.xbm',
283 '.xpm', '.pgm', '.pbm', '.ppm', '.pcx', '.tga',
284 '.fpx', '.ico', '.pcd', '.jpeg', '.tiff')
285 return os.path.splitext(f)[1].lower() in goodexts
287 def media_data(f):
288 if f.name in self.media_data_cache:
289 return self.media_data_cache[f.name]
291 item = {}
292 item['path'] = f.name
293 item['part_path'] = f.name.replace(local_base_path, '', 1)
294 item['name'] = os.path.split(f.name)[1]
295 item['is_dir'] = f.isdir
296 item['rotation'] = 0
297 item['cdate'] = '%#x' % f.cdate
298 item['mdate'] = '%#x' % f.mdate
300 self.media_data_cache[f.name] = item
301 return item
303 t = Template(PHOTO_TEMPLATE, filter=EncodeUnicode)
304 t.name = subcname
305 t.container = cname
306 t.files, t.total, t.start = self.get_files(handler, query,
307 ImageFileFilter)
308 t.files = map(media_data, t.files)
309 t.quote = quote
310 t.escape = escape
311 page = str(t)
313 handler.send_response(200)
314 handler.send_header('Content-Type', 'text/xml')
315 handler.send_header('Content-Length', len(page))
316 handler.send_header('Connection', 'close')
317 handler.end_headers()
318 handler.wfile.write(page)
320 def get_files(self, handler, query, filterFunction):
322 class FileData:
323 def __init__(self, name, isdir):
324 self.name = name
325 self.isdir = isdir
326 st = os.stat(unicode(name, 'utf-8'))
327 self.cdate = int(st.st_ctime)
328 self.mdate = int(st.st_mtime)
330 class SortList:
331 def __init__(self, files):
332 self.files = files
333 self.unsorted = True
334 self.sortby = None
335 self.last_start = 0
336 self.lock = threading.RLock()
338 def acquire(self, blocking=1):
339 return self.lock.acquire(blocking)
341 def release(self):
342 self.lock.release()
344 def build_recursive_list(path, recurse=True):
345 files = []
346 path = unicode(path, 'utf-8')
347 try:
348 for f in os.listdir(path):
349 if f.startswith('.'):
350 continue
351 f = os.path.join(path, f)
352 isdir = os.path.isdir(f)
353 f = f.encode('utf-8')
354 if recurse and isdir:
355 files.extend(build_recursive_list(f))
356 else:
357 if isdir or filterFunction(f):
358 files.append(FileData(f, isdir))
359 except:
360 pass
362 return files
364 def name_sort(x, y):
365 return cmp(x.name, y.name)
367 def cdate_sort(x, y):
368 return cmp(x.cdate, y.cdate)
370 def mdate_sort(x, y):
371 return cmp(x.mdate, y.mdate)
373 def dir_sort(x, y):
374 if x.isdir == y.isdir:
375 return sortfunc(x, y)
376 else:
377 return y.isdir - x.isdir
379 subcname = query['Container'][0]
380 cname = subcname.split('/')[0]
381 path = self.get_local_path(handler, query)
383 # Build the list
384 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
386 filelist = []
387 rc = self.recurse_cache
388 dc = self.dir_cache
389 if recurse:
390 if path in rc:
391 filelist = rc[path]
392 else:
393 updated = os.stat(unicode(path, 'utf-8'))[8]
394 if path in dc and dc.mtime(path) >= updated:
395 filelist = dc[path]
396 for p in rc:
397 if path.startswith(p) and rc.mtime(p) < updated:
398 del rc[p]
400 if not filelist:
401 filelist = SortList(build_recursive_list(path, recurse))
403 if recurse:
404 rc[path] = filelist
405 else:
406 dc[path] = filelist
408 filelist.acquire()
410 # Sort it
411 seed = ''
412 start = ''
413 sortby = query.get('SortOrder', ['Normal'])[0]
414 if 'Random' in sortby:
415 if 'RandomSeed' in query:
416 seed = query['RandomSeed'][0]
417 sortby += seed
418 if 'RandomStart' in query:
419 start = query['RandomStart'][0]
420 sortby += start
422 if filelist.unsorted or filelist.sortby != sortby:
423 if 'Random' in sortby:
424 self.random_lock.acquire()
425 if seed:
426 random.seed(seed)
427 random.shuffle(filelist.files)
428 self.random_lock.release()
429 if start:
430 local_base_path = self.get_local_base_path(handler, query)
431 start = unquote(start)
432 start = start.replace(os.path.sep + cname,
433 local_base_path, 1)
434 filenames = [x.name for x in filelist.files]
435 try:
436 index = filenames.index(start)
437 i = filelist.files.pop(index)
438 filelist.files.insert(0, i)
439 except ValueError:
440 handler.server.logger.warning('Start not found: ' +
441 start)
442 else:
443 if 'CaptureDate' in sortby:
444 sortfunc = cdate_sort
445 elif 'LastChangeDate' in sortby:
446 sortfunc = mdate_sort
447 else:
448 sortfunc = name_sort
450 if 'Type' in sortby:
451 filelist.files.sort(dir_sort)
452 else:
453 filelist.files.sort(sortfunc)
455 filelist.sortby = sortby
456 filelist.unsorted = False
458 files = filelist.files[:]
460 # Filter it -- this section needs work
461 if 'Filter' in query:
462 usedir = 'folder' in query['Filter'][0]
463 useimg = 'image' in query['Filter'][0]
464 if not usedir:
465 files = [x for x in files if not x.isdir]
466 elif usedir and not useimg:
467 files = [x for x in files if x.isdir]
469 files, total, start = self.item_count(handler, query, cname, files,
470 filelist.last_start)
471 filelist.last_start = start
472 filelist.release()
473 return files, total, start