Preload the templates for the video plugin.
[pyTivo/wgw.git] / plugins / video / video.py
blob14ce736d14f04bd66ab135a9fc160e3859f29f91
1 import transcode, os, socket, re, urllib, zlib
2 from Cheetah.Template import Template
3 from plugin import Plugin, quote, unquote
4 from urlparse import urlparse
5 from xml.sax.saxutils import escape
6 from lrucache import LRUCache
7 from UserDict import DictMixin
8 from datetime import datetime, timedelta
9 import config
10 import time
11 import mind
12 import logging
14 SCRIPTDIR = os.path.dirname(__file__)
16 CLASS_NAME = 'Video'
18 # Preload the templates
19 tcname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
20 ttname = os.path.join(SCRIPTDIR, 'templates', 'TvBus.tmpl')
21 txname = os.path.join(SCRIPTDIR, 'templates', 'container.xsl')
22 CONTAINER_TEMPLATE = file(tcname, 'rb').read()
23 TVBUS_TEMPLATE = file(ttname, 'rb').read()
24 XSL_TEMPLATE = file(txname, 'rb').read()
26 extfile = os.path.join(SCRIPTDIR, 'video.ext')
27 try:
28 extensions = file(extfile).read().split()
29 except:
30 extensions = None
32 if config.getHack83():
33 logging.getLogger('pyTivo.hack83').info('Hack83 is enabled.')
35 class Video(Plugin):
37 CONTENT_TYPE = 'x-container/tivo-videos'
39 # Used for 8.3's broken requests
40 count = 0
41 request_history = {}
43 def pre_cache(self, full_path):
44 if Video.video_file_filter(self, full_path):
45 transcode.supported_format(full_path)
47 def video_file_filter(self, full_path, type=None):
48 if os.path.isdir(full_path):
49 return True
50 if extensions:
51 return os.path.splitext(full_path)[1].lower() in extensions
52 else:
53 return transcode.supported_format(full_path)
55 def hack(self, handler, query, subcname):
56 logger = logging.getLogger('pyTivo.hack83')
57 logger.debug('new request ------------------------')
58 logger.debug('TiVo request is: \n%s' % query)
59 queryAnchor = ''
60 rightAnchor = ''
61 leftAnchor = ''
62 tsn = handler.headers.getheader('tsn', '')
64 # not a tivo
65 if not tsn:
66 logger.debug('this was not a TiVo request. Using default tsn.')
67 tsn = '123456789'
69 # this breaks up the anchor item request into seperate parts
70 if 'AnchorItem' in query and query['AnchorItem'] != ['Hack8.3']:
71 queryAnchor = urllib.unquote_plus(''.join(query['AnchorItem']))
72 if queryAnchor.find('Container=') >= 0:
73 # This is a folder
74 queryAnchor = queryAnchor.split('Container=')[-1]
75 else:
76 # This is a file
77 queryAnchor = queryAnchor.split('/', 1)[-1]
78 leftAnchor, rightAnchor = queryAnchor.rsplit('/', 1)
79 logger.debug('queryAnchor:%s \n leftAnchor:%s\n rightAnchor: %s' %
80 (queryAnchor, leftAnchor, rightAnchor))
81 try:
82 path, state = self.request_history[tsn]
83 except KeyError:
84 # Never seen this tsn, starting new history
85 logger.debug('New TSN.')
86 path = []
87 state = {}
88 self.request_history[tsn] = (path, state)
89 state['query'] = query
90 state['page'] = ''
91 state['time'] = int(time.time()) + 1000
93 logger.debug('our saved request is: \n%s' % state['query'])
95 current_folder = subcname.split('/')[-1]
97 # Begin figuring out what the request TiVo sent us means
98 # There are 7 options that can occur
100 # 1. at the root - This request is always accurate
101 if len(subcname.split('/')) == 1:
102 logger.debug('we are at the root. Saving query, Clearing state[page].')
103 path[:] = [current_folder]
104 state['query'] = query
105 state['page'] = ''
106 return query, path
108 # 2. entering a new folder
109 # If there is no AnchorItem in the request then we must be
110 # entering a new folder.
111 if 'AnchorItem' not in query:
112 logger.debug('we are entering a new folder. Saving query, setting time, setting state[page].')
113 path[:] = subcname.split('/')
114 state['query'] = query
115 state['time'] = int(time.time())
116 files, total, start = self.get_files(handler, query,
117 self.video_file_filter)
118 if files:
119 state['page'] = files[0]
120 else:
121 state['page'] = ''
122 return query, path
124 # 3. Request a page after pyTivo sent a 302 code
125 # we know this is the proper page
126 if ''.join(query['AnchorItem']) == 'Hack8.3':
127 logger.debug('requested page from 302 code. Returning saved query.')
128 return state['query'], path
130 # 4. this is a request for a file
131 if 'ItemCount' in query and int(''.join(query['ItemCount'])) == 1:
132 logger.debug('requested a file')
133 # Everything in this request is right except the container
134 query['Container'] = ['/'.join(path)]
135 state['page'] = ''
136 return query, path
138 # All remaining requests could be a second erroneous request for
139 # each of the following we will pause to see if a correct
140 # request is coming right behind it.
142 # Sleep just in case the erroneous request came first this
143 # allows a proper request to be processed first
144 logger.debug('maybe erroneous request, sleeping.')
145 time.sleep(.25)
147 # 5. scrolling in a folder
148 # This could be a request to exit a folder or scroll up or down
149 # within the folder
150 # First we have to figure out if we are scrolling
151 if 'AnchorOffset' in query:
152 logger.debug('Anchor offset was in query. leftAnchor needs to match %s' % '/'.join(path))
153 if leftAnchor == str('/'.join(path)):
154 logger.debug('leftAnchor matched.')
155 query['Container'] = ['/'.join(path)]
156 files, total, start = self.get_files(handler, query,
157 self.video_file_filter)
158 logger.debug('saved page is=%s top returned file is= %s' % (state['page'], files[0]))
159 # If the first file returned equals the top of the page
160 # then we haven't scrolled pages
161 if files[0] != str(state['page']):
162 logger.debug('this is scrolling within a folder.')
163 state['page'] = files[0]
164 return query, path
166 # The only remaining options are exiting a folder or this is a
167 # erroneous second request.
169 # 6. this an extraneous request
170 # this came within a second of a valid request; just use that
171 # request.
172 if (int(time.time()) - state['time']) <= 1:
173 logger.debug('erroneous request, send a 302 error')
174 return None, path
176 # 7. this is a request to exit a folder
177 # this request came by itself; it must be to exit a folder
178 else:
179 logger.debug('over 1 second must be request to exit folder')
180 path.pop()
181 state['query'] = {'Command': query['Command'],
182 'SortOrder': query['SortOrder'],
183 'ItemCount': query['ItemCount'],
184 'Filter': query['Filter'],
185 'Container': ['/'.join(path)]}
186 files, total, start = self.get_files(handler, state['query'],
187 self.video_file_filter)
188 if files:
189 state['page'] = files[0]
190 else:
191 state['page'] = ''
192 return None, path
194 # just in case we missed something.
195 logger.debug('ERROR, should not have made it here Trying to recover.')
196 return state['query'], path
198 def send_file(self, handler, container, name):
199 if handler.headers.getheader('Range') and \
200 handler.headers.getheader('Range') != 'bytes=0-':
201 handler.send_response(206)
202 handler.send_header('Connection', 'close')
203 handler.send_header('Content-Type', 'video/x-tivo-mpeg')
204 handler.send_header('Transfer-Encoding', 'chunked')
205 handler.end_headers()
206 handler.wfile.write("\x30\x0D\x0A")
207 return
209 tsn = handler.headers.getheader('tsn', '')
211 o = urlparse("http://fake.host" + handler.path)
212 path = unquote(o[2])
213 handler.send_response(200)
214 handler.end_headers()
215 transcode.output_video(container['path'] + path[len(name) + 1:],
216 handler.wfile, tsn)
218 def __isdir(self, full_path):
219 return os.path.isdir(full_path)
221 def __duration(self, full_path):
222 return transcode.video_info(full_path)['millisecs']
224 def __total_items(self, full_path):
225 count = 0
226 try:
227 for file in os.listdir(full_path):
228 if file.startswith('.'):
229 continue
230 file = os.path.join(full_path, file)
231 if os.path.isdir(file):
232 count += 1
233 elif extensions:
234 if os.path.splitext(file)[1].lower() in extensions:
235 count += 1
236 elif file in transcode.info_cache:
237 if transcode.supported_format(file):
238 count += 1
239 except:
240 pass
241 return count
243 def __est_size(self, full_path, tsn = ''):
244 # Size is estimated by taking audio and video bit rate adding 2%
246 if transcode.tivo_compatable(full_path, tsn)[0]:
247 # Is TiVo-compatible mpeg2
248 return int(os.stat(full_path).st_size)
249 else:
250 # Must be re-encoded
251 if config.getAudioCodec(tsn) == None:
252 audioBPS = config.getMaxAudioBR(tsn)*1000
253 else:
254 audioBPS = config.strtod(config.getAudioBR(tsn))
255 videoBPS = transcode.select_videostr(full_path, tsn)
256 bitrate = audioBPS + videoBPS
257 return int((self.__duration(full_path) / 1000) *
258 (bitrate * 1.02 / 8))
260 def getMetadataFromTxt(self, full_path):
261 metadata = {}
263 default_meta = os.path.join(os.path.split(full_path)[0], 'default.txt')
264 standard_meta = full_path + '.txt'
265 subdir_meta = os.path.join(os.path.dirname(full_path), '.meta',
266 os.path.basename(full_path)) + '.txt'
268 for metafile in (default_meta, standard_meta, subdir_meta):
269 metadata.update(self.__getMetadataFromFile(metafile))
271 return metadata
273 def __getMetadataFromFile(self, file):
274 metadata = {}
276 if os.path.exists(file):
277 for line in open(file):
278 if line.strip().startswith('#'):
279 continue
280 if not ':' in line:
281 continue
283 key, value = line.split(':', 1)
284 key = key.strip()
285 value = value.strip()
287 if key.startswith('v'):
288 if key in metadata:
289 metadata[key].append(value)
290 else:
291 metadata[key] = [value]
292 else:
293 metadata[key] = value
295 return metadata
297 def metadata_basic(self, full_path):
298 metadata = {}
300 base_path, title = os.path.split(full_path)
301 ctime = os.stat(full_path).st_ctime
302 if (ctime < 0): ctime = 0
303 originalAirDate = datetime.fromtimestamp(ctime)
305 metadata['title'] = '.'.join(title.split('.')[:-1])
306 metadata['seriesTitle'] = metadata['title'] # default to the filename
307 metadata['originalAirDate'] = originalAirDate.isoformat()
309 metadata.update(self.getMetadataFromTxt(full_path))
311 return metadata
313 def metadata_full(self, full_path, tsn=''):
314 metadata = {}
316 now = datetime.utcnow()
318 duration = self.__duration(full_path)
319 duration_delta = timedelta(milliseconds = duration)
321 metadata['time'] = now.isoformat()
322 metadata['startTime'] = now.isoformat()
323 metadata['stopTime'] = (now + duration_delta).isoformat()
324 metadata['size'] = self.__est_size(full_path, tsn)
325 metadata['duration'] = duration
326 vInfo = transcode.video_info(full_path)
327 transcode_options = {}
328 if not transcode.tivo_compatable(full_path, tsn)[0]:
329 transcode_options = transcode.transcode(True, full_path, '', tsn)
330 metadata['vHost'] = [str(transcode.tivo_compatable(full_path, tsn)[1])]+\
331 ['SOURCE INFO: ']+["%s=%s" % (k, v) for k, v in sorted(transcode.video_info(full_path).items(), reverse=True)]+\
332 ['TRANSCODE OPTIONS: ']+["%s" % (v) for k, v in transcode_options.items()]+\
333 ['SOURCE FILE: ']+[str(os.path.split(full_path)[1])]
334 if not (full_path[-5:]).lower() == '.tivo':
335 if (int(vInfo['vHeight']) >= 720 and config.getTivoHeight >= 720) or \
336 (int(vInfo['vWidth']) >= 1280 and config.getTivoWidth >= 1280):
337 metadata['showingBits'] = '4096'
339 metadata.update(self.metadata_basic(full_path))
341 min = duration_delta.seconds / 60
342 sec = duration_delta.seconds % 60
343 hours = min / 60
344 min = min % 60
345 metadata['iso_duration'] = 'P' + str(duration_delta.days) + \
346 'DT' + str(hours) + 'H' + str(min) + \
347 'M' + str(sec) + 'S'
348 return metadata
350 def QueryContainer(self, handler, query):
351 tsn = handler.headers.getheader('tsn', '')
352 subcname = query['Container'][0]
354 # If you are running 8.3 software you want to enable hack83
355 # in the config file
356 if config.getHack83():
357 logger = logging.getLogger('pyTivo.hack83')
358 logger.debug('=' * 73)
359 query, hackPath = self.hack(handler, query, subcname)
360 hackPath = '/'.join(hackPath)
361 logger.debug('Tivo said: %s || Hack said: %s' % (subcname, hackPath))
362 subcname = hackPath
364 if not query:
365 logger.debug('sending 302 redirect page')
366 handler.send_response(302)
367 handler.send_header('Location ', 'http://' +
368 handler.headers.getheader('host') +
369 '/TiVoConnect?Command=QueryContainer&' +
370 'AnchorItem=Hack8.3&Container=' + quote(hackPath))
371 handler.end_headers()
372 return
374 # End hack mess
376 cname = subcname.split('/')[0]
378 if not handler.server.containers.has_key(cname) or \
379 not self.get_local_path(handler, query):
380 handler.send_response(404)
381 handler.end_headers()
382 return
384 container = handler.server.containers[cname]
385 precache = container.get('precache', 'False').lower() == 'true'
387 files, total, start = self.get_files(handler, query,
388 self.video_file_filter)
390 videos = []
391 local_base_path = self.get_local_base_path(handler, query)
392 for file in files:
393 mtime = os.stat(file).st_mtime
394 if (mtime < 0): mtime = 0
395 mtime = datetime.fromtimestamp(mtime)
396 video = VideoDetails()
397 video['captureDate'] = hex(int(time.mktime(mtime.timetuple())))
398 video['name'] = os.path.split(file)[1]
399 video['path'] = file
400 video['part_path'] = file.replace(local_base_path, '', 1)
401 if not video['part_path'].startswith(os.path.sep):
402 video['part_path'] = os.path.sep + video['part_path']
403 video['title'] = os.path.split(file)[1]
404 video['is_dir'] = self.__isdir(file)
405 if video['is_dir']:
406 video['small_path'] = subcname + '/' + video['name']
407 video['total_items'] = self.__total_items(file)
408 else:
409 if precache or len(files) == 1 or file in transcode.info_cache:
410 video['valid'] = transcode.supported_format(file)
411 if video['valid']:
412 video.update(self.metadata_full(file, tsn))
413 else:
414 video['valid'] = True
415 video.update(self.metadata_basic(file))
417 videos.append(video)
419 handler.send_response(200)
420 handler.end_headers()
421 t = Template(CONTAINER_TEMPLATE)
422 t.container = cname
423 t.name = subcname
424 t.total = total
425 t.start = start
426 t.videos = videos
427 t.quote = quote
428 t.escape = escape
429 t.crc = zlib.crc32
430 t.guid = config.getGUID()
431 t.tivos = handler.tivos
432 t.tivo_names = handler.tivo_names
433 handler.wfile.write(t)
435 def TVBusQuery(self, handler, query):
436 tsn = handler.headers.getheader('tsn', '')
437 file = query['File'][0]
438 path = self.get_local_path(handler, query)
439 file_path = path + file
441 file_info = VideoDetails()
442 file_info['valid'] = transcode.supported_format(file_path)
443 if file_info['valid']:
444 file_info.update(self.metadata_full(file_path, tsn))
446 handler.send_response(200)
447 handler.end_headers()
448 t = Template(TVBUS_TEMPLATE)
449 t.video = file_info
450 t.escape = escape
451 handler.wfile.write(t)
453 def XSL(self, handler, query):
454 handler.send_response(200)
455 handler.end_headers()
456 handler.wfile.write(XSL_TEMPLATE)
458 def Push(self, handler, query):
459 file = unquote(query['File'][0])
461 tsn = query['tsn'][0]
462 for key in handler.tivo_names:
463 if handler.tivo_names[key] == tsn:
464 tsn = key
465 break
467 path = self.get_local_path(handler, query)
468 file_path = path + file
470 file_info = VideoDetails()
471 file_info['valid'] = transcode.supported_format(file_path)
472 if file_info['valid']:
473 file_info.update(self.metadata_full(file_path, tsn))
475 import socket
476 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
477 s.connect(('tivo.com',123))
478 ip = s.getsockname()[0]
479 container = quote(query['Container'][0].split('/')[0])
480 port = config.getPort()
482 url = 'http://%s:%s/%s%s' % (ip, port, container, quote(file))
484 try:
485 m = mind.getMind()
486 m.pushVideo(
487 tsn = tsn,
488 url = url,
489 description = file_info['description'],
490 duration = file_info['duration'] / 1000,
491 size = file_info['size'],
492 title = file_info['title'],
493 subtitle = file_info['name'])
494 except Exception, e:
495 import traceback
496 handler.send_response(500)
497 handler.end_headers()
498 handler.wfile.write('%s\n\n%s' % (e, traceback.format_exc() ))
499 raise
501 referer = handler.headers.getheader('Referer')
502 handler.send_response(302)
503 handler.send_header('Location', referer)
504 handler.end_headers()
507 class VideoDetails(DictMixin):
509 def __init__(self, d=None):
510 if d:
511 self.d = d
512 else:
513 self.d = {}
515 def __getitem__(self, key):
516 if key not in self.d:
517 self.d[key] = self.default(key)
518 return self.d[key]
520 def __contains__(self, key):
521 return True
523 def __setitem__(self, key, value):
524 self.d[key] = value
526 def __delitem__(self):
527 del self.d[key]
529 def keys(self):
530 return self.d.keys()
532 def __iter__(self):
533 return self.d.__iter__()
535 def iteritems(self):
536 return self.d.iteritems()
538 def default(self, key):
539 defaults = {
540 'showingBits' : '0',
541 'episodeNumber' : '0',
542 'displayMajorNumber' : '0',
543 'displayMinorNumber' : '0',
544 'isEpisode' : 'true',
545 'colorCode' : ('COLOR', '4'),
546 'showType' : ('SERIES', '5'),
547 'tvRating' : ('NR', '7')
549 if key in defaults:
550 return defaults[key]
551 elif key.startswith('v'):
552 return []
553 else:
554 return ''