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
37 from cStringIO
import StringIO
38 from xml
.sax
.saxutils
import escape
48 print 'Python Imaging Library not found; using FFmpeg'
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__
)
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
65 re
.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
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'
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
)
95 def __setitem__(self
, key
, obj
):
98 LRUCache
.__setitem
__(self
, key
, obj
)
102 def __getitem__(self
, key
):
106 item
= LRUCache
.__getitem
__(self
, key
)
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
124 ratio
= float(oldw
) / oldh
126 if float(width
) / height
< ratio
:
127 height
= int(width
/ ratio
)
129 width
= int(height
* ratio
)
133 def parse_exif(self
, exif
, rot
, attrs
):
135 if attrs
and not 'odate' in attrs
:
136 date
= exif_date(exif
)
138 year
, month
, day
, hour
, minute
, second
= (int(x
)
139 for x
in date
.groups())
141 odate
= time
.mktime((year
, month
, day
, hour
,
142 minute
, second
, -1, -1, -1))
143 attrs
['odate'] = '%#x' % int(odate
)
146 if attrs
and 'exifrot' in attrs
:
147 rot
= (rot
+ attrs
['exifrot']) % 360
150 orient
= exif_orient_i(exif
)
152 orient
= exif_orient_m(exif
)
163 8: 90}.get(ord(orient
.group(1)), 0)
165 rot
= (rot
+ exifrot
) % 360
167 attrs
['exifrot'] = exifrot
171 def get_image_pil(self
, path
, width
, height
, pshape
, rot
, attrs
):
174 pic
= Image
.open(unicode(path
, 'utf-8'))
175 except Exception, msg
:
176 return False, 'Could not open %s -- %s' % (path
, msg
)
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
)
191 pic
= pic
.rotate(rot
)
192 except Exception, msg
:
193 return False, 'Rotate failed on %s -- %s' % (path
, msg
)
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
)
203 oldw
, oldh
= pic
.size
205 width
, height
= self
.new_size(oldw
, oldh
, width
, height
, pshape
)
208 pic
= pic
.resize((width
, height
), Image
.ANTIALIAS
)
209 except Exception, msg
:
210 return False, 'Resize failed on %s -- %s' % (path
, msg
)
215 pic
.save(out
, 'JPEG', quality
=85)
216 encoded
= out
.getvalue()
218 except Exception, msg
:
219 return False, 'Encode failed on %s -- %s' % (path
, msg
)
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
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()
235 for i
in xrange(limit
* 20):
237 if not ffmpeg
.poll() == None:
240 if ffmpeg
.poll() == None:
242 return False, 'FFmpeg timed out'
247 output
= err_tmp
.read()
250 x
= ffmpeg_size
.search(output
)
252 width
= int(x
.group(1))
253 height
= int(x
.group(2))
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')
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']
271 status
, result
= self
.get_size_ffmpeg(ffmpeg_path
, fname
)
275 attrs
['size'] = result
282 width
, height
= self
.new_size(oldw
, oldh
, width
, height
, pshape
)
285 filters
= 'transpose=1,'
287 filters
= 'hflip,vflip,'
289 filters
= 'transpose=2,'
293 filters
+= 'format=yuvj420p,'
295 neww
, newh
= oldw
, oldh
296 while (neww
/ width
>= 50) or (newh
/ height
>= 50):
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()
311 for i
in xrange(limit
* 20):
313 if not ffmpeg
.poll() == None:
316 if ffmpeg
.poll() == None:
318 return False, 'FFmpeg timed out'
323 output
= jpeg_tmp
.read()
326 if 'JFIF' not in output
[:10]:
327 output
= output
[:2] + JFIF_TAG
+ output
[2:]
331 def send_file(self
, handler
, path
, query
):
334 handler
.send_fixed(data
, 'image/jpeg')
336 if 'Format' in query
and query
['Format'][0] != 'image/jpeg':
337 handler
.send_error(415)
341 attrs
= self
.media_data_cache
[path
]
347 rot
= attrs
['rotation']
351 if 'Rotation' in query
:
352 rot
= (rot
- int(query
['Rotation'][0])) % 360
354 attrs
['rotation'] = rot
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'])
367 # Requested pixel shape
368 pshape
= query
.get('PixelShape', ['1:1'])[0]
372 status
, result
= self
.get_image_pil(path
, width
, height
,
375 status
, result
= self
.get_image_ffmpeg(path
, width
, height
,
380 if attrs
and width
< 100 and height
< 100:
381 attrs
['thumb'] = result
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')
396 handler
.send_error(404)
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)
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
411 if f
.name
in self
.media_data_cache
:
412 return self
.media_data_cache
[f
.name
]
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
420 item
['cdate'] = '%#x' % f
.cdate
421 item
['mdate'] = '%#x' % f
.mdate
423 self
.media_data_cache
[f
.name
] = 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
,
431 t
.files
= map(media_data
, t
.files
)
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
]
446 handler
.send_xml(str(t
))
448 handler
.send_error(404)
450 def get_files(self
, handler
, query
, filterFunction
):
453 def __init__(self
, name
, isdir
):
456 st
= os
.stat(unicode(name
, 'utf-8'))
457 self
.cdate
= int(st
.st_ctime
)
458 self
.mdate
= int(st
.st_mtime
)
461 def __init__(self
, files
):
466 self
.lock
= threading
.RLock()
468 def acquire(self
, blocking
=1):
469 return self
.lock
.acquire(blocking
)
474 def build_recursive_list(path
, recurse
=True):
476 path
= unicode(path
, 'utf-8')
478 for f
in os
.listdir(path
):
479 if f
.startswith('.'):
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
))
489 if isdir
or filterFunction(f
):
490 files
.append(FileData(f
, isdir
))
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
)
506 if x
.isdir
== y
.isdir
:
507 return sortfunc(x
, y
)
509 return y
.isdir
- x
.isdir
511 path
= self
.get_local_path(handler
, query
)
514 recurse
= query
.get('Recurse', ['No'])[0] == 'Yes'
517 rc
= self
.recurse_cache
523 updated
= os
.stat(unicode(path
, 'utf-8'))[8]
524 if path
in dc
and dc
.mtime(path
) >= updated
:
527 if path
.startswith(p
) and rc
.mtime(p
) < updated
:
531 filelist
= SortList(build_recursive_list(path
, recurse
))
543 sortby
= query
.get('SortOrder', ['Normal'])[0]
544 if 'Random' in sortby
:
545 if 'RandomSeed' in query
:
546 seed
= query
['RandomSeed'][0]
548 if 'RandomStart' in query
:
549 start
= query
['RandomStart'][0]
552 if filelist
.unsorted
or filelist
.sortby
!= sortby
:
553 if 'Random' in sortby
:
554 self
.random_lock
.acquire()
557 random
.shuffle(filelist
.files
)
558 self
.random_lock
.release()
560 local_base_path
= self
.get_local_base_path(handler
, query
)
561 start
= unquote(start
)
562 start
= start
.replace(os
.path
.sep
+ handler
.cname
,
564 filenames
= [x
.name
for x
in filelist
.files
]
566 index
= filenames
.index(start
)
567 i
= filelist
.files
.pop(index
)
568 filelist
.files
.insert(0, i
)
570 handler
.server
.logger
.warning('Start not found: ' +
573 if 'CaptureDate' in sortby
:
574 sortfunc
= cdate_sort
575 elif 'LastChangeDate' in sortby
:
576 sortfunc
= mdate_sort
581 filelist
.files
.sort(dir_sort
)
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]
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
603 return files
, total
, start