Oops.
[pyTivo.git] / plugins / photo / photo.py
blobc893cf0f686de08c08a85e1677eb48a4cce9b25f
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 for f in os.listdir(path):
335 f = os.path.join(path, f)
336 isdir = os.path.isdir(f)
337 f = f.encode('utf-8')
338 if recurse and isdir:
339 files.extend(build_recursive_list(f))
340 else:
341 if isdir or filterFunction(f):
342 files.append(FileData(f, isdir))
344 return files
346 def name_sort(x, y):
347 return cmp(x.name, y.name)
349 def cdate_sort(x, y):
350 return cmp(x.cdate, y.cdate)
352 def mdate_sort(x, y):
353 return cmp(x.mdate, y.mdate)
355 def dir_sort(x, y):
356 if x.isdir == y.isdir:
357 return sortfunc(x, y)
358 else:
359 return y.isdir - x.isdir
361 subcname = query['Container'][0]
362 cname = subcname.split('/')[0]
363 path = self.get_local_path(handler, query)
365 # Build the list
366 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
368 if recurse and path in self.recurse_cache:
369 filelist = self.recurse_cache[path]
370 elif not recurse and path in self.dir_cache:
371 filelist = self.dir_cache[path]
372 else:
373 filelist = SortList(build_recursive_list(path, recurse))
375 if recurse:
376 self.recurse_cache[path] = filelist
377 else:
378 self.dir_cache[path] = filelist
380 filelist.acquire()
382 # Sort it
383 seed = ''
384 start = ''
385 sortby = query.get('SortOrder', ['Normal'])[0]
386 if 'Random' in sortby:
387 if 'RandomSeed' in query:
388 seed = query['RandomSeed'][0]
389 sortby += seed
390 if 'RandomStart' in query:
391 start = query['RandomStart'][0]
392 sortby += start
394 if filelist.unsorted or filelist.sortby != sortby:
395 if 'Random' in sortby:
396 self.random_lock.acquire()
397 if seed:
398 random.seed(seed)
399 random.shuffle(filelist.files)
400 self.random_lock.release()
401 if start:
402 local_base_path = self.get_local_base_path(handler, query)
403 start = unquote(start)
404 start = start.replace(os.path.sep + cname,
405 local_base_path, 1)
406 filenames = [x.name for x in filelist.files]
407 try:
408 index = filenames.index(start)
409 i = filelist.files.pop(index)
410 filelist.files.insert(0, i)
411 except ValueError:
412 print 'Start not found:', start
413 else:
414 if 'CaptureDate' in sortby:
415 sortfunc = cdate_sort
416 elif 'LastChangeDate' in sortby:
417 sortfunc = mdate_sort
418 else:
419 sortfunc = name_sort
421 if 'Type' in sortby:
422 filelist.files.sort(dir_sort)
423 else:
424 filelist.files.sort(sortfunc)
426 filelist.sortby = sortby
427 filelist.unsorted = False
429 files = filelist.files[:]
431 # Filter it -- this section needs work
432 if 'Filter' in query:
433 usedir = 'folder' in query['Filter'][0]
434 useimg = 'image' in query['Filter'][0]
435 if not usedir:
436 files = [x for x in files if not x.isdir]
437 elif usedir and not useimg:
438 files = [x for x in files if x.isdir]
440 files, total, start = self.item_count(handler, query, cname, files,
441 filelist.last_start)
442 filelist.last_start = start
443 filelist.release()
444 return files, total, start