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
, re
, random
, urllib
, threading
, time
, cgi
29 from cStringIO
import StringIO
30 from Cheetah
.Template
import Template
31 from Cheetah
.Filters
import Filter
32 from plugin
import Plugin
33 from xml
.sax
.saxutils
import escape
34 from lrucache
import LRUCache
36 SCRIPTDIR
= os
.path
.dirname(__file__
)
40 if os
.path
.sep
== '/':
42 unquote
= urllib
.unquote_plus
44 quote
= lambda x
: urllib
.quote(x
.replace(os
.path
.sep
, '/'))
45 unquote
= lambda x
: urllib
.unquote_plus(x
).replace('/', os
.path
.sep
)
47 # Match Exif date -- YYYY:MM:DD HH:MM:SS
48 exif_date
= re
.compile(r
'(\d{4}):(\d\d):(\d\d) (\d\d):(\d\d):(\d\d)').search
50 # Match Exif orientation, Intel and Motorola versions
52 re
.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
54 re
.compile('\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00').search
56 # Preload the template
57 tname
= os
.path
.join(SCRIPTDIR
, 'templates', 'container.tmpl')
58 photo_template
= file(tname
, 'rb').read()
60 class EncodeUnicode(Filter
):
61 def filter(self
, val
, **kw
):
62 """Encode Unicode strings, by default in UTF-8"""
64 if kw
.has_key('encoding'):
65 encoding
= kw
['encoding']
69 if type(val
) == type(u
''):
70 filtered
= val
.encode(encoding
)
77 CONTENT_TYPE
= 'x-container/tivo-photos'
79 class LockedLRUCache(LRUCache
):
80 def __init__(self
, num
):
81 LRUCache
.__init
__(self
, num
)
82 self
.lock
= threading
.RLock()
84 def acquire(self
, blocking
=1):
85 return self
.lock
.acquire(blocking
)
90 def __setitem__(self
, key
, obj
):
92 LRUCache
.__setitem
__(self
, key
, obj
)
95 media_data_cache
= LockedLRUCache(300) # info and thumbnails
96 recurse_cache
= LockedLRUCache(5) # recursive directory lists
97 dir_cache
= LockedLRUCache(10) # non-recursive lists
99 def send_file(self
, handler
, container
, name
):
102 handler
.send_response(200)
103 handler
.send_header('Content-Type', 'image/jpeg')
104 handler
.send_header('Content-Length', len(data
))
105 handler
.send_header('Connection', 'close')
106 handler
.end_headers()
107 handler
.wfile
.write(data
)
109 path
, query
= handler
.path
.split('?')
110 infile
= os
.path
.join(os
.path
.normpath(container
['path']),
111 unquote(path
)[len(name
) + 2:])
112 opts
= cgi
.parse_qs(query
)
114 if 'Format' in opts
and opts
['Format'][0] != 'image/jpeg':
115 handler
.send_error(415)
119 attrs
= self
.media_data_cache
[infile
]
125 rot
= attrs
['rotation']
129 if 'Rotation' in opts
:
130 rot
= (rot
- int(opts
['Rotation'][0])) % 360
132 attrs
['rotation'] = rot
137 width
= int(opts
.get('Width', ['0'])[0])
138 height
= int(opts
.get('Height', ['0'])[0])
140 # Return saved thumbnail?
141 if attrs
and 'thumb' in attrs
and 0 < width
< 100 and 0 < height
< 100:
142 send_jpeg(attrs
['thumb'])
147 pic
= Image
.open(unicode(infile
, 'utf-8'))
148 except Exception, msg
:
149 print 'Could not open', infile
, '--', msg
150 handler
.send_error(404)
155 pic
.draft('RGB', (width
, height
))
156 except Exception, msg
:
157 print 'Failed to set draft mode for', infile
, '--', msg
158 handler
.send_error(404)
161 # Read Exif data if possible
162 if 'exif' in pic
.info
:
163 exif
= pic
.info
['exif']
166 if attrs
and not 'odate' in attrs
:
167 date
= exif_date(exif
)
169 year
, month
, day
, hour
, minute
, second
= \
170 (int(x
) for x
in date
.groups())
172 odate
= time
.mktime((year
, month
, day
, hour
,
173 minute
, second
, -1, -1, -1))
174 attrs
['odate'] = '%#x' % int(odate
)
177 if attrs
and 'exifrot' in attrs
:
178 rot
= (rot
+ attrs
['exifrot']) % 360
181 orient
= exif_orient_i(exif
)
183 orient
= exif_orient_m(exif
)
186 exifrot
= ((ord(orient
.group(1)) - 1) * -90) % 360
187 rot
= (rot
+ exifrot
) % 360
189 attrs
['exifrot'] = exifrot
194 pic
= pic
.rotate(rot
)
195 except Exception, msg
:
196 print 'Rotate failed on', infile
, '--', msg
197 handler
.send_error(404)
204 except Exception, msg
:
205 print 'Palette conversion failed on', infile
, '--', msg
206 handler
.send_error(404)
210 oldw
, oldh
= pic
.size
212 if not width
: width
= oldw
213 if not height
: width
= oldh
215 # Correct aspect ratio
216 if 'PixelShape' in opts
:
217 pixw
, pixh
= opts
['PixelShape'][0].split(':')
222 ratio
= float(oldw
) / oldh
224 if float(width
) / height
< ratio
:
225 height
= int(width
/ ratio
)
227 width
= int(height
* ratio
)
230 pic
= pic
.resize((width
, height
), Image
.ANTIALIAS
)
231 except Exception, msg
:
232 print 'Resize failed on', infile
, '--', msg
233 handler
.send_error(404)
239 pic
.save(out
, 'JPEG')
240 encoded
= out
.getvalue()
242 except Exception, msg
:
243 print 'Encode failed on', infile
, '--', msg
244 handler
.send_error(404)
248 if attrs
and width
< 100 and height
< 100:
249 attrs
['thumb'] = encoded
254 def QueryContainer(self
, handler
, query
):
256 # Reject a malformed request -- these attributes should only
257 # appear in requests to send_file, but sometimes appear here
258 badattrs
= ('Rotation', 'Width', 'Height', 'PixelShape')
261 handler
.send_error(404)
264 subcname
= query
['Container'][0]
265 cname
= subcname
.split('/')[0]
266 local_base_path
= self
.get_local_base_path(handler
, query
)
267 if not handler
.server
.containers
.has_key(cname
) or \
268 not self
.get_local_path(handler
, query
):
269 handler
.send_error(404)
272 def ImageFileFilter(f
):
273 goodexts
= ('.jpg', '.gif', '.png', '.bmp', '.tif', '.xbm',
274 '.xpm', '.pgm', '.pbm', '.ppm', '.pcx', '.tga',
275 '.fpx', '.ico', '.pcd', '.jpeg', '.tiff')
276 return os
.path
.splitext(f
)[1].lower() in goodexts
279 if f
.name
in self
.media_data_cache
:
280 return self
.media_data_cache
[f
.name
]
283 item
['path'] = f
.name
284 item
['part_path'] = f
.name
.replace(local_base_path
, '', 1)
285 item
['name'] = os
.path
.split(f
.name
)[1]
286 item
['is_dir'] = f
.isdir
288 item
['cdate'] = '%#x' % f
.cdate
289 item
['mdate'] = '%#x' % f
.mdate
291 self
.media_data_cache
[f
.name
] = item
294 t
= Template(photo_template
, filter=EncodeUnicode
)
297 t
.files
, t
.total
, t
.start
= self
.get_files(handler
, query
,
299 t
.files
= map(media_data
, t
.files
)
304 handler
.send_response(200)
305 handler
.send_header('Content-Type', 'text/xml')
306 handler
.send_header('Content-Length', len(page
))
307 handler
.send_header('Connection', 'close')
308 handler
.end_headers()
309 handler
.wfile
.write(page
)
311 def get_files(self
, handler
, query
, filterFunction
):
314 def __init__(self
, name
, isdir
):
318 self
.cdate
= int(st
.st_ctime
)
319 self
.mdate
= int(st
.st_mtime
)
322 def __init__(self
, files
):
327 self
.lock
= threading
.RLock()
329 def acquire(self
, blocking
=1):
330 return self
.lock
.acquire(blocking
)
335 def build_recursive_list(path
, recurse
=True):
337 path
= unicode(path
, 'utf-8')
338 for f
in os
.listdir(path
):
339 f
= os
.path
.join(path
, f
)
340 isdir
= os
.path
.isdir(f
)
341 f
= f
.encode('utf-8')
342 if recurse
and isdir
:
343 files
.extend(build_recursive_list(f
))
345 if isdir
or filterFunction(f
):
346 files
.append(FileData(f
, isdir
))
351 return cmp(x
.name
, y
.name
)
353 def cdate_sort(x
, y
):
354 return cmp(x
.cdate
, y
.cdate
)
356 def mdate_sort(x
, y
):
357 return cmp(x
.mdate
, y
.mdate
)
360 if x
.isdir
== y
.isdir
:
361 return sortfunc(x
, y
)
363 return y
.isdir
- x
.isdir
365 subcname
= query
['Container'][0]
366 cname
= subcname
.split('/')[0]
367 path
= self
.get_local_path(handler
, query
)
370 recurse
= query
.get('Recurse', ['No'])[0] == 'Yes'
372 if recurse
and path
in self
.recurse_cache
:
373 filelist
= self
.recurse_cache
[path
]
374 elif not recurse
and path
in self
.dir_cache
:
375 filelist
= self
.dir_cache
[path
]
377 filelist
= SortList(build_recursive_list(path
, recurse
))
380 self
.recurse_cache
[path
] = filelist
382 self
.dir_cache
[path
] = filelist
389 sortby
= query
.get('SortOrder', ['Normal'])[0]
390 if 'Random' in sortby
:
391 if 'RandomSeed' in query
:
392 seed
= query
['RandomSeed'][0]
394 if 'RandomStart' in query
:
395 start
= query
['RandomStart'][0]
398 if filelist
.unsorted
or filelist
.sortby
!= sortby
:
399 if 'Random' in sortby
:
400 self
.random_lock
.acquire()
403 random
.shuffle(filelist
.files
)
404 self
.random_lock
.release()
406 local_base_path
= self
.get_local_base_path(handler
, query
)
407 start
= unquote(start
)
408 start
= start
.replace(os
.path
.sep
+ cname
,
410 filenames
= [x
.name
for x
in filelist
.files
]
412 index
= filenames
.index(start
)
413 i
= filelist
.files
.pop(index
)
414 filelist
.files
.insert(0, i
)
416 print 'Start not found:', start
418 if 'CaptureDate' in sortby
:
419 sortfunc
= cdate_sort
420 elif 'LastChangeDate' in sortby
:
421 sortfunc
= mdate_sort
426 filelist
.files
.sort(dir_sort
)
428 filelist
.files
.sort(sortfunc
)
430 filelist
.sortby
= sortby
431 filelist
.unsorted
= False
433 files
= filelist
.files
[:]
435 # Filter it -- this section needs work
436 if 'Filter' in query
:
437 usedir
= 'folder' in query
['Filter'][0]
438 useimg
= 'image' in query
['Filter'][0]
440 files
= [x
for x
in files
if not x
.isdir
]
441 elif usedir
and not useimg
:
442 files
= [x
for x
in files
if x
.isdir
]
444 files
, total
, start
= self
.item_count(handler
, query
, cname
, files
,
446 filelist
.last_start
= start
448 return files
, total
, start