Neater.
[pyTivo.git] / plugins / photo / photo.py
blobfbc793f0d13e1d4772d60fb944c53fdc18983c8c
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 import Image
29 from cStringIO import StringIO
30 from Cheetah.Template import Template
31 from Cheetah.Filters import Filter
32 from plugin import Plugin, quote, unquote
33 from xml.sax.saxutils import escape
34 from lrucache import LRUCache
36 SCRIPTDIR = os.path.dirname(__file__)
38 CLASS_NAME = 'Photo'
40 # Match Exif date -- YYYY:MM:DD HH:MM:SS
41 exif_date = re.compile(r'(\d{4}):(\d\d):(\d\d) (\d\d):(\d\d):(\d\d)').search
43 # Match Exif orientation, Intel and Motorola versions
44 exif_orient_i = \
45 re.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
46 exif_orient_m = \
47 re.compile('\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00').search
49 # Preload the template
50 tname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
51 photo_template = file(tname, 'rb').read()
53 class EncodeUnicode(Filter):
54 def filter(self, val, **kw):
55 """Encode Unicode strings, by default in UTF-8"""
57 if kw.has_key('encoding'):
58 encoding = kw['encoding']
59 else:
60 encoding='utf8'
62 if type(val) == type(u''):
63 filtered = val.encode(encoding)
64 else:
65 filtered = str(val)
66 return filtered
68 class Photo(Plugin):
70 CONTENT_TYPE = 'x-container/tivo-photos'
72 class LockedLRUCache(LRUCache):
73 def __init__(self, num):
74 LRUCache.__init__(self, num)
75 self.lock = threading.RLock()
77 def acquire(self, blocking=1):
78 return self.lock.acquire(blocking)
80 def release(self):
81 self.lock.release()
83 def __setitem__(self, key, obj):
84 self.acquire()
85 LRUCache.__setitem__(self, key, obj)
86 self.release()
88 media_data_cache = LockedLRUCache(300) # info and thumbnails
89 recurse_cache = LockedLRUCache(5) # recursive directory lists
90 dir_cache = LockedLRUCache(10) # non-recursive lists
92 def send_file(self, handler, container, name):
94 def send_jpeg(data):
95 handler.send_response(200)
96 handler.send_header('Content-Type', 'image/jpeg')
97 handler.send_header('Content-Length', len(data))
98 handler.send_header('Connection', 'close')
99 handler.end_headers()
100 handler.wfile.write(data)
102 path, query = handler.path.split('?')
103 infile = os.path.join(os.path.normpath(container['path']),
104 unquote(path)[len(name) + 2:])
105 opts = cgi.parse_qs(query)
107 if 'Format' in opts and opts['Format'][0] != 'image/jpeg':
108 handler.send_error(415)
109 return
111 try:
112 attrs = self.media_data_cache[infile]
113 except:
114 attrs = None
116 # Set rotation
117 if attrs:
118 rot = attrs['rotation']
119 else:
120 rot = 0
122 if 'Rotation' in opts:
123 rot = (rot - int(opts['Rotation'][0])) % 360
124 if attrs:
125 attrs['rotation'] = rot
126 if 'thumb' in attrs:
127 del attrs['thumb']
129 # Requested size
130 width = int(opts.get('Width', ['0'])[0])
131 height = int(opts.get('Height', ['0'])[0])
133 # Return saved thumbnail?
134 if attrs and 'thumb' in attrs and 0 < width < 100 and 0 < height < 100:
135 send_jpeg(attrs['thumb'])
136 return
138 # Load
139 try:
140 pic = Image.open(unicode(infile, 'utf-8'))
141 except Exception, msg:
142 print 'Could not open', infile, '--', msg
143 handler.send_error(404)
144 return
146 # Set draft mode
147 try:
148 pic.draft('RGB', (width, height))
149 except Exception, msg:
150 print 'Failed to set draft mode for', infile, '--', msg
151 handler.send_error(404)
152 return
154 # Read Exif data if possible
155 if 'exif' in pic.info:
156 exif = pic.info['exif']
158 # Capture date
159 if attrs and not 'odate' in attrs:
160 date = exif_date(exif)
161 if date:
162 year, month, day, hour, minute, second = \
163 (int(x) for x in date.groups())
164 if year:
165 odate = time.mktime((year, month, day, hour,
166 minute, second, -1, -1, -1))
167 attrs['odate'] = '%#x' % int(odate)
169 # Orientation
170 if attrs and 'exifrot' in attrs:
171 rot = (rot + attrs['exifrot']) % 360
172 else:
173 if exif[6] == 'I':
174 orient = exif_orient_i(exif)
175 else:
176 orient = exif_orient_m(exif)
178 if orient:
179 exifrot = ((ord(orient.group(1)) - 1) * -90) % 360
180 rot = (rot + exifrot) % 360
181 if attrs:
182 attrs['exifrot'] = exifrot
184 # Rotate
185 try:
186 if rot:
187 pic = pic.rotate(rot)
188 except Exception, msg:
189 print 'Rotate failed on', infile, '--', msg
190 handler.send_error(404)
191 return
193 # De-palletize
194 try:
195 if pic.mode == 'P':
196 pic = pic.convert()
197 except Exception, msg:
198 print 'Palette conversion failed on', infile, '--', msg
199 handler.send_error(404)
200 return
202 # Old size
203 oldw, oldh = pic.size
205 if not width: width = oldw
206 if not height: width = oldh
208 # Correct aspect ratio
209 if 'PixelShape' in opts:
210 pixw, pixh = opts['PixelShape'][0].split(':')
211 oldw *= int(pixh)
212 oldh *= int(pixw)
214 # Resize
215 ratio = float(oldw) / oldh
217 if float(width) / height < ratio:
218 height = int(width / ratio)
219 else:
220 width = int(height * ratio)
222 try:
223 pic = pic.resize((width, height), Image.ANTIALIAS)
224 except Exception, msg:
225 print 'Resize failed on', infile, '--', msg
226 handler.send_error(404)
227 return
229 # Re-encode
230 try:
231 out = StringIO()
232 pic.save(out, 'JPEG')
233 encoded = out.getvalue()
234 out.close()
235 except Exception, msg:
236 print 'Encode failed on', infile, '--', msg
237 handler.send_error(404)
238 return
240 # Save thumbnails
241 if attrs and width < 100 and height < 100:
242 attrs['thumb'] = encoded
244 # Send it
245 send_jpeg(encoded)
247 def QueryContainer(self, handler, query):
249 # Reject a malformed request -- these attributes should only
250 # appear in requests to send_file, but sometimes appear here
251 badattrs = ('Rotation', 'Width', 'Height', 'PixelShape')
252 for i in badattrs:
253 if i in query:
254 handler.send_error(404)
255 return
257 subcname = query['Container'][0]
258 cname = subcname.split('/')[0]
259 local_base_path = self.get_local_base_path(handler, query)
260 if not handler.server.containers.has_key(cname) or \
261 not self.get_local_path(handler, query):
262 handler.send_error(404)
263 return
265 def ImageFileFilter(f):
266 goodexts = ('.jpg', '.gif', '.png', '.bmp', '.tif', '.xbm',
267 '.xpm', '.pgm', '.pbm', '.ppm', '.pcx', '.tga',
268 '.fpx', '.ico', '.pcd', '.jpeg', '.tiff')
269 return os.path.splitext(f)[1].lower() in goodexts
271 def media_data(f):
272 if f.name in self.media_data_cache:
273 return self.media_data_cache[f.name]
275 item = {}
276 item['path'] = f.name
277 item['part_path'] = f.name.replace(local_base_path, '', 1)
278 item['name'] = os.path.split(f.name)[1]
279 item['is_dir'] = f.isdir
280 item['rotation'] = 0
281 item['cdate'] = '%#x' % f.cdate
282 item['mdate'] = '%#x' % f.mdate
284 self.media_data_cache[f.name] = item
285 return item
287 t = Template(photo_template, filter=EncodeUnicode)
288 t.name = subcname
289 t.container = cname
290 t.files, t.total, t.start = self.get_files(handler, query,
291 ImageFileFilter)
292 t.files = map(media_data, t.files)
293 t.quote = quote
294 t.escape = escape
295 page = str(t)
297 handler.send_response(200)
298 handler.send_header('Content-Type', 'text/xml')
299 handler.send_header('Content-Length', len(page))
300 handler.send_header('Connection', 'close')
301 handler.end_headers()
302 handler.wfile.write(page)
304 def get_files(self, handler, query, filterFunction):
306 class FileData:
307 def __init__(self, name, isdir):
308 self.name = name
309 self.isdir = isdir
310 st = os.stat(name)
311 self.cdate = int(st.st_ctime)
312 self.mdate = int(st.st_mtime)
314 class SortList:
315 def __init__(self, files):
316 self.files = files
317 self.unsorted = True
318 self.sortby = None
319 self.last_start = 0
320 self.lock = threading.RLock()
322 def acquire(self, blocking=1):
323 return self.lock.acquire(blocking)
325 def release(self):
326 self.lock.release()
328 def build_recursive_list(path, recurse=True):
329 files = []
330 path = unicode(path, 'utf-8')
331 for f in os.listdir(path):
332 f = os.path.join(path, f)
333 isdir = os.path.isdir(f)
334 f = f.encode('utf-8')
335 if recurse and isdir:
336 files.extend(build_recursive_list(f))
337 else:
338 if isdir or filterFunction(f):
339 files.append(FileData(f, isdir))
341 return files
343 def name_sort(x, y):
344 return cmp(x.name, y.name)
346 def cdate_sort(x, y):
347 return cmp(x.cdate, y.cdate)
349 def mdate_sort(x, y):
350 return cmp(x.mdate, y.mdate)
352 def dir_sort(x, y):
353 if x.isdir == y.isdir:
354 return sortfunc(x, y)
355 else:
356 return y.isdir - x.isdir
358 subcname = query['Container'][0]
359 cname = subcname.split('/')[0]
360 path = self.get_local_path(handler, query)
362 # Build the list
363 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
365 if recurse and path in self.recurse_cache:
366 filelist = self.recurse_cache[path]
367 elif not recurse and path in self.dir_cache:
368 filelist = self.dir_cache[path]
369 else:
370 filelist = SortList(build_recursive_list(path, recurse))
372 if recurse:
373 self.recurse_cache[path] = filelist
374 else:
375 self.dir_cache[path] = filelist
377 filelist.acquire()
379 # Sort it
380 seed = ''
381 start = ''
382 sortby = query.get('SortOrder', ['Normal'])[0]
383 if 'Random' in sortby:
384 if 'RandomSeed' in query:
385 seed = query['RandomSeed'][0]
386 sortby += seed
387 if 'RandomStart' in query:
388 start = query['RandomStart'][0]
389 sortby += start
391 if filelist.unsorted or filelist.sortby != sortby:
392 if 'Random' in sortby:
393 self.random_lock.acquire()
394 if seed:
395 random.seed(seed)
396 random.shuffle(filelist.files)
397 self.random_lock.release()
398 if start:
399 local_base_path = self.get_local_base_path(handler, query)
400 start = unquote(start)
401 start = start.replace(os.path.sep + cname,
402 local_base_path, 1)
403 filenames = [x.name for x in filelist.files]
404 try:
405 index = filenames.index(start)
406 i = filelist.files.pop(index)
407 filelist.files.insert(0, i)
408 except ValueError:
409 print 'Start not found:', start
410 else:
411 if 'CaptureDate' in sortby:
412 sortfunc = cdate_sort
413 elif 'LastChangeDate' in sortby:
414 sortfunc = mdate_sort
415 else:
416 sortfunc = name_sort
418 if 'Type' in sortby:
419 filelist.files.sort(dir_sort)
420 else:
421 filelist.files.sort(sortfunc)
423 filelist.sortby = sortby
424 filelist.unsorted = False
426 files = filelist.files[:]
428 # Filter it -- this section needs work
429 if 'Filter' in query:
430 usedir = 'folder' in query['Filter'][0]
431 useimg = 'image' in query['Filter'][0]
432 if not usedir:
433 files = [x for x in files if not x.isdir]
434 elif usedir and not useimg:
435 files = [x for x in files if x.isdir]
437 files, total, start = self.item_count(handler, query, cname, files,
438 filelist.last_start)
439 filelist.last_start = start
440 filelist.release()
441 return files, total, start