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
34 from cStringIO
import StringIO
35 from xml
.sax
.saxutils
import escape
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__
)
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
56 re
.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
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
)
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
)
91 def __setitem__(self
, key
, obj
):
94 LRUCache
.__setitem
__(self
, key
, obj
)
98 def __getitem__(self
, key
):
102 item
= LRUCache
.__getitem
__(self
, key
)
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
):
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)
131 attrs
= self
.media_data_cache
[infile
]
137 rot
= attrs
['rotation']
141 if 'Rotation' in opts
:
142 rot
= (rot
- int(opts
['Rotation'][0])) % 360
144 attrs
['rotation'] = rot
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'])
159 pic
= Image
.open(unicode(infile
, 'utf-8'))
160 except Exception, msg
:
161 print 'Could not open', infile
, '--', msg
162 handler
.send_error(404)
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)
173 # Read Exif data if possible
174 if 'exif' in pic
.info
:
175 exif
= pic
.info
['exif']
178 if attrs
and not 'odate' in attrs
:
179 date
= exif_date(exif
)
181 year
, month
, day
, hour
, minute
, second
= (int(x
)
182 for x
in date
.groups())
184 odate
= time
.mktime((year
, month
, day
, hour
,
185 minute
, second
, -1, -1, -1))
186 attrs
['odate'] = '%#x' % int(odate
)
189 if attrs
and 'exifrot' in attrs
:
190 rot
= (rot
+ attrs
['exifrot']) % 360
193 orient
= exif_orient_i(exif
)
195 orient
= exif_orient_m(exif
)
206 8: 90}.get(ord(orient
.group(1)), 0)
208 rot
= (rot
+ exifrot
) % 360
210 attrs
['exifrot'] = exifrot
215 pic
= pic
.rotate(rot
)
216 except Exception, msg
:
217 print 'Rotate failed on', infile
, '--', msg
218 handler
.send_error(404)
225 except Exception, msg
:
226 print 'Palette conversion failed on', infile
, '--', msg
227 handler
.send_error(404)
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(':')
243 ratio
= float(oldw
) / oldh
245 if float(width
) / height
< ratio
:
246 height
= int(width
/ ratio
)
248 width
= int(height
* ratio
)
251 pic
= pic
.resize((width
, height
), Image
.ANTIALIAS
)
252 except Exception, msg
:
253 print 'Resize failed on', infile
, '--', msg
254 handler
.send_error(404)
260 pic
.save(out
, 'JPEG')
261 encoded
= out
.getvalue()
263 except Exception, msg
:
264 print 'Encode failed on', infile
, '--', msg
265 handler
.send_error(404)
269 if attrs
and width
< 100 and height
< 100:
270 attrs
['thumb'] = 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')
282 handler
.send_error(404)
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)
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
300 if f
.name
in self
.media_data_cache
:
301 return self
.media_data_cache
[f
.name
]
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
309 item
['cdate'] = '%#x' % f
.cdate
310 item
['mdate'] = '%#x' % f
.mdate
312 self
.media_data_cache
[f
.name
] = item
315 t
= Template(PHOTO_TEMPLATE
, filter=EncodeUnicode
)
318 t
.files
, t
.total
, t
.start
= self
.get_files(handler
, query
,
320 t
.files
= map(media_data
, t
.files
)
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
):
335 def __init__(self
, name
, isdir
):
339 self
.cdate
= int(st
.st_ctime
)
340 self
.mdate
= int(st
.st_mtime
)
343 def __init__(self
, files
):
348 self
.lock
= threading
.RLock()
350 def acquire(self
, blocking
=1):
351 return self
.lock
.acquire(blocking
)
356 def build_recursive_list(path
, recurse
=True):
358 path
= unicode(path
, 'utf-8')
360 for f
in os
.listdir(path
):
361 if f
.startswith('.'):
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
))
369 if isdir
or filterFunction(f
):
370 files
.append(FileData(f
, isdir
))
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
)
386 if x
.isdir
== y
.isdir
:
387 return sortfunc(x
, y
)
389 return y
.isdir
- x
.isdir
391 subcname
= query
['Container'][0]
392 cname
= subcname
.split('/')[0]
393 path
= self
.get_local_path(handler
, query
)
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
]
403 filelist
= SortList(build_recursive_list(path
, recurse
))
406 self
.recurse_cache
[path
] = filelist
408 self
.dir_cache
[path
] = filelist
415 sortby
= query
.get('SortOrder', ['Normal'])[0]
416 if 'Random' in sortby
:
417 if 'RandomSeed' in query
:
418 seed
= query
['RandomSeed'][0]
420 if 'RandomStart' in query
:
421 start
= query
['RandomStart'][0]
424 if filelist
.unsorted
or filelist
.sortby
!= sortby
:
425 if 'Random' in sortby
:
426 self
.random_lock
.acquire()
429 random
.shuffle(filelist
.files
)
430 self
.random_lock
.release()
432 local_base_path
= self
.get_local_base_path(handler
, query
)
433 start
= unquote(start
)
434 start
= start
.replace(os
.path
.sep
+ cname
,
436 filenames
= [x
.name
for x
in filelist
.files
]
438 index
= filenames
.index(start
)
439 i
= filelist
.files
.pop(index
)
440 filelist
.files
.insert(0, i
)
442 print 'Start not found:', start
444 if 'CaptureDate' in sortby
:
445 sortfunc
= cdate_sort
446 elif 'LastChangeDate' in sortby
:
447 sortfunc
= mdate_sort
452 filelist
.files
.sort(dir_sort
)
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]
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
,
472 filelist
.last_start
= start
474 return files
, total
, start