root/plugins/video/video.py

Revision f15900aefc8ba09915196185fd3f9895c1532600, 18.3 kB (checked in by Jason Michalski <armooo@armooo.net>, 9 months ago)

Fixed push bug from the changed mind class api

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