Re-ordered the import lines to fit with PEP 8.
[pyTivo/wgw.git] / plugins / photo / photo.py
blob6fe4beaeedef88e7ce171bda233f48303364dee4
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 cgi
28 import os
29 import re
30 import random
31 import threading
32 import time
33 import urllib
34 from cStringIO import StringIO
35 from xml.sax.saxutils import escape
37 try:
38 import Image
39 except ImportError:
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__)
49 CLASS_NAME = 'Photo'
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
55 exif_orient_i = \
56 re.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
57 exif_orient_m = \
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)
72 else:
73 filtered = str(val)
74 return filtered
76 class Photo(Plugin):
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)
88 def release(self):
89 self.lock.release()
91 def __setitem__(self, key, obj):
92 self.acquire()
93 LRUCache.__setitem__(self, key, obj)
94 self.release()
96 media_data_cache = LockedLRUCache(300) # info and thumbnails
97 recurse_cache = LockedLRUCache(5) # recursive directory lists
98 dir_cache = LockedLRUCache(10) # non-recursive lists
100 def send_file(self, handler, container, name):
102 def send_jpeg(data):
103 handler.send_response(200)
104 handler.send_header('Content-Type', 'image/jpeg')
105 handler.send_header('Content-Length', len(data))
106 handler.send_header('Connection', 'close')
107 handler.end_headers()
108 handler.wfile.write(data)
110 path, query = handler.path.split('?')
111 infile = os.path.join(os.path.normpath(container['path']),
112 unquote(path)[len(name) + 2:])
113 opts = cgi.parse_qs(query)
115 if 'Format' in opts and opts['Format'][0] != 'image/jpeg':
116 handler.send_error(415)
117 return
119 try:
120 attrs = self.media_data_cache[infile]
121 except:
122 attrs = None
124 # Set rotation
125 if attrs:
126 rot = attrs['rotation']
127 else:
128 rot = 0
130 if 'Rotation' in opts:
131 rot = (rot - int(opts['Rotation'][0])) % 360
132 if attrs:
133 attrs['rotation'] = rot
134 if 'thumb' in attrs:
135 del attrs['thumb']
137 # Requested size
138 width = int(opts.get('Width', ['0'])[0])
139 height = int(opts.get('Height', ['0'])[0])
141 # Return saved thumbnail?
142 if attrs and 'thumb' in attrs and 0 < width < 100 and 0 < height < 100:
143 send_jpeg(attrs['thumb'])
144 return
146 # Load
147 try:
148 pic = Image.open(unicode(infile, 'utf-8'))
149 except Exception, msg:
150 print 'Could not open', infile, '--', msg
151 handler.send_error(404)
152 return
154 # Set draft mode
155 try:
156 pic.draft('RGB', (width, height))
157 except Exception, msg:
158 print 'Failed to set draft mode for', infile, '--', msg
159 handler.send_error(404)
160 return
162 # Read Exif data if possible
163 if 'exif' in pic.info:
164 exif = pic.info['exif']
166 # Capture date
167 if attrs and not 'odate' in attrs:
168 date = exif_date(exif)
169 if date:
170 year, month, day, hour, minute, second = \
171 (int(x) for x in date.groups())
172 if year:
173 odate = time.mktime((year, month, day, hour,
174 minute, second, -1, -1, -1))
175 attrs['odate'] = '%#x' % int(odate)
177 # Orientation
178 if attrs and 'exifrot' in attrs:
179 rot = (rot + attrs['exifrot']) % 360
180 else:
181 if exif[6] == 'I':
182 orient = exif_orient_i(exif)
183 else:
184 orient = exif_orient_m(exif)
186 if orient:
187 exifrot = ((ord(orient.group(1)) - 1) * -90) % 360
188 rot = (rot + exifrot) % 360
189 if attrs:
190 attrs['exifrot'] = exifrot
192 # Rotate
193 try:
194 if rot:
195 pic = pic.rotate(rot)
196 except Exception, msg:
197 print 'Rotate failed on', infile, '--', msg
198 handler.send_error(404)
199 return
201 # De-palletize
202 try:
203 if pic.mode == 'P':
204 pic = pic.convert()
205 except Exception, msg:
206 print 'Palette conversion failed on', infile, '--', msg
207 handler.send_error(404)
208 return
210 # Old size
211 oldw, oldh = pic.size
213 if not width: width = oldw
214 if not height: height = oldh
216 # Correct aspect ratio
217 if 'PixelShape' in opts:
218 pixw, pixh = opts['PixelShape'][0].split(':')
219 oldw *= int(pixh)
220 oldh *= int(pixw)
222 # Resize
223 ratio = float(oldw) / oldh
225 if float(width) / height < ratio:
226 height = int(width / ratio)
227 else:
228 width = int(height * ratio)
230 try:
231 pic = pic.resize((width, height), Image.ANTIALIAS)
232 except Exception, msg:
233 print 'Resize failed on', infile, '--', msg
234 handler.send_error(404)
235 return
237 # Re-encode
238 try:
239 out = StringIO()
240 pic.save(out, 'JPEG')
241 encoded = out.getvalue()
242 out.close()
243 except Exception, msg:
244 print 'Encode failed on', infile, '--', msg
245 handler.send_error(404)
246 return
248 # Save thumbnails
249 if attrs and width < 100 and height < 100:
250 attrs['thumb'] = encoded
252 # Send it
253 send_jpeg(encoded)
255 def QueryContainer(self, handler, query):
257 # Reject a malformed request -- these attributes should only
258 # appear in requests to send_file, but sometimes appear here
259 badattrs = ('Rotation', 'Width', 'Height', 'PixelShape')
260 for i in badattrs:
261 if i in query:
262 handler.send_error(404)
263 return
265 subcname = query['Container'][0]
266 cname = subcname.split('/')[0]
267 local_base_path = self.get_local_base_path(handler, query)
268 if not handler.server.containers.has_key(cname) or \
269 not self.get_local_path(handler, query):
270 handler.send_error(404)
271 return
273 def ImageFileFilter(f):
274 goodexts = ('.jpg', '.gif', '.png', '.bmp', '.tif', '.xbm',
275 '.xpm', '.pgm', '.pbm', '.ppm', '.pcx', '.tga',
276 '.fpx', '.ico', '.pcd', '.jpeg', '.tiff')
277 return os.path.splitext(f)[1].lower() in goodexts
279 def media_data(f):
280 if f.name in self.media_data_cache:
281 return self.media_data_cache[f.name]
283 item = {}
284 item['path'] = f.name
285 item['part_path'] = f.name.replace(local_base_path, '', 1)
286 item['name'] = os.path.split(f.name)[1]
287 item['is_dir'] = f.isdir
288 item['rotation'] = 0
289 item['cdate'] = '%#x' % f.cdate
290 item['mdate'] = '%#x' % f.mdate
292 self.media_data_cache[f.name] = item
293 return item
295 t = Template(PHOTO_TEMPLATE, filter=EncodeUnicode)
296 t.name = subcname
297 t.container = cname
298 t.files, t.total, t.start = self.get_files(handler, query,
299 ImageFileFilter)
300 t.files = map(media_data, t.files)
301 t.quote = quote
302 t.escape = escape
303 page = str(t)
305 handler.send_response(200)
306 handler.send_header('Content-Type', 'text/xml')
307 handler.send_header('Content-Length', len(page))
308 handler.send_header('Connection', 'close')
309 handler.end_headers()
310 handler.wfile.write(page)
312 def get_files(self, handler, query, filterFunction):
314 class FileData:
315 def __init__(self, name, isdir):
316 self.name = name
317 self.isdir = isdir
318 st = os.stat(name)
319 self.cdate = int(st.st_ctime)
320 self.mdate = int(st.st_mtime)
322 class SortList:
323 def __init__(self, files):
324 self.files = files
325 self.unsorted = True
326 self.sortby = None
327 self.last_start = 0
328 self.lock = threading.RLock()
330 def acquire(self, blocking=1):
331 return self.lock.acquire(blocking)
333 def release(self):
334 self.lock.release()
336 def build_recursive_list(path, recurse=True):
337 files = []
338 path = unicode(path, 'utf-8')
339 try:
340 for f in os.listdir(path):
341 if f.startswith('.'):
342 continue
343 f = os.path.join(path, f)
344 isdir = os.path.isdir(f)
345 f = f.encode('utf-8')
346 if recurse and isdir:
347 files.extend(build_recursive_list(f))
348 else:
349 if isdir or filterFunction(f):
350 files.append(FileData(f, isdir))
351 except:
352 pass
354 return files
356 def name_sort(x, y):
357 return cmp(x.name, y.name)
359 def cdate_sort(x, y):
360 return cmp(x.cdate, y.cdate)
362 def mdate_sort(x, y):
363 return cmp(x.mdate, y.mdate)
365 def dir_sort(x, y):
366 if x.isdir == y.isdir:
367 return sortfunc(x, y)
368 else:
369 return y.isdir - x.isdir
371 subcname = query['Container'][0]
372 cname = subcname.split('/')[0]
373 path = self.get_local_path(handler, query)
375 # Build the list
376 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
378 if recurse and path in self.recurse_cache:
379 filelist = self.recurse_cache[path]
380 elif not recurse and path in self.dir_cache:
381 filelist = self.dir_cache[path]
382 else:
383 filelist = SortList(build_recursive_list(path, recurse))
385 if recurse:
386 self.recurse_cache[path] = filelist
387 else:
388 self.dir_cache[path] = filelist
390 filelist.acquire()
392 # Sort it
393 seed = ''
394 start = ''
395 sortby = query.get('SortOrder', ['Normal'])[0]
396 if 'Random' in sortby:
397 if 'RandomSeed' in query:
398 seed = query['RandomSeed'][0]
399 sortby += seed
400 if 'RandomStart' in query:
401 start = query['RandomStart'][0]
402 sortby += start
404 if filelist.unsorted or filelist.sortby != sortby:
405 if 'Random' in sortby:
406 self.random_lock.acquire()
407 if seed:
408 random.seed(seed)
409 random.shuffle(filelist.files)
410 self.random_lock.release()
411 if start:
412 local_base_path = self.get_local_base_path(handler, query)
413 start = unquote(start)
414 start = start.replace(os.path.sep + cname,
415 local_base_path, 1)
416 filenames = [x.name for x in filelist.files]
417 try:
418 index = filenames.index(start)
419 i = filelist.files.pop(index)
420 filelist.files.insert(0, i)
421 except ValueError:
422 print 'Start not found:', start
423 else:
424 if 'CaptureDate' in sortby:
425 sortfunc = cdate_sort
426 elif 'LastChangeDate' in sortby:
427 sortfunc = mdate_sort
428 else:
429 sortfunc = name_sort
431 if 'Type' in sortby:
432 filelist.files.sort(dir_sort)
433 else:
434 filelist.files.sort(sortfunc)
436 filelist.sortby = sortby
437 filelist.unsorted = False
439 files = filelist.files[:]
441 # Filter it -- this section needs work
442 if 'Filter' in query:
443 usedir = 'folder' in query['Filter'][0]
444 useimg = 'image' in query['Filter'][0]
445 if not usedir:
446 files = [x for x in files if not x.isdir]
447 elif usedir and not useimg:
448 files = [x for x in files if x.isdir]
450 files, total, start = self.item_count(handler, query, cname, files,
451 filelist.last_start)
452 filelist.last_start = start
453 filelist.release()
454 return files, total, start