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