No more pyTivo-temp files.
[pyTivo/wmcbrine.git] / plugins / video / video.py
blob95ed6b88a275e7a9f8438eef3983efa30a8be87e
1 import calendar
2 import logging
3 import os
4 import re
5 import struct
6 import thread
7 import time
8 import urllib
9 import zlib
10 from UserDict import DictMixin
11 from datetime import datetime, timedelta
12 from xml.sax.saxutils import escape
14 from Cheetah.Template import Template
15 from lrucache import LRUCache
17 import config
18 import metadata
19 import transcode
20 from plugin import EncodeUnicode, Plugin, quote
22 logger = logging.getLogger('pyTivo.video.video')
24 SCRIPTDIR = os.path.dirname(__file__)
26 CLASS_NAME = 'Video'
28 # Preload the templates
29 def tmpl(name):
30 return file(os.path.join(SCRIPTDIR, 'templates', name), 'rb').read()
32 XML_CONTAINER_TEMPLATE = tmpl('container_xml.tmpl')
33 TVBUS_TEMPLATE = tmpl('TvBus.tmpl')
35 EXTENSIONS = """.tivo .mpg .avi .wmv .mov .flv .f4v .vob .mp4 .m4v .mkv
36 .ts .tp .trp .3g2 .3gp .3gp2 .3gpp .amv .asf .avs .bik .bix .box .bsf
37 .dat .dif .divx .dmb .dpg .dv .dvr-ms .evo .eye .flc .fli .flx .gvi .ivf
38 .m1v .m21 .m2t .m2ts .m2v .m2p .m4e .mjp .mjpeg .mod .moov .movie .mp21
39 .mpe .mpeg .mpv .mpv2 .mqv .mts .mvb .nsv .nuv .nut .ogm .qt .rm .rmvb
40 .rts .scm .smv .ssm .svi .vdo .vfw .vid .viv .vivo .vp6 .vp7 .vro .webm
41 .wm .wmd .wtv .yuv""".split()
43 LIKELYTS = """.ts .tp .trp .3g2 .3gp .3gp2 .3gpp .m2t .m2ts .mts .mp4
44 .m4v .flv .mkv .mov .wtv .dvr-ms .webm""".split()
46 use_extensions = True
47 try:
48 assert(config.get_bin('ffmpeg'))
49 except:
50 use_extensions = False
52 def uniso(iso):
53 return time.strptime(iso[:19], '%Y-%m-%dT%H:%M:%S')
55 def isodt(iso):
56 return datetime(*uniso(iso)[:6])
58 def isogm(iso):
59 return int(calendar.timegm(uniso(iso)))
61 class Video(Plugin):
63 CONTENT_TYPE = 'x-container/tivo-videos'
65 tvbus_cache = LRUCache(1)
67 def video_file_filter(self, full_path, type=None):
68 if os.path.isdir(unicode(full_path, 'utf-8')):
69 return True
70 if use_extensions:
71 return os.path.splitext(full_path)[1].lower() in EXTENSIONS
72 else:
73 return transcode.supported_format(full_path)
75 def send_file(self, handler, path, query):
76 mime = 'video/x-tivo-mpeg'
77 tsn = handler.headers.getheader('tsn', '')
78 try:
79 assert(tsn)
80 tivo_name = config.tivos[tsn].get('name', tsn)
81 except:
82 tivo_name = handler.address_string()
84 is_tivo_file = (path[-5:].lower() == '.tivo')
86 if 'Format' in query:
87 mime = query['Format'][0]
89 needs_tivodecode = (is_tivo_file and mime == 'video/mpeg')
90 compatible = (not needs_tivodecode and
91 transcode.tivo_compatible(path, tsn, mime)[0])
93 try: # "bytes=XXX-"
94 offset = int(handler.headers.getheader('Range')[6:-1])
95 except:
96 offset = 0
98 if needs_tivodecode:
99 valid = bool(config.get_bin('tivodecode') and
100 config.get_server('tivo_mak'))
101 else:
102 valid = True
104 if valid and offset:
105 valid = ((compatible and offset < os.path.getsize(path)) or
106 (not compatible and transcode.is_resumable(path, offset)))
108 #faking = (mime in ['video/x-tivo-mpeg-ts', 'video/x-tivo-mpeg'] and
109 faking = (mime == 'video/x-tivo-mpeg' and
110 not (is_tivo_file and compatible))
111 fname = unicode(path, 'utf-8')
112 thead = ''
113 if faking:
114 thead = self.tivo_header(tsn, path, mime)
115 if compatible:
116 size = os.path.getsize(fname) + len(thead)
117 handler.send_response(200)
118 handler.send_header('Content-Length', size - offset)
119 handler.send_header('Content-Range', 'bytes %d-%d/%d' %
120 (offset, size - offset - 1, size))
121 else:
122 handler.send_response(206)
123 handler.send_header('Transfer-Encoding', 'chunked')
124 handler.send_header('Content-Type', mime)
125 handler.end_headers()
127 logger.info('[%s] Start sending "%s" to %s' %
128 (time.strftime('%d/%b/%Y %H:%M:%S'), fname, tivo_name))
129 start = time.time()
130 count = 0
132 if valid:
133 if compatible:
134 if faking and not offset:
135 handler.wfile.write(thead)
136 logger.debug('"%s" is tivo compatible' % fname)
137 f = open(fname, 'rb')
138 try:
139 if offset:
140 offset -= len(thead)
141 f.seek(offset)
142 while True:
143 block = f.read(512 * 1024)
144 if not block:
145 break
146 handler.wfile.write(block)
147 count += len(block)
148 except Exception, msg:
149 logger.info(msg)
150 f.close()
151 else:
152 logger.debug('"%s" is not tivo compatible' % fname)
153 if offset:
154 count = transcode.resume_transfer(path, handler.wfile,
155 offset)
156 else:
157 count = transcode.transcode(False, path, handler.wfile,
158 tsn, mime, thead)
159 try:
160 if not compatible:
161 handler.wfile.write('0\r\n\r\n')
162 handler.wfile.flush()
163 except Exception, msg:
164 logger.info(msg)
166 mega_elapsed = (time.time() - start) * 1024 * 1024
167 if mega_elapsed < 1:
168 mega_elapsed = 1
169 rate = count * 8.0 / mega_elapsed
170 logger.info('[%s] Done sending "%s" to %s, %d bytes, %.2f Mb/s' %
171 (time.strftime('%d/%b/%Y %H:%M:%S'), fname,
172 tivo_name, count, rate))
174 def __duration(self, full_path):
175 return transcode.video_info(full_path)['millisecs']
177 def __total_items(self, full_path):
178 count = 0
179 try:
180 full_path = unicode(full_path, 'utf-8')
181 for f in os.listdir(full_path):
182 if f.startswith('.'):
183 continue
184 f = os.path.join(full_path, f)
185 f2 = f.encode('utf-8')
186 if os.path.isdir(f):
187 count += 1
188 elif use_extensions:
189 if os.path.splitext(f2)[1].lower() in EXTENSIONS:
190 count += 1
191 elif f2 in transcode.info_cache:
192 if transcode.supported_format(f2):
193 count += 1
194 except:
195 pass
196 return count
198 def __est_size(self, full_path, tsn='', mime=''):
199 # Size is estimated by taking audio and video bit rate adding 2%
201 if transcode.tivo_compatible(full_path, tsn, mime)[0]:
202 return os.path.getsize(unicode(full_path, 'utf-8'))
203 else:
204 # Must be re-encoded
205 audioBPS = config.getMaxAudioBR(tsn) * 1000
206 #audioBPS = config.strtod(config.getAudioBR(tsn))
207 videoBPS = transcode.select_videostr(full_path, tsn)
208 bitrate = audioBPS + videoBPS
209 return int((self.__duration(full_path) / 1000) *
210 (bitrate * 1.02 / 8))
212 def metadata_full(self, full_path, tsn='', mime='', mtime=None):
213 data = {}
214 vInfo = transcode.video_info(full_path)
216 if ((int(vInfo['vHeight']) >= 720 and
217 config.getTivoHeight >= 720) or
218 (int(vInfo['vWidth']) >= 1280 and
219 config.getTivoWidth >= 1280)):
220 data['showingBits'] = '4096'
222 data.update(metadata.basic(full_path, mtime))
223 if full_path[-5:].lower() == '.tivo':
224 data.update(metadata.from_tivo(full_path))
225 if full_path[-4:].lower() == '.wtv':
226 data.update(metadata.from_mscore(vInfo['rawmeta']))
228 if 'episodeNumber' in data:
229 try:
230 ep = int(data['episodeNumber'])
231 except:
232 ep = 0
233 data['episodeNumber'] = str(ep)
235 if config.getDebug() and 'vHost' not in data:
236 compatible, reason = transcode.tivo_compatible(full_path, tsn, mime)
237 if compatible:
238 transcode_options = []
239 else:
240 transcode_options = transcode.transcode(True, full_path,
241 '', tsn, mime)
242 data['vHost'] = (
243 ['TRANSCODE=%s, %s' % (['YES', 'NO'][compatible], reason)] +
244 ['SOURCE INFO: '] +
245 ["%s=%s" % (k, v)
246 for k, v in sorted(vInfo.items(), reverse=True)] +
247 ['TRANSCODE OPTIONS: '] +
248 transcode_options +
249 ['SOURCE FILE: ', os.path.basename(full_path)]
252 now = datetime.utcnow()
253 if 'time' in data:
254 if data['time'].lower() == 'file':
255 if not mtime:
256 mtime = os.path.getmtime(unicode(full_path, 'utf-8'))
257 try:
258 now = datetime.utcfromtimestamp(mtime)
259 except:
260 logger.warning('Bad file time on ' + full_path)
261 elif data['time'].lower() == 'oad':
262 now = isodt(data['originalAirDate'])
263 else:
264 try:
265 now = isodt(data['time'])
266 except:
267 logger.warning('Bad time format: ' + data['time'] +
268 ' , using current time')
270 duration = self.__duration(full_path)
271 duration_delta = timedelta(milliseconds = duration)
272 min = duration_delta.seconds / 60
273 sec = duration_delta.seconds % 60
274 hours = min / 60
275 min = min % 60
277 data.update({'time': now.isoformat(),
278 'startTime': now.isoformat(),
279 'stopTime': (now + duration_delta).isoformat(),
280 'size': self.__est_size(full_path, tsn, mime),
281 'duration': duration,
282 'iso_duration': ('P%sDT%sH%sM%sS' %
283 (duration_delta.days, hours, min, sec))})
285 return data
287 def QueryContainer(self, handler, query):
288 tsn = handler.headers.getheader('tsn', '')
289 subcname = query['Container'][0]
291 if not self.get_local_path(handler, query):
292 handler.send_error(404)
293 return
295 container = handler.container
296 force_alpha = container.getboolean('force_alpha')
297 ar = container.get('allow_recurse', 'auto').lower()
298 if ar == 'auto':
299 allow_recurse = not tsn or tsn[0] < '7'
300 else:
301 allow_recurse = ar in ('1', 'yes', 'true', 'on')
303 files, total, start = self.get_files(handler, query,
304 self.video_file_filter,
305 force_alpha, allow_recurse)
307 videos = []
308 local_base_path = self.get_local_base_path(handler, query)
309 for f in files:
310 video = VideoDetails()
311 mtime = f.mdate
312 try:
313 ltime = time.localtime(mtime)
314 except:
315 logger.warning('Bad file time on ' + unicode(f.name, 'utf-8'))
316 mtime = time.time()
317 ltime = time.localtime(mtime)
318 video['captureDate'] = hex(int(mtime))
319 video['textDate'] = time.strftime('%b %d, %Y', ltime)
320 video['name'] = os.path.basename(f.name)
321 video['path'] = f.name
322 video['part_path'] = f.name.replace(local_base_path, '', 1)
323 if not video['part_path'].startswith(os.path.sep):
324 video['part_path'] = os.path.sep + video['part_path']
325 video['title'] = os.path.basename(f.name)
326 video['is_dir'] = f.isdir
327 if video['is_dir']:
328 video['small_path'] = subcname + '/' + video['name']
329 video['total_items'] = self.__total_items(f.name)
330 else:
331 if len(files) == 1 or f.name in transcode.info_cache:
332 video['valid'] = transcode.supported_format(f.name)
333 if video['valid']:
334 video.update(self.metadata_full(f.name, tsn,
335 mtime=mtime))
336 if len(files) == 1:
337 video['captureDate'] = hex(isogm(video['time']))
338 else:
339 video['valid'] = True
340 video.update(metadata.basic(f.name, mtime))
342 if self.use_ts(tsn, f.name):
343 video['mime'] = 'video/x-tivo-mpeg-ts'
344 else:
345 video['mime'] = 'video/x-tivo-mpeg'
347 video['textSize'] = metadata.human_size(f.size)
349 videos.append(video)
351 t = Template(XML_CONTAINER_TEMPLATE, filter=EncodeUnicode)
352 t.container = handler.cname
353 t.name = subcname
354 t.total = total
355 t.start = start
356 t.videos = videos
357 t.quote = quote
358 t.escape = escape
359 t.crc = zlib.crc32
360 t.guid = config.getGUID()
361 t.tivos = config.tivos
362 handler.send_xml(str(t))
364 def use_ts(self, tsn, file_path):
365 if config.is_ts_capable(tsn):
366 ext = os.path.splitext(file_path)[1].lower()
367 if ext == '.tivo':
368 try:
369 flag = file(file_path).read(8)
370 except:
371 return False
372 if ord(flag[7]) & 0x20:
373 return True
374 else:
375 opt = config.get_ts_flag()
376 if ((opt == 'auto' and ext in LIKELYTS) or
377 (opt in ['true', 'yes', 'on'])):
378 return True
380 return False
382 def get_details_xml(self, tsn, file_path):
383 if (tsn, file_path) in self.tvbus_cache:
384 details = self.tvbus_cache[(tsn, file_path)]
385 else:
386 file_info = VideoDetails()
387 file_info['valid'] = transcode.supported_format(file_path)
388 if file_info['valid']:
389 file_info.update(self.metadata_full(file_path, tsn))
391 t = Template(TVBUS_TEMPLATE, filter=EncodeUnicode)
392 t.video = file_info
393 t.escape = escape
394 t.get_tv = metadata.get_tv
395 t.get_mpaa = metadata.get_mpaa
396 t.get_stars = metadata.get_stars
397 t.get_color = metadata.get_color
398 details = str(t)
399 self.tvbus_cache[(tsn, file_path)] = details
400 return details
402 def tivo_header(self, tsn, path, mime):
403 def pad(length, align):
404 extra = length % align
405 if extra:
406 extra = align - extra
407 return extra
409 if mime == 'video/x-tivo-mpeg-ts':
410 flag = 45
411 else:
412 flag = 13
413 details = self.get_details_xml(tsn, path)
414 ld = len(details)
415 chunk = details + '\0' * (pad(ld, 4) + 4)
416 lc = len(chunk)
417 blocklen = lc * 2 + 40
418 padding = pad(blocklen, 1024)
420 return ''.join(['TiVo', struct.pack('>HHHLH', 4, flag, 0,
421 padding + blocklen, 2),
422 struct.pack('>LLHH', lc + 12, ld, 1, 0),
423 chunk,
424 struct.pack('>LLHH', lc + 12, ld, 2, 0),
425 chunk, '\0' * padding])
427 def TVBusQuery(self, handler, query):
428 tsn = handler.headers.getheader('tsn', '')
429 f = query['File'][0]
430 path = self.get_local_path(handler, query)
431 file_path = os.path.normpath(path + '/' + f)
433 details = self.get_details_xml(tsn, file_path)
435 handler.send_xml(details)
437 class VideoDetails(DictMixin):
439 def __init__(self, d=None):
440 if d:
441 self.d = d
442 else:
443 self.d = {}
445 def __getitem__(self, key):
446 if key not in self.d:
447 self.d[key] = self.default(key)
448 return self.d[key]
450 def __contains__(self, key):
451 return True
453 def __setitem__(self, key, value):
454 self.d[key] = value
456 def __delitem__(self):
457 del self.d[key]
459 def keys(self):
460 return self.d.keys()
462 def __iter__(self):
463 return self.d.__iter__()
465 def iteritems(self):
466 return self.d.iteritems()
468 def default(self, key):
469 defaults = {
470 'showingBits' : '0',
471 'displayMajorNumber' : '0',
472 'displayMinorNumber' : '0',
473 'isEpisode' : 'true',
474 'colorCode' : '4',
475 'showType' : ('SERIES', '5')
477 if key in defaults:
478 return defaults[key]
479 elif key.startswith('v'):
480 return []
481 else:
482 return ''