Merge commit 'krkeegan/master'
[pyTivo.git] / plugins / photo / photo.py
blobe69c320fae6000144fba1ac7278bf64cbb57a8e9
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
28 try:
29 import Image
30 except ImportError:
31 print 'Photo Plugin Error: The Python Imaging Library is not installed'
32 from cStringIO import StringIO
33 from Cheetah.Template import Template
34 from Cheetah.Filters import Filter
35 from plugin import Plugin, quote, unquote
36 from xml.sax.saxutils import escape
37 from lrucache import LRUCache
39 SCRIPTDIR = os.path.dirname(__file__)
41 CLASS_NAME = 'Photo'
43 # Match Exif date -- YYYY:MM:DD HH:MM:SS
44 exif_date = re.compile(r'(\d{4}):(\d\d):(\d\d) (\d\d):(\d\d):(\d\d)').search
46 # Match Exif orientation, Intel and Motorola versions
47 exif_orient_i = \
48 re.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
49 exif_orient_m = \
50 re.compile('\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00').search
52 # Preload the template
53 tname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
54 photo_template = file(tname, 'rb').read()
56 class EncodeUnicode(Filter):
57 def filter(self, val, **kw):
58 """Encode Unicode strings, by default in UTF-8"""
60 if kw.has_key('encoding'):
61 encoding = kw['encoding']
62 else:
63 encoding='utf8'
65 if type(val) == type(u''):
66 filtered = val.encode(encoding)
67 else:
68 filtered = str(val)
69 return filtered
71 class Photo(Plugin):
73 CONTENT_TYPE = 'x-container/tivo-photos'
75 class LockedLRUCache(LRUCache):
76 def __init__(self, num):
77 LRUCache.__init__(self, num)
78 self.lock = threading.RLock()
80 def acquire(self, blocking=1):
81 return self.lock.acquire(blocking)
83 def release(self):
84 self.lock.release()
86 def __setitem__(self, key, obj):
87 self.acquire()
88 LRUCache.__setitem__(self, key, obj)
89 self.release()
91 media_data_cache = LockedLRUCache(300) # info and thumbnails
92 recurse_cache = LockedLRUCache(5) # recursive directory lists
93 dir_cache = LockedLRUCache(10) # non-recursive lists
95 def send_file(self, handler, container, name):
97 def send_jpeg(data):
98 handler.send_response(200)
99 handler.send_header('Content-Type', 'image/jpeg')
100 handler.send_header('Content-Length', len(data))
101 handler.send_header('Connection', 'close')
102 handler.end_headers()
103 handler.wfile.write(data)
105 path, query = handler.path.split('?')
106 infile = os.path.join(os.path.normpath(container['path']),
107 unquote(path)[len(name) + 2:])
108 opts = cgi.parse_qs(query)
110 if 'Format' in opts and opts['Format'][0] != 'image/jpeg':
111 handler.send_error(415)
112 return
114 try:
115 attrs = self.media_data_cache[infile]
116 except:
117 attrs = None
119 # Set rotation
120 if attrs:
121 rot = attrs['rotation']
122 else:
123 rot = 0
125 if 'Rotation' in opts:
126 rot = (rot - int(opts['Rotation'][0])) % 360
127 if attrs:
128 attrs['rotation'] = rot
129 if 'thumb' in attrs:
130 del attrs['thumb']
132 # Requested size
133 width = int(opts.get('Width', ['0'])[0])
134 height = int(opts.get('Height', ['0'])[0])
136 # Return saved thumbnail?
137 if attrs and 'thumb' in attrs and 0 < width < 100 and 0 < height < 100:
138 send_jpeg(attrs['thumb'])
139 return
141 # Load
142 try:
143 pic = Image.open(unicode(infile, 'utf-8'))
144 except Exception, msg:
145 print 'Could not open', infile, '--', msg
146 handler.send_error(404)
147 return
149 # Set draft mode
150 try:
151 pic.draft('RGB', (width, height))
152 except Exception, msg:
153 print 'Failed to set draft mode for', infile, '--', 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 = \
166 (int(x) 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 = ((ord(orient.group(1)) - 1) * -90) % 360
183 rot = (rot + exifrot) % 360
184 if attrs:
185 attrs['exifrot'] = exifrot
187 # Rotate
188 try:
189 if rot:
190 pic = pic.rotate(rot)
191 except Exception, msg:
192 print 'Rotate failed on', infile, '--', msg
193 handler.send_error(404)
194 return
196 # De-palletize
197 try:
198 if pic.mode == 'P':
199 pic = pic.convert()
200 except Exception, msg:
201 print 'Palette conversion failed on', infile, '--', msg
202 handler.send_error(404)
203 return
205 # Old size
206 oldw, oldh = pic.size
208 if not width: width = oldw
209 if not height: height = oldh
211 # Correct aspect ratio
212 if 'PixelShape' in opts:
213 pixw, pixh = opts['PixelShape'][0].split(':')
214 oldw *= int(pixh)
215 oldh *= int(pixw)
217 # Resize
218 ratio = float(oldw) / oldh
220 if float(width) / height < ratio:
221 height = int(width / ratio)
222 else:
223 width = int(height * ratio)
225 try:
226 pic = pic.resize((width, height), Image.ANTIALIAS)
227 except Exception, msg:
228 print 'Resize failed on', infile, '--', msg
229 handler.send_error(404)
230 return
232 # Re-encode
233 try:
234 out = StringIO()
235 pic.save(out, 'JPEG')
236 encoded = out.getvalue()
237 out.close()
238 except Exception, msg:
239 print 'Encode failed on', infile, '--', msg
240 handler.send_error(404)
241 return
243 # Save thumbnails
244 if attrs and width < 100 and height < 100:
245 attrs['thumb'] = encoded
247 # Send it
248 send_jpeg(encoded)
250 def QueryContainer(self, handler, query):
252 # Reject a malformed request -- these attributes should only
253 # appear in requests to send_file, but sometimes appear here
254 badattrs = ('Rotation', 'Width', 'Height', 'PixelShape')
255 for i in badattrs:
256 if i in query:
257 handler.send_error(404)
258 return
260 subcname = query['Container'][0]
261 cname = subcname.split('/')[0]
262 local_base_path = self.get_local_base_path(handler, query)
263 if not handler.server.containers.has_key(cname) or \
264 not self.get_local_path(handler, query):
265 handler.send_error(404)
266 return
268 def ImageFileFilter(f):
269 goodexts = ('.jpg', '.gif', '.png', '.bmp', '.tif', '.xbm',
270 '.xpm', '.pgm', '.pbm', '.ppm', '.pcx', '.tga',
271 '.fpx', '.ico', '.pcd', '.jpeg', '.tiff')
272 return os.path.splitext(f)[1].lower() in goodexts
274 def media_data(f):
275 if f.name in self.media_data_cache:
276 return self.media_data_cache[f.name]
278 item = {}
279 item['path'] = f.name
280 item['part_path'] = f.name.replace(local_base_path, '', 1)
281 item['name'] = os.path.split(f.name)[1]
282 item['is_dir'] = f.isdir
283 item['rotation'] = 0
284 item['cdate'] = '%#x' % f.cdate
285 item['mdate'] = '%#x' % f.mdate
287 self.media_data_cache[f.name] = item
288 return item
290 t = Template(photo_template, filter=EncodeUnicode)
291 t.name = subcname
292 t.container = cname
293 t.files, t.total, t.start = self.get_files(handler, query,
294 ImageFileFilter)
295 t.files = map(media_data, t.files)
296 t.quote = quote
297 t.escape = escape
298 page = str(t)
300 handler.send_response(200)
301 handler.send_header('Content-Type', 'text/xml')
302 handler.send_header('Content-Length', len(page))
303 handler.send_header('Connection', 'close')
304 handler.end_headers()
305 handler.wfile.write(page)
307 def get_files(self, handler, query, filterFunction):
309 class FileData:
310 def __init__(self, name, isdir):
311 self.name = name
312 self.isdir = isdir
313 st = os.stat(name)
314 self.cdate = int(st.st_ctime)
315 self.mdate = int(st.st_mtime)
317 class SortList:
318 def __init__(self, files):
319 self.files = files
320 self.unsorted = True
321 self.sortby = None
322 self.last_start = 0
323 self.lock = threading.RLock()
325 def acquire(self, blocking=1):
326 return self.lock.acquire(blocking)
328 def release(self):
329 self.lock.release()
331 def build_recursive_list(path, recurse=True):
332 files = []
333 path = unicode(path, 'utf-8')
334 try:
335 for f in os.listdir(path):
336 f = os.path.join(path, f)
337 isdir = os.path.isdir(f)
338 f = f.encode('utf-8')
339 if recurse and isdir:
340 files.extend(build_recursive_list(f))
341 else:
342 if isdir or filterFunction(f):
343 files.append(FileData(f, isdir))
344 except:
345 pass
347 return files
349 def name_sort(x, y):
350 return cmp(x.name, y.name)
352 def cdate_sort(x, y):
353 return cmp(x.cdate, y.cdate)
355 def mdate_sort(x, y):
356 return cmp(x.mdate, y.mdate)
358 def dir_sort(x, y):
359 if x.isdir == y.isdir:
360 return sortfunc(x, y)
361 else:
362 return y.isdir - x.isdir
364 subcname = query['Container'][0]
365 cname = subcname.split('/')[0]
366 path = self.get_local_path(handler, query)
368 # Build the list
369 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
371 if recurse and path in self.recurse_cache:
372 filelist = self.recurse_cache[path]
373 elif not recurse and path in self.dir_cache:
374 filelist = self.dir_cache[path]
375 else:
376 filelist = SortList(build_recursive_list(path, recurse))
378 if recurse:
379 self.recurse_cache[path] = filelist
380 else:
381 self.dir_cache[path] = filelist
383 filelist.acquire()
385 # Sort it
386 seed = ''
387 start = ''
388 sortby = query.get('SortOrder', ['Normal'])[0]
389 if 'Random' in sortby:
390 if 'RandomSeed' in query:
391 seed = query['RandomSeed'][0]
392 sortby += seed
393 if 'RandomStart' in query:
394 start = query['RandomStart'][0]
395 sortby += start
397 if filelist.unsorted or filelist.sortby != sortby:
398 if 'Random' in sortby:
399 self.random_lock.acquire()
400 if seed:
401 random.seed(seed)
402 random.shuffle(filelist.files)
403 self.random_lock.release()
404 if start:
405 local_base_path = self.get_local_base_path(handler, query)
406 start = unquote(start)
407 start = start.replace(os.path.sep + cname,
408 local_base_path, 1)
409 filenames = [x.name for x in filelist.files]
410 try:
411 index = filenames.index(start)
412 i = filelist.files.pop(index)
413 filelist.files.insert(0, i)
414 except ValueError:
415 print 'Start not found:', start
416 else:
417 if 'CaptureDate' in sortby:
418 sortfunc = cdate_sort
419 elif 'LastChangeDate' in sortby:
420 sortfunc = mdate_sort
421 else:
422 sortfunc = name_sort
424 if 'Type' in sortby:
425 filelist.files.sort(dir_sort)
426 else:
427 filelist.files.sort(sortfunc)
429 filelist.sortby = sortby
430 filelist.unsorted = False
432 files = filelist.files[:]
434 # Filter it -- this section needs work
435 if 'Filter' in query:
436 usedir = 'folder' in query['Filter'][0]
437 useimg = 'image' in query['Filter'][0]
438 if not usedir:
439 files = [x for x in files if not x.isdir]
440 elif usedir and not useimg:
441 files = [x for x in files if x.isdir]
443 files, total, start = self.item_count(handler, query, cname, files,
444 filelist.last_start)
445 filelist.last_start = start
446 filelist.release()
447 return files, total, start