Default ffmpeg_wait to 0, which makes pyTivo skip the poll()ing and just
[pyTivo/wmcbrine.git] / plugins / photo / photo.py
blobd0db7223eab8ee36b20b930bf222c83956b3819c
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 os
28 import re
29 import random
30 import subprocess
31 import sys
32 import tempfile
33 import threading
34 import time
35 import unicodedata
36 import urllib
37 from cStringIO import StringIO
38 from xml.sax.saxutils import escape
40 use_pil = True
41 try:
42 from PIL import Image
43 except ImportError:
44 try:
45 import Image
46 except ImportError:
47 use_pil = False
48 print 'Python Imaging Library not found; using FFmpeg'
50 import config
51 from Cheetah.Template import Template
52 from lrucache import LRUCache
53 from plugin import EncodeUnicode, Plugin, quote, unquote
54 from plugins.video.transcode import kill
56 SCRIPTDIR = os.path.dirname(__file__)
58 CLASS_NAME = 'Photo'
60 # Match Exif date -- YYYY:MM:DD HH:MM:SS
61 exif_date = re.compile(r'(\d{4}):(\d\d):(\d\d) (\d\d):(\d\d):(\d\d)').search
63 # Match Exif orientation, Intel and Motorola versions
64 exif_orient_i = \
65 re.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
66 exif_orient_m = \
67 re.compile('\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00').search
69 # Find size in FFmpeg output
70 ffmpeg_size = re.compile(r'.*Video: .+, (\d+)x(\d+)[, ].*')
72 # Preload the template
73 tname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
74 iname = os.path.join(SCRIPTDIR, 'templates', 'item.tmpl')
75 PHOTO_TEMPLATE = file(tname, 'rb').read()
76 ITEM_TEMPLATE = file(iname, 'rb').read()
78 JFIF_TAG = '\xff\xe0\x00\x10JFIF\x00\x01\x02\x00\x00\x01\x00\x01\x00\x00'
80 class Photo(Plugin):
82 CONTENT_TYPE = 'x-container/tivo-photos'
84 class LockedLRUCache(LRUCache):
85 def __init__(self, num):
86 LRUCache.__init__(self, num)
87 self.lock = threading.RLock()
89 def acquire(self, blocking=1):
90 return self.lock.acquire(blocking)
92 def release(self):
93 self.lock.release()
95 def __setitem__(self, key, obj):
96 self.acquire()
97 try:
98 LRUCache.__setitem__(self, key, obj)
99 finally:
100 self.release()
102 def __getitem__(self, key):
103 item = None
104 self.acquire()
105 try:
106 item = LRUCache.__getitem__(self, key)
107 finally:
108 self.release()
109 return item
111 media_data_cache = LockedLRUCache(300) # info and thumbnails
112 recurse_cache = LockedLRUCache(5) # recursive directory lists
113 dir_cache = LockedLRUCache(10) # non-recursive lists
115 def new_size(self, oldw, oldh, width, height, pshape):
116 pixw, pixh = [int(x) for x in pshape.split(':')]
118 if not width: width = oldw
119 if not height: height = oldh
121 oldw *= pixh
122 oldh *= pixw
124 ratio = float(oldw) / oldh
126 if float(width) / height < ratio:
127 height = int(width / ratio)
128 else:
129 width = int(height * ratio)
131 return width, height
133 def parse_exif(self, exif, rot, attrs):
134 # Capture date
135 if attrs and not 'odate' in attrs:
136 date = exif_date(exif)
137 if date:
138 year, month, day, hour, minute, second = (int(x)
139 for x in date.groups())
140 if year:
141 odate = time.mktime((year, month, day, hour,
142 minute, second, -1, -1, -1))
143 attrs['odate'] = '%#x' % int(odate)
145 # Orientation
146 if attrs and 'exifrot' in attrs:
147 rot = (rot + attrs['exifrot']) % 360
148 else:
149 if exif[6] == 'I':
150 orient = exif_orient_i(exif)
151 else:
152 orient = exif_orient_m(exif)
154 if orient:
155 exifrot = {
156 1: 0,
157 2: 0,
158 3: 180,
159 4: 180,
160 5: 90,
161 6: -90,
162 7: -90,
163 8: 90}.get(ord(orient.group(1)), 0)
165 rot = (rot + exifrot) % 360
166 if attrs:
167 attrs['exifrot'] = exifrot
169 return rot
171 def get_image_pil(self, path, width, height, pshape, rot, attrs):
172 # Load
173 try:
174 pic = Image.open(unicode(path, 'utf-8'))
175 except Exception, msg:
176 return False, 'Could not open %s -- %s' % (path, msg)
178 # Set draft mode
179 try:
180 pic.draft('RGB', (width, height))
181 except Exception, msg:
182 return False, 'Failed to set draft mode for %s -- %s' % (path, msg)
184 # Read Exif data if possible
185 if 'exif' in pic.info:
186 rot = self.parse_exif(pic.info['exif'], rot, attrs)
188 # Rotate
189 try:
190 if rot:
191 pic = pic.rotate(rot)
192 except Exception, msg:
193 return False, 'Rotate failed on %s -- %s' % (path, msg)
195 # De-palletize
196 try:
197 if pic.mode not in ('RGB', 'L'):
198 pic = pic.convert('RGB')
199 except Exception, msg:
200 return False, 'Palette conversion failed on %s -- %s' % (path, msg)
202 # Old size
203 oldw, oldh = pic.size
205 width, height = self.new_size(oldw, oldh, width, height, pshape)
207 try:
208 pic = pic.resize((width, height), Image.ANTIALIAS)
209 except Exception, msg:
210 return False, 'Resize failed on %s -- %s' % (path, msg)
212 # Re-encode
213 try:
214 out = StringIO()
215 pic.save(out, 'JPEG', quality=85)
216 encoded = out.getvalue()
217 out.close()
218 except Exception, msg:
219 return False, 'Encode failed on %s -- %s' % (path, msg)
221 return True, encoded
223 def get_size_ffmpeg(self, ffmpeg_path, fname):
224 cmd = [ffmpeg_path, '-i', fname]
225 # Windows and other OS buffer 4096 and ffmpeg can output more
226 # than that.
227 err_tmp = tempfile.TemporaryFile()
228 ffmpeg = subprocess.Popen(cmd, stderr=err_tmp,
229 stdout=subprocess.PIPE,
230 stdin=subprocess.PIPE)
232 # wait configured # of seconds: if ffmpeg is not back give up
233 limit = config.getFFmpegWait()
234 if limit:
235 for i in xrange(limit * 20):
236 time.sleep(.05)
237 if not ffmpeg.poll() == None:
238 break
240 if ffmpeg.poll() == None:
241 kill(ffmpeg)
242 return False, 'FFmpeg timed out'
243 else:
244 ffmpeg.wait()
246 err_tmp.seek(0)
247 output = err_tmp.read()
248 err_tmp.close()
250 x = ffmpeg_size.search(output)
251 if x:
252 width = int(x.group(1))
253 height = int(x.group(2))
254 else:
255 return False, "Couldn't parse size"
257 return True, (width, height)
259 def get_image_ffmpeg(self, path, width, height, pshape, rot, attrs):
260 ffmpeg_path = config.get_bin('ffmpeg')
261 if not ffmpeg_path:
262 return False, 'FFmpeg not found'
264 fname = unicode(path, 'utf-8')
265 if sys.platform == 'win32':
266 fname = fname.encode('iso8859-1')
268 if attrs and 'size' in attrs:
269 result = attrs['size']
270 else:
271 status, result = self.get_size_ffmpeg(ffmpeg_path, fname)
272 if not status:
273 return False, result
274 if attrs:
275 attrs['size'] = result
277 if rot in (90, 270):
278 oldh, oldw = result
279 else:
280 oldw, oldh = result
282 width, height = self.new_size(oldw, oldh, width, height, pshape)
284 if rot == 270:
285 filters = 'transpose=1,'
286 elif rot == 180:
287 filters = 'hflip,vflip,'
288 elif rot == 90:
289 filters = 'transpose=2,'
290 else:
291 filters = ''
293 filters += 'format=yuvj420p,'
295 neww, newh = oldw, oldh
296 while (neww / width >= 50) or (newh / height >= 50):
297 neww /= 2
298 newh /= 2
299 filters += 'scale=%d:%d,' % (neww, newh)
301 filters += 'scale=%d:%d' % (width, height)
303 cmd = [ffmpeg_path, '-i', fname, '-vf', filters, '-f', 'mjpeg', '-']
304 jpeg_tmp = tempfile.TemporaryFile()
305 ffmpeg = subprocess.Popen(cmd, stdout=jpeg_tmp,
306 stdin=subprocess.PIPE)
308 # wait configured # of seconds: if ffmpeg is not back give up
309 limit = config.getFFmpegWait()
310 if limit:
311 for i in xrange(limit * 20):
312 time.sleep(.05)
313 if not ffmpeg.poll() == None:
314 break
316 if ffmpeg.poll() == None:
317 kill(ffmpeg)
318 return False, 'FFmpeg timed out'
319 else:
320 ffmpeg.wait()
322 jpeg_tmp.seek(0)
323 output = jpeg_tmp.read()
324 jpeg_tmp.close()
326 if 'JFIF' not in output[:10]:
327 output = output[:2] + JFIF_TAG + output[2:]
329 return True, output
331 def send_file(self, handler, path, query):
333 def send_jpeg(data):
334 handler.send_fixed(data, 'image/jpeg')
336 if 'Format' in query and query['Format'][0] != 'image/jpeg':
337 handler.send_error(415)
338 return
340 try:
341 attrs = self.media_data_cache[path]
342 except:
343 attrs = None
345 # Set rotation
346 if attrs:
347 rot = attrs['rotation']
348 else:
349 rot = 0
351 if 'Rotation' in query:
352 rot = (rot - int(query['Rotation'][0])) % 360
353 if attrs:
354 attrs['rotation'] = rot
355 if 'thumb' in attrs:
356 del attrs['thumb']
358 # Requested size
359 width = int(query.get('Width', ['0'])[0])
360 height = int(query.get('Height', ['0'])[0])
362 # Return saved thumbnail?
363 if attrs and 'thumb' in attrs and 0 < width < 100 and 0 < height < 100:
364 send_jpeg(attrs['thumb'])
365 return
367 # Requested pixel shape
368 pshape = query.get('PixelShape', ['1:1'])[0]
370 # Build a new image
371 if use_pil:
372 status, result = self.get_image_pil(path, width, height,
373 pshape, rot, attrs)
374 else:
375 status, result = self.get_image_ffmpeg(path, width, height,
376 pshape, rot, attrs)
378 if status:
379 # Save thumbnails
380 if attrs and width < 100 and height < 100:
381 attrs['thumb'] = result
383 # Send it
384 send_jpeg(result)
385 else:
386 handler.server.logger.error(result)
387 handler.send_error(404)
389 def QueryContainer(self, handler, query):
391 # Reject a malformed request -- these attributes should only
392 # appear in requests to send_file, but sometimes appear here
393 badattrs = ('Rotation', 'Width', 'Height', 'PixelShape')
394 for i in badattrs:
395 if i in query:
396 handler.send_error(404)
397 return
399 local_base_path = self.get_local_base_path(handler, query)
400 if not self.get_local_path(handler, query):
401 handler.send_error(404)
402 return
404 def ImageFileFilter(f):
405 goodexts = ('.jpg', '.gif', '.png', '.bmp', '.tif', '.xbm',
406 '.xpm', '.pgm', '.pbm', '.ppm', '.pcx', '.tga',
407 '.fpx', '.ico', '.pcd', '.jpeg', '.tiff', '.nef')
408 return os.path.splitext(f)[1].lower() in goodexts
410 def media_data(f):
411 if f.name in self.media_data_cache:
412 return self.media_data_cache[f.name]
414 item = {}
415 item['path'] = f.name
416 item['part_path'] = f.name.replace(local_base_path, '', 1)
417 item['name'] = os.path.basename(f.name)
418 item['is_dir'] = f.isdir
419 item['rotation'] = 0
420 item['cdate'] = '%#x' % f.cdate
421 item['mdate'] = '%#x' % f.mdate
423 self.media_data_cache[f.name] = item
424 return item
426 t = Template(PHOTO_TEMPLATE, filter=EncodeUnicode)
427 t.name = query['Container'][0]
428 t.container = handler.cname
429 t.files, t.total, t.start = self.get_files(handler, query,
430 ImageFileFilter)
431 t.files = map(media_data, t.files)
432 t.quote = quote
433 t.escape = escape
435 handler.send_xml(str(t))
437 def QueryItem(self, handler, query):
438 uq = urllib.unquote_plus
439 splitpath = [x for x in uq(query['Url'][0]).split('/') if x]
440 path = os.path.join(handler.container['path'], *splitpath[1:])
442 if path in self.media_data_cache:
443 t = Template(ITEM_TEMPLATE, filter=EncodeUnicode)
444 t.file = self.media_data_cache[path]
445 t.escape = escape
446 handler.send_xml(str(t))
447 else:
448 handler.send_error(404)
450 def get_files(self, handler, query, filterFunction):
452 class FileData:
453 def __init__(self, name, isdir):
454 self.name = name
455 self.isdir = isdir
456 st = os.stat(unicode(name, 'utf-8'))
457 self.cdate = int(st.st_ctime)
458 self.mdate = int(st.st_mtime)
460 class SortList:
461 def __init__(self, files):
462 self.files = files
463 self.unsorted = True
464 self.sortby = None
465 self.last_start = 0
466 self.lock = threading.RLock()
468 def acquire(self, blocking=1):
469 return self.lock.acquire(blocking)
471 def release(self):
472 self.lock.release()
474 def build_recursive_list(path, recurse=True):
475 files = []
476 path = unicode(path, 'utf-8')
477 try:
478 for f in os.listdir(path):
479 if f.startswith('.'):
480 continue
481 f = os.path.join(path, f)
482 isdir = os.path.isdir(f)
483 if sys.platform == 'darwin':
484 f = unicodedata.normalize('NFC', f)
485 f = f.encode('utf-8')
486 if recurse and isdir:
487 files.extend(build_recursive_list(f))
488 else:
489 if isdir or filterFunction(f):
490 files.append(FileData(f, isdir))
491 except:
492 pass
494 return files
496 def name_sort(x, y):
497 return cmp(x.name, y.name)
499 def cdate_sort(x, y):
500 return cmp(x.cdate, y.cdate)
502 def mdate_sort(x, y):
503 return cmp(x.mdate, y.mdate)
505 def dir_sort(x, y):
506 if x.isdir == y.isdir:
507 return sortfunc(x, y)
508 else:
509 return y.isdir - x.isdir
511 path = self.get_local_path(handler, query)
513 # Build the list
514 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
516 filelist = []
517 rc = self.recurse_cache
518 dc = self.dir_cache
519 if recurse:
520 if path in rc:
521 filelist = rc[path]
522 else:
523 updated = os.stat(unicode(path, 'utf-8'))[8]
524 if path in dc and dc.mtime(path) >= updated:
525 filelist = dc[path]
526 for p in rc:
527 if path.startswith(p) and rc.mtime(p) < updated:
528 del rc[p]
530 if not filelist:
531 filelist = SortList(build_recursive_list(path, recurse))
533 if recurse:
534 rc[path] = filelist
535 else:
536 dc[path] = filelist
538 filelist.acquire()
540 # Sort it
541 seed = ''
542 start = ''
543 sortby = query.get('SortOrder', ['Normal'])[0]
544 if 'Random' in sortby:
545 if 'RandomSeed' in query:
546 seed = query['RandomSeed'][0]
547 sortby += seed
548 if 'RandomStart' in query:
549 start = query['RandomStart'][0]
550 sortby += start
552 if filelist.unsorted or filelist.sortby != sortby:
553 if 'Random' in sortby:
554 self.random_lock.acquire()
555 if seed:
556 random.seed(seed)
557 random.shuffle(filelist.files)
558 self.random_lock.release()
559 if start:
560 local_base_path = self.get_local_base_path(handler, query)
561 start = unquote(start)
562 start = start.replace(os.path.sep + handler.cname,
563 local_base_path, 1)
564 filenames = [x.name for x in filelist.files]
565 try:
566 index = filenames.index(start)
567 i = filelist.files.pop(index)
568 filelist.files.insert(0, i)
569 except ValueError:
570 handler.server.logger.warning('Start not found: ' +
571 start)
572 else:
573 if 'CaptureDate' in sortby:
574 sortfunc = cdate_sort
575 elif 'LastChangeDate' in sortby:
576 sortfunc = mdate_sort
577 else:
578 sortfunc = name_sort
580 if 'Type' in sortby:
581 filelist.files.sort(dir_sort)
582 else:
583 filelist.files.sort(sortfunc)
585 filelist.sortby = sortby
586 filelist.unsorted = False
588 files = filelist.files[:]
590 # Filter it -- this section needs work
591 if 'Filter' in query:
592 usedir = 'folder' in query['Filter'][0]
593 useimg = 'image' in query['Filter'][0]
594 if not usedir:
595 files = [x for x in files if not x.isdir]
596 elif usedir and not useimg:
597 files = [x for x in files if x.isdir]
599 files, total, start = self.item_count(handler, query, handler.cname,
600 files, filelist.last_start)
601 filelist.last_start = start
602 filelist.release()
603 return files, total, start