mimetype.guess_type() returns a tuple, and it might be (None, None).
[pyTivo/TheBayer.git] / plugins / photo / photo.py
blob5aadd1a3d4f45a2385e93ff2eba41a7432c78a7a
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 from PIL import Image
39 except ImportError:
40 try:
41 import Image
42 except ImportError:
43 print 'Photo Plugin Error: The Python Imaging Library is not installed'
45 from Cheetah.Template import Template
46 from lrucache import LRUCache
47 from plugin import EncodeUnicode, Plugin, quote, unquote
49 SCRIPTDIR = os.path.dirname(__file__)
51 CLASS_NAME = 'Photo'
53 # Match Exif date -- YYYY:MM:DD HH:MM:SS
54 exif_date = re.compile(r'(\d{4}):(\d\d):(\d\d) (\d\d):(\d\d):(\d\d)').search
56 # Match Exif orientation, Intel and Motorola versions
57 exif_orient_i = \
58 re.compile('\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00').search
59 exif_orient_m = \
60 re.compile('\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00').search
62 # Preload the template
63 tname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
64 PHOTO_TEMPLATE = file(tname, 'rb').read()
66 class Photo(Plugin):
68 CONTENT_TYPE = 'x-container/tivo-photos'
70 class LockedLRUCache(LRUCache):
71 def __init__(self, num):
72 LRUCache.__init__(self, num)
73 self.lock = threading.RLock()
75 def acquire(self, blocking=1):
76 return self.lock.acquire(blocking)
78 def release(self):
79 self.lock.release()
81 def __setitem__(self, key, obj):
82 self.acquire()
83 try:
84 LRUCache.__setitem__(self, key, obj)
85 finally:
86 self.release()
88 def __getitem__(self, key):
89 item = None
90 self.acquire()
91 try:
92 item = LRUCache.__getitem__(self, key)
93 finally:
94 self.release()
95 return item
97 media_data_cache = LockedLRUCache(300) # info and thumbnails
98 recurse_cache = LockedLRUCache(5) # recursive directory lists
99 dir_cache = LockedLRUCache(10) # non-recursive lists
101 def send_file(self, handler, path, query):
103 def send_jpeg(data):
104 handler.send_response(200)
105 handler.send_header('Content-Type', 'image/jpeg')
106 handler.send_header('Content-Length', len(data))
107 handler.send_header('Connection', 'close')
108 handler.end_headers()
109 handler.wfile.write(data)
111 if 'Format' in query and query['Format'][0] != 'image/jpeg':
112 handler.send_error(415)
113 return
115 try:
116 attrs = self.media_data_cache[path]
117 except:
118 attrs = None
120 # Set rotation
121 if attrs:
122 rot = attrs['rotation']
123 else:
124 rot = 0
126 if 'Rotation' in query:
127 rot = (rot - int(query['Rotation'][0])) % 360
128 if attrs:
129 attrs['rotation'] = rot
130 if 'thumb' in attrs:
131 del attrs['thumb']
133 # Requested size
134 width = int(query.get('Width', ['0'])[0])
135 height = int(query.get('Height', ['0'])[0])
137 # Return saved thumbnail?
138 if attrs and 'thumb' in attrs and 0 < width < 100 and 0 < height < 100:
139 send_jpeg(attrs['thumb'])
140 return
142 # Load
143 try:
144 pic = Image.open(unicode(path, 'utf-8'))
145 except Exception, msg:
146 handler.server.logger.error('Could not open %s -- %s' %
147 (path, msg))
148 handler.send_error(404)
149 return
151 # Set draft mode
152 try:
153 pic.draft('RGB', (width, height))
154 except Exception, msg:
155 handler.server.logger.error('Failed to set draft mode ' +
156 'for %s -- %s' % (path, msg))
157 handler.send_error(404)
158 return
160 # Read Exif data if possible
161 if 'exif' in pic.info:
162 exif = pic.info['exif']
164 # Capture date
165 if attrs and not 'odate' in attrs:
166 date = exif_date(exif)
167 if date:
168 year, month, day, hour, minute, second = (int(x)
169 for x in date.groups())
170 if year:
171 odate = time.mktime((year, month, day, hour,
172 minute, second, -1, -1, -1))
173 attrs['odate'] = '%#x' % int(odate)
175 # Orientation
176 if attrs and 'exifrot' in attrs:
177 rot = (rot + attrs['exifrot']) % 360
178 else:
179 if exif[6] == 'I':
180 orient = exif_orient_i(exif)
181 else:
182 orient = exif_orient_m(exif)
184 if orient:
185 exifrot = {
186 1: 0,
187 2: 0,
188 3: 180,
189 4: 180,
190 5: 90,
191 6: -90,
192 7: -90,
193 8: 90}.get(ord(orient.group(1)), 0)
195 rot = (rot + exifrot) % 360
196 if attrs:
197 attrs['exifrot'] = exifrot
199 # Rotate
200 try:
201 if rot:
202 pic = pic.rotate(rot)
203 except Exception, msg:
204 handler.server.logger.error('Rotate failed on %s -- %s' %
205 (path, msg))
206 handler.send_error(404)
207 return
209 # De-palletize
210 try:
211 if pic.mode == 'P':
212 pic = pic.convert()
213 except Exception, msg:
214 handler.server.logger.error('Palette conversion failed ' +
215 'on %s -- %s' % (path, msg))
216 handler.send_error(404)
217 return
219 # Old size
220 oldw, oldh = pic.size
222 if not width: width = oldw
223 if not height: height = oldh
225 # Correct aspect ratio
226 if 'PixelShape' in query:
227 pixw, pixh = query['PixelShape'][0].split(':')
228 oldw *= int(pixh)
229 oldh *= int(pixw)
231 # Resize
232 ratio = float(oldw) / oldh
234 if float(width) / height < ratio:
235 height = int(width / ratio)
236 else:
237 width = int(height * ratio)
239 try:
240 pic = pic.resize((width, height), Image.ANTIALIAS)
241 except Exception, msg:
242 handler.server.logger.error('Resize failed on %s -- %s' %
243 (path, msg))
244 handler.send_error(404)
245 return
247 # Re-encode
248 try:
249 out = StringIO()
250 pic.save(out, 'JPEG')
251 encoded = out.getvalue()
252 out.close()
253 except Exception, msg:
254 handler.server.logger.error('Encode failed on %s -- %s' %
255 (path, msg))
256 handler.send_error(404)
257 return
259 # Save thumbnails
260 if attrs and width < 100 and height < 100:
261 attrs['thumb'] = encoded
263 # Send it
264 send_jpeg(encoded)
266 def QueryContainer(self, handler, query):
268 # Reject a malformed request -- these attributes should only
269 # appear in requests to send_file, but sometimes appear here
270 badattrs = ('Rotation', 'Width', 'Height', 'PixelShape')
271 for i in badattrs:
272 if i in query:
273 handler.send_error(404)
274 return
276 subcname = query['Container'][0]
277 cname = subcname.split('/')[0]
278 local_base_path = self.get_local_base_path(handler, query)
279 if (not cname in handler.server.containers or
280 not self.get_local_path(handler, query)):
281 handler.send_error(404)
282 return
284 def ImageFileFilter(f):
285 goodexts = ('.jpg', '.gif', '.png', '.bmp', '.tif', '.xbm',
286 '.xpm', '.pgm', '.pbm', '.ppm', '.pcx', '.tga',
287 '.fpx', '.ico', '.pcd', '.jpeg', '.tiff', '.nef')
288 return os.path.splitext(f)[1].lower() in goodexts
290 def media_data(f):
291 if f.name in self.media_data_cache:
292 return self.media_data_cache[f.name]
294 item = {}
295 item['path'] = f.name
296 item['part_path'] = f.name.replace(local_base_path, '', 1)
297 item['name'] = os.path.split(f.name)[1]
298 item['is_dir'] = f.isdir
299 item['rotation'] = 0
300 item['cdate'] = '%#x' % f.cdate
301 item['mdate'] = '%#x' % f.mdate
303 self.media_data_cache[f.name] = item
304 return item
306 t = Template(PHOTO_TEMPLATE, filter=EncodeUnicode)
307 t.name = subcname
308 t.container = cname
309 t.files, t.total, t.start = self.get_files(handler, query,
310 ImageFileFilter)
311 t.files = map(media_data, t.files)
312 t.quote = quote
313 t.escape = escape
314 page = str(t)
316 handler.send_response(200)
317 handler.send_header('Content-Type', 'text/xml')
318 handler.send_header('Content-Length', len(page))
319 handler.send_header('Connection', 'close')
320 handler.end_headers()
321 handler.wfile.write(page)
323 def get_files(self, handler, query, filterFunction):
325 class FileData:
326 def __init__(self, name, isdir):
327 self.name = name
328 self.isdir = isdir
329 st = os.stat(unicode(name, 'utf-8'))
330 self.cdate = int(st.st_ctime)
331 self.mdate = int(st.st_mtime)
333 class SortList:
334 def __init__(self, files):
335 self.files = files
336 self.unsorted = True
337 self.sortby = None
338 self.last_start = 0
339 self.lock = threading.RLock()
341 def acquire(self, blocking=1):
342 return self.lock.acquire(blocking)
344 def release(self):
345 self.lock.release()
347 def build_recursive_list(path, recurse=True):
348 files = []
349 path = unicode(path, 'utf-8')
350 try:
351 for f in os.listdir(path):
352 if f.startswith('.'):
353 continue
354 f = os.path.join(path, f)
355 isdir = os.path.isdir(f)
356 f = f.encode('utf-8')
357 if recurse and isdir:
358 files.extend(build_recursive_list(f))
359 else:
360 if isdir or filterFunction(f):
361 files.append(FileData(f, isdir))
362 except:
363 pass
365 return files
367 def name_sort(x, y):
368 return cmp(x.name, y.name)
370 def cdate_sort(x, y):
371 return cmp(x.cdate, y.cdate)
373 def mdate_sort(x, y):
374 return cmp(x.mdate, y.mdate)
376 def dir_sort(x, y):
377 if x.isdir == y.isdir:
378 return sortfunc(x, y)
379 else:
380 return y.isdir - x.isdir
382 subcname = query['Container'][0]
383 cname = subcname.split('/')[0]
384 path = self.get_local_path(handler, query)
386 # Build the list
387 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
389 filelist = []
390 rc = self.recurse_cache
391 dc = self.dir_cache
392 if recurse:
393 if path in rc:
394 filelist = rc[path]
395 else:
396 updated = os.stat(unicode(path, 'utf-8'))[8]
397 if path in dc and dc.mtime(path) >= updated:
398 filelist = dc[path]
399 for p in rc:
400 if path.startswith(p) and rc.mtime(p) < updated:
401 del rc[p]
403 if not filelist:
404 filelist = SortList(build_recursive_list(path, recurse))
406 if recurse:
407 rc[path] = filelist
408 else:
409 dc[path] = filelist
411 filelist.acquire()
413 # Sort it
414 seed = ''
415 start = ''
416 sortby = query.get('SortOrder', ['Normal'])[0]
417 if 'Random' in sortby:
418 if 'RandomSeed' in query:
419 seed = query['RandomSeed'][0]
420 sortby += seed
421 if 'RandomStart' in query:
422 start = query['RandomStart'][0]
423 sortby += start
425 if filelist.unsorted or filelist.sortby != sortby:
426 if 'Random' in sortby:
427 self.random_lock.acquire()
428 if seed:
429 random.seed(seed)
430 random.shuffle(filelist.files)
431 self.random_lock.release()
432 if start:
433 local_base_path = self.get_local_base_path(handler, query)
434 start = unquote(start)
435 start = start.replace(os.path.sep + cname,
436 local_base_path, 1)
437 filenames = [x.name for x in filelist.files]
438 try:
439 index = filenames.index(start)
440 i = filelist.files.pop(index)
441 filelist.files.insert(0, i)
442 except ValueError:
443 handler.server.logger.warning('Start not found: ' +
444 start)
445 else:
446 if 'CaptureDate' in sortby:
447 sortfunc = cdate_sort
448 elif 'LastChangeDate' in sortby:
449 sortfunc = mdate_sort
450 else:
451 sortfunc = name_sort
453 if 'Type' in sortby:
454 filelist.files.sort(dir_sort)
455 else:
456 filelist.files.sort(sortfunc)
458 filelist.sortby = sortby
459 filelist.unsorted = False
461 files = filelist.files[:]
463 # Filter it -- this section needs work
464 if 'Filter' in query:
465 usedir = 'folder' in query['Filter'][0]
466 useimg = 'image' in query['Filter'][0]
467 if not usedir:
468 files = [x for x in files if not x.isdir]
469 elif usedir and not useimg:
470 files = [x for x in files if x.isdir]
472 files, total, start = self.item_count(handler, query, cname, files,
473 filelist.last_start)
474 filelist.last_start = start
475 filelist.release()
476 return files, total, start