Extra call to getMetadataFromTxt
[pyTivo.git] / plugins / video / video.py
blobf2d7752dc7b5da55d32db5f2f245477724f0bd24
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
12 SCRIPTDIR = os.path.dirname(__file__)
14 CLASS_NAME = 'Video'
16 extfile = os.path.join(SCRIPTDIR, 'video.ext')
17 try:
18 extensions = file(extfile).read().split()
19 except:
20 extensions = None
22 debug = config.getDebug()
23 hack83 = config.getHack83()
25 def debug_write(data):
26 if debug:
27 debug_out = []
28 debug_out.append('Video.py - ')
29 for x in data:
30 debug_out.append(str(x))
31 fdebug = open('debug.txt', 'a')
32 fdebug.write(' '.join(debug_out))
33 fdebug.close()
35 if hack83:
36 debug_write(['Hack83 is enabled.\n'])
38 class Video(Plugin):
40 CONTENT_TYPE = 'x-container/tivo-videos'
42 # Used for 8.3's broken requests
43 count = 0
44 request_history = {}
46 def pre_cache(self, full_path):
47 if Video.video_file_filter(self, full_path):
48 transcode.supported_format(full_path)
50 def video_file_filter(self, full_path, type=None):
51 if os.path.isdir(full_path):
52 return True
53 if extensions:
54 return os.path.splitext(full_path)[1].lower() in extensions
55 else:
56 return transcode.supported_format(full_path)
58 def hack(self, handler, query, subcname):
59 debug_write(['Hack new request ------------------------\n'])
60 debug_write(['Hack TiVo request is: \n', query, '\n'])
61 queryAnchor = ''
62 rightAnchor = ''
63 leftAnchor = ''
64 tsn = handler.headers.getheader('tsn', '')
66 # not a tivo
67 if not tsn:
68 debug_write(['Hack this was not a TiVo request.',
69 'Using default tsn.\n'])
70 tsn = '123456789'
72 # this breaks up the anchor item request into seperate parts
73 if 'AnchorItem' in query and query['AnchorItem'] != ['Hack8.3']:
74 queryAnchor = urllib.unquote_plus(''.join(query['AnchorItem']))
75 if queryAnchor.find('Container=') >= 0:
76 # This is a folder
77 queryAnchor = queryAnchor.split('Container=')[-1]
78 else:
79 # This is a file
80 queryAnchor = queryAnchor.split('/', 1)[-1]
81 leftAnchor, rightAnchor = queryAnchor.rsplit('/', 1)
82 debug_write(['Hack queryAnchor: ', queryAnchor,
83 ' leftAnchor: ', leftAnchor,
84 ' rightAnchor: ', rightAnchor, '\n'])
85 try:
86 path, state = self.request_history[tsn]
87 except KeyError:
88 # Never seen this tsn, starting new history
89 debug_write(['New TSN.\n'])
90 path = []
91 state = {}
92 self.request_history[tsn] = (path, state)
93 state['query'] = query
94 state['page'] = ''
95 state['time'] = int(time.time()) + 1000
97 debug_write(['Hack our saved request is: \n', state['query'], '\n'])
99 current_folder = subcname.split('/')[-1]
101 # Begin figuring out what the request TiVo sent us means
102 # There are 7 options that can occur
104 # 1. at the root - This request is always accurate
105 if len(subcname.split('/')) == 1:
106 debug_write(['Hack we are at the root.',
107 'Saving query, Clearing state[page].\n'])
108 path[:] = [current_folder]
109 state['query'] = query
110 state['page'] = ''
111 return query, path
113 # 2. entering a new folder
114 # If there is no AnchorItem in the request then we must be
115 # entering a new folder.
116 if 'AnchorItem' not in query:
117 debug_write(['Hack we are entering a new folder.',
118 'Saving query, setting time, setting state[page].\n'])
119 path[:] = subcname.split('/')
120 state['query'] = query
121 state['time'] = int(time.time())
122 files, total, start = self.get_files(handler, query,
123 self.video_file_filter)
124 if files:
125 state['page'] = files[0]
126 else:
127 state['page'] = ''
128 return query, path
130 # 3. Request a page after pyTivo sent a 302 code
131 # we know this is the proper page
132 if ''.join(query['AnchorItem']) == 'Hack8.3':
133 debug_write(['Hack requested page from 302 code.',
134 'Returning saved query,\n'])
135 return state['query'], path
137 # 4. this is a request for a file
138 if 'ItemCount' in query and int(''.join(query['ItemCount'])) == 1:
139 debug_write(['Hack requested a file', '\n'])
140 # Everything in this request is right except the container
141 query['Container'] = ['/'.join(path)]
142 state['page'] = ''
143 return query, path
145 # All remaining requests could be a second erroneous request for
146 # each of the following we will pause to see if a correct
147 # request is coming right behind it.
149 # Sleep just in case the erroneous request came first this
150 # allows a proper request to be processed first
151 debug_write(['Hack maybe erroneous request, sleeping.\n'])
152 time.sleep(.25)
154 # 5. scrolling in a folder
155 # This could be a request to exit a folder or scroll up or down
156 # within the folder
157 # First we have to figure out if we are scrolling
158 if 'AnchorOffset' in query:
159 debug_write(['Hack Anchor offset was in query.',
160 'leftAnchor needs to match ', '/'.join(path), '\n'])
161 if leftAnchor == str('/'.join(path)):
162 debug_write(['Hack leftAnchor matched.', '\n'])
163 query['Container'] = ['/'.join(path)]
164 files, total, start = self.get_files(handler, query,
165 self.video_file_filter)
166 debug_write(['Hack saved page is= ', state['page'],
167 ' top returned file is= ', files[0], '\n'])
168 # If the first file returned equals the top of the page
169 # then we haven't scrolled pages
170 if files[0] != str(state['page']):
171 debug_write(['Hack this is scrolling within a folder.\n'])
172 state['page'] = files[0]
173 return query, path
175 # The only remaining options are exiting a folder or this is a
176 # erroneous second request.
178 # 6. this an extraneous request
179 # this came within a second of a valid request; just use that
180 # request.
181 if (int(time.time()) - state['time']) <= 1:
182 debug_write(['Hack erroneous request, send a 302 error', '\n'])
183 return None, path
185 # 7. this is a request to exit a folder
186 # this request came by itself; it must be to exit a folder
187 else:
188 debug_write(['Hack over 1 second,',
189 'must be request to exit folder\n'])
190 path.pop()
191 state['query'] = {'Command': query['Command'],
192 'SortOrder': query['SortOrder'],
193 'ItemCount': query['ItemCount'],
194 'Filter': query['Filter'],
195 'Container': ['/'.join(path)]}
196 return None, path
198 # just in case we missed something.
199 debug_write(['Hack ERROR, should not have made it here. ',
200 'Trying to recover.\n'])
201 return state['query'], path
203 def send_file(self, handler, container, name):
204 if handler.headers.getheader('Range') and \
205 handler.headers.getheader('Range') != 'bytes=0-':
206 handler.send_response(206)
207 handler.send_header('Connection', 'close')
208 handler.send_header('Content-Type', 'video/x-tivo-mpeg')
209 handler.send_header('Transfer-Encoding', 'chunked')
210 handler.end_headers()
211 handler.wfile.write("\x30\x0D\x0A")
212 return
214 tsn = handler.headers.getheader('tsn', '')
216 o = urlparse("http://fake.host" + handler.path)
217 path = unquote(o[2])
218 handler.send_response(200)
219 handler.end_headers()
220 transcode.output_video(container['path'] + path[len(name) + 1:],
221 handler.wfile, tsn)
223 def __isdir(self, full_path):
224 return os.path.isdir(full_path)
226 def __duration(self, full_path):
227 return transcode.video_info(full_path)[4]
229 def __total_items(self, full_path):
230 count = 0
231 for file in os.listdir(full_path):
232 if file.startswith('.'):
233 continue
234 file = os.path.join(full_path, file)
235 if os.path.isdir(file):
236 count += 1
237 elif extensions:
238 if os.path.splitext(file)[1].lower() in extensions:
239 count += 1
240 elif file in transcode.info_cache:
241 if transcode.supported_format(file):
242 count += 1
243 return count
245 def __est_size(self, full_path, tsn = ''):
246 # Size is estimated by taking audio and video bit rate adding 2%
248 if transcode.tivo_compatable(full_path, tsn):
249 # Is TiVo-compatible mpeg2
250 return int(os.stat(full_path).st_size)
251 else:
252 # Must be re-encoded
253 audioBPS = config.strtod(config.getAudioBR(tsn))
254 videoBPS = config.strtod(config.getVideoBR(tsn))
255 bitrate = audioBPS + videoBPS
256 return int((self.__duration(full_path) / 1000) *
257 (bitrate * 1.02 / 8))
259 def __getMetadataFromTxt(self, full_path):
260 metadata = {}
262 default_file = os.path.join(os.path.split(full_path)[0], 'default.txt')
263 description_file = full_path + '.txt'
264 description_file2 = os.path.join(os.path.dirname(full_path), '.meta',
265 os.path.basename(full_path)) + '.txt'
267 metadata.update(self.__getMetadataFromFile(default_file))
268 metadata.update(self.__getMetadataFromFile(description_file))
269 metadata.update(self.__getMetadataFromFile(description_file2))
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 originalAirDate = datetime.fromtimestamp(os.stat(full_path).st_ctime)
303 metadata['title'] = '.'.join(title.split('.')[:-1])
304 metadata['seriesTitle'] = metadata['title'] # default to the filename
305 metadata['originalAirDate'] = originalAirDate.isoformat()
307 metadata.update(self.__getMetadataFromTxt(full_path))
309 return metadata
311 def __metadata_full(self, full_path, tsn=''):
312 metadata = {}
313 metadata.update(self.__metadata_basic(full_path))
315 now = datetime.utcnow()
317 duration = self.__duration(full_path)
318 duration_delta = timedelta(milliseconds = duration)
320 metadata['time'] = now.isoformat()
321 metadata['startTime'] = now.isoformat()
322 metadata['stopTime'] = (now + duration_delta).isoformat()
323 metadata['size'] = self.__est_size(full_path, tsn)
324 metadata['duration'] = duration
326 min = duration_delta.seconds / 60
327 sec = duration_delta.seconds % 60
328 hours = min / 60
329 min = min % 60
330 metadata['iso_duration'] = 'P' + str(duration_delta.days) + \
331 'DT' + str(hours) + 'H' + str(min) + \
332 'M' + str(sec) + 'S'
333 return metadata
335 def QueryContainer(self, handler, query):
336 tsn = handler.headers.getheader('tsn', '')
337 subcname = query['Container'][0]
339 # If you are running 8.3 software you want to enable hack83
340 # in the config file
342 if hack83:
343 print '=' * 73
344 query, hackPath = self.hack(handler, query, subcname)
345 hackPath = '/'.join(hackPath)
346 print 'Tivo said:', subcname, '|| Hack said:', hackPath
347 debug_write(['Hack Tivo said: ', subcname, ' || Hack said: ',
348 hackPath, '\n'])
349 subcname = hackPath
351 if not query:
352 debug_write(['Hack sending 302 redirect page', '\n'])
353 handler.send_response(302)
354 handler.send_header('Location ', 'http://' +
355 handler.headers.getheader('host') +
356 '/TiVoConnect?Command=QueryContainer&' +
357 'AnchorItem=Hack8.3&Container=' + hackPath)
358 handler.end_headers()
359 return
361 # End hack mess
363 cname = subcname.split('/')[0]
365 if not handler.server.containers.has_key(cname) or \
366 not self.get_local_path(handler, query):
367 handler.send_response(404)
368 handler.end_headers()
369 return
371 container = handler.server.containers[cname]
372 precache = container.get('precache', 'False').lower() == 'true'
374 files, total, start = self.get_files(handler, query,
375 self.video_file_filter)
377 videos = []
378 local_base_path = self.get_local_base_path(handler, query)
379 for file in files:
380 mtime = datetime.fromtimestamp(os.stat(file).st_mtime)
381 video = VideoDetails()
382 video['captureDate'] = hex(int(time.mktime(mtime.timetuple())))
383 video['name'] = os.path.split(file)[1]
384 video['path'] = file
385 video['part_path'] = file.replace(local_base_path, '', 1)
386 video['title'] = os.path.split(file)[1]
387 video['is_dir'] = self.__isdir(file)
388 if video['is_dir']:
389 video['small_path'] = subcname + '/' + video['name']
390 video['total_items'] = self.__total_items(file)
391 else:
392 if precache or len(files) == 1 or file in transcode.info_cache:
393 video['valid'] = transcode.supported_format(file)
394 if video['valid']:
395 video.update(self.__metadata_full(file, tsn))
396 else:
397 video['valid'] = True
398 video.update(self.__metadata_basic(file))
400 videos.append(video)
402 handler.send_response(200)
403 handler.end_headers()
404 t = Template(file=os.path.join(SCRIPTDIR,'templates', 'container.tmpl'))
405 t.container = cname
406 t.name = subcname
407 t.total = total
408 t.start = start
409 t.videos = videos
410 t.quote = quote
411 t.escape = escape
412 t.crc = zlib.crc32
413 t.guid = config.getGUID()
414 handler.wfile.write(t)
416 def TVBusQuery(self, handler, query):
417 tsn = handler.headers.getheader('tsn', '')
418 file = query['File'][0]
419 path = self.get_local_path(handler, query)
420 file_path = path + file
422 file_info = VideoDetails()
423 file_info['valid'] = transcode.supported_format(file_path)
424 if file_info['valid']:
425 file_info.update(self.__metadata_full(file_path, tsn))
427 handler.send_response(200)
428 handler.end_headers()
429 t = Template(file=os.path.join(SCRIPTDIR,'templates', 'TvBus.tmpl'))
430 t.video = file_info
431 t.escape = escape
432 handler.wfile.write(t)
434 class VideoDetails(DictMixin):
436 def __init__(self, d=None):
437 if d:
438 self.d = d
439 else:
440 self.d = {}
442 def __getitem__(self, key):
443 if key not in self.d:
444 self.d[key] = self.default(key)
445 return self.d[key]
447 def __contains__(self, key):
448 return True
450 def __setitem__(self, key, value):
451 self.d[key] = value
453 def __delitem__(self):
454 del self.d[key]
456 def keys(self):
457 return self.d.keys()
459 def __iter__(self):
460 return self.d.__iter__()
462 def iteritems(self):
463 return self.d.iteritems()
465 def default(self, key):
466 defaults = {
467 'showingBits' : '0',
468 'episodeNumber' : '0',
469 'displayMajorNumber' : '0',
470 'displayMinorNumber' : '0',
471 'isEpisode' : 'true',
472 'colorCode' : ('COLOR', '4'),
473 'showType' : ('SERIES', '5'),
474 'tvRating' : ('NR', '7')
476 if key in defaults:
477 return defaults[key]
478 elif key.startswith('v'):
479 return []
480 else:
481 return ''