9 from datetime
import datetime
10 from itertools
import izip
11 from xml
.dom
import minidom
12 from xml
.parsers
import expat
19 from lrucache
import LRUCache
22 import plugins
.video
.transcode
26 TRIBUNE_CR
= ' Copyright Tribune Media Services, Inc.'
28 TV_RATINGS
= {'TV-Y7': 1, 'TV-Y': 2, 'TV-G': 3, 'TV-PG': 4, 'TV-14': 5,
29 'TV-MA': 6, 'TV-NR': 7, 'TVY7': 1, 'TVY': 2, 'TVG': 3,
30 'TVPG': 4, 'TV14': 5, 'TVMA': 6, 'TVNR': 7, 'Y7': 1,
31 'Y': 2, 'G': 3, 'PG': 4, '14': 5, 'MA': 6, 'NR': 7,
32 'UNRATED': 7, 'X1': 1, 'X2': 2, 'X3': 3, 'X4': 4, 'X5': 5,
35 MPAA_RATINGS
= {'G': 1, 'PG': 2, 'PG-13': 3, 'PG13': 3, 'R': 4, 'X': 5,
36 'NC-17': 6, 'NC17': 6, 'NR': 8, 'UNRATED': 8, 'G1': 1,
37 'P2': 2, 'P3': 3, 'R4': 4, 'X5': 5, 'N6': 6, 'N8': 8}
39 STAR_RATINGS
= {'1': 1, '1.5': 2, '2': 3, '2.5': 4, '3': 5, '3.5': 6,
40 '4': 7, '*': 1, '**': 3, '***': 5, '****': 7, 'X1': 1,
41 'X2': 2, 'X3': 3, 'X4': 4, 'X5': 5, 'X6': 6, 'X7': 7}
43 HUMAN
= {'mpaaRating': {1: 'G', 2: 'PG', 3: 'PG-13', 4: 'R', 5: 'X',
45 'tvRating': {1: 'Y7', 2: 'Y', 3: 'G', 4: 'PG', 5: '14',
47 'starRating': {1: '1', 2: '1.5', 3: '2', 4: '2.5', 5: '3',
56 tivo_cache
= LRUCache(50)
57 mp4_cache
= LRUCache(50)
58 dvrms_cache
= LRUCache(50)
59 nfo_cache
= LRUCache(50)
61 mswindows
= (sys
.platform
== "win32")
64 return HUMAN
['mpaaRating'].get(rating
, 'NR')
67 return HUMAN
['tvRating'].get(rating
, 'NR')
69 def get_stars(rating
):
70 return HUMAN
['starRating'].get(rating
, '')
75 tsize
= '%.2f GB' % (raw
/ GB
)
77 tsize
= '%.2f MB' % (raw
/ MB
)
79 tsize
= '%.2f KB' % (raw
/ KB
)
81 tsize
= '%d Bytes' % raw
84 def tag_data(element
, tag
):
85 for name
in tag
.split('/'):
87 for new_element
in element
.childNodes
:
88 if new_element
.nodeName
== name
:
94 if not element
.firstChild
:
96 return element
.firstChild
.data
98 def _vtag_data(element
, tag
):
99 for name
in tag
.split('/'):
100 new_element
= element
.getElementsByTagName(name
)
103 element
= new_element
[0]
104 elements
= element
.getElementsByTagName('element')
105 return [x
.firstChild
.data
for x
in elements
if x
.firstChild
]
107 def _vtag_data_alternate(element
, tag
):
109 for name
in tag
.split('/'):
111 for elmt
in elements
:
112 new_elements
+= elmt
.getElementsByTagName(name
)
113 elements
= new_elements
114 return [x
.firstChild
.data
for x
in elements
if x
.firstChild
]
116 def _tag_value(element
, tag
):
117 item
= element
.getElementsByTagName(tag
)
119 value
= item
[0].attributes
['value'].value
122 def from_moov(full_path
):
123 if full_path
in mp4_cache
:
124 return mp4_cache
[full_path
]
130 mp4meta
= mutagen
.File(unicode(full_path
, 'utf-8'))
133 mp4_cache
[full_path
] = {}
136 # The following 1-to-1 correspondence of atoms to pyTivo
137 # variables is TV-biased
138 keys
= {'tvnn': 'callsign', 'tven': 'episodeNumber',
139 'tvsh': 'seriesTitle'}
141 for key
, value
in mp4meta
.items():
142 if type(value
) == list:
145 metadata
['isEpisode'] = ['false', 'true'][value
== 'TV Show']
147 metadata
[keys
[key
]] = value
148 # These keys begin with the copyright symbol \xA9
149 elif key
== '\xa9day':
151 value
+= '-01-01T16:00:00Z'
152 metadata
['originalAirDate'] = value
153 #metadata['time'] = value
154 elif key
in ['\xa9gen', 'gnre']:
155 for k
in ('vProgramGenre', 'vSeriesGenre'):
157 metadata
[k
].append(value
)
159 metadata
[k
] = [value
]
160 elif key
== '\xa9nam':
161 if 'tvsh' in mp4meta
:
162 metadata
['episodeTitle'] = value
164 metadata
['title'] = value
166 # Description in desc, cmt, and/or ldes tags. Keep the longest.
167 elif key
in ['desc', '\xa9cmt', 'ldes'] and len(value
) > len_desc
:
168 metadata
['description'] = value
169 len_desc
= len(value
)
171 # A common custom "reverse DNS format" tag
172 elif (key
== '----:com.apple.iTunes:iTunEXTC' and
173 ('us-tv' in value
or 'mpaa' in value
)):
174 rating
= value
.split("|")[1].upper()
175 if rating
in TV_RATINGS
and 'us-tv' in value
:
176 metadata
['tvRating'] = TV_RATINGS
[rating
]
177 elif rating
in MPAA_RATINGS
and 'mpaa' in value
:
178 metadata
['mpaaRating'] = MPAA_RATINGS
[rating
]
180 # Actors, directors, producers, AND screenwriters may be in a long
181 # embedded XML plist.
182 elif (key
== '----:com.apple.iTunes:iTunMOVI' and
183 'plistlib' in sys
.modules
):
184 items
= {'cast': 'vActor', 'directors': 'vDirector',
185 'producers': 'vProducer', 'screenwriters': 'vWriter'}
187 data
= plistlib
.readPlistFromString(value
)
193 metadata
[items
[item
]] = [x
['name'] for x
in data
[item
]]
195 mp4_cache
[full_path
] = metadata
198 def from_mscore(rawmeta
):
200 keys
= {'title': ['Title'],
201 'description': ['Description', 'WM/SubTitleDescription'],
202 'episodeTitle': ['WM/SubTitle'],
203 'callsign': ['WM/MediaStationCallSign'],
204 'displayMajorNumber': ['WM/MediaOriginalChannel'],
205 'originalAirDate': ['WM/MediaOriginalBroadcastDateTime'],
206 'rating': ['WM/ParentalRating'],
207 'credits': ['WM/MediaCredits'], 'genre': ['WM/Genre']}
210 for tag
in keys
[tagname
]:
213 value
= rawmeta
[tag
][0]
214 if type(value
) not in (str, unicode):
217 metadata
[tagname
] = value
221 if 'episodeTitle' in metadata
and 'title' in metadata
:
222 metadata
['seriesTitle'] = metadata
['title']
223 if 'genre' in metadata
:
224 value
= metadata
['genre'].split(',')
225 metadata
['vProgramGenre'] = value
226 metadata
['vSeriesGenre'] = value
227 del metadata
['genre']
228 if 'credits' in metadata
:
229 value
= [x
.split('/') for x
in metadata
['credits'].split(';')]
231 metadata
['vActor'] = [x
for x
in (value
[0] + value
[3]) if x
]
232 metadata
['vDirector'] = [x
for x
in value
[1] if x
]
233 del metadata
['credits']
234 if 'rating' in metadata
:
235 rating
= metadata
['rating']
236 if rating
in TV_RATINGS
:
237 metadata
['tvRating'] = TV_RATINGS
[rating
]
238 del metadata
['rating']
242 def from_dvrms(full_path
):
243 if full_path
in dvrms_cache
:
244 return dvrms_cache
[full_path
]
247 rawmeta
= mutagen
.File(unicode(full_path
, 'utf-8'))
250 dvrms_cache
[full_path
] = {}
253 metadata
= from_mscore(rawmeta
)
254 dvrms_cache
[full_path
] = metadata
257 def from_eyetv(full_path
):
258 keys
= {'TITLE': 'title', 'SUBTITLE': 'episodeTitle',
259 'DESCRIPTION': 'description', 'YEAR': 'movieYear',
260 'EPISODENUM': 'episodeNumber'}
262 path
= os
.path
.dirname(unicode(full_path
, 'utf-8'))
263 eyetvp
= [x
for x
in os
.listdir(path
) if x
.endswith('.eyetvp')][0]
264 eyetvp
= os
.path
.join(path
, eyetvp
)
266 eyetv
= plistlib
.readPlist(eyetvp
)
269 if 'epg info' in eyetv
:
270 info
= eyetv
['epg info']
273 metadata
[keys
[key
]] = info
[key
]
275 metadata
['seriesTitle'] = info
['TITLE']
277 metadata
['vActor'] = [x
.strip() for x
in info
['ACTORS'].split(',')]
279 metadata
['vDirector'] = [info
['DIRECTOR']]
281 for ptag
, etag
, ratings
in [('tvRating', 'TV_RATING', TV_RATINGS
),
282 ('mpaaRating', 'MPAA_RATING', MPAA_RATINGS
),
283 ('starRating', 'STAR_RATING', STAR_RATINGS
)]:
284 x
= info
[etag
].upper()
285 if x
and x
in ratings
:
286 metadata
[ptag
] = ratings
[x
]
288 # movieYear must be set for the mpaa/star ratings to work
289 if (('mpaaRating' in metadata
or 'starRating' in metadata
) and
290 'movieYear' not in metadata
):
291 metadata
['movieYear'] = eyetv
['info']['start'].year
294 def from_text(full_path
):
296 full_path
= unicode(full_path
, 'utf-8')
297 path
, name
= os
.path
.split(full_path
)
298 title
, ext
= os
.path
.splitext(name
)
303 parent
= os
.path
.dirname(ptmp
)
308 search_paths
.append(os
.path
.join(ptmp
, 'default.txt'))
310 search_paths
.append(os
.path
.join(path
, title
) + '.properties')
311 search_paths
.reverse()
313 search_paths
+= [full_path
+ '.txt',
314 os
.path
.join(path
, '.meta', 'default.txt'),
315 os
.path
.join(path
, '.meta', name
) + '.txt']
317 for metafile
in search_paths
:
318 if os
.path
.exists(metafile
):
319 sep
= ':='[metafile
.endswith('.properties')]
320 for line
in file(metafile
, 'U'):
321 if line
.startswith(BOM
):
323 if line
.strip().startswith('#') or not sep
in line
:
325 key
, value
= [x
.strip() for x
in line
.split(sep
, 1)]
326 if not key
or not value
:
328 if key
.startswith('v'):
330 metadata
[key
].append(value
)
332 metadata
[key
] = [value
]
334 metadata
[key
] = value
336 for rating
, ratings
in [('tvRating', TV_RATINGS
),
337 ('mpaaRating', MPAA_RATINGS
),
338 ('starRating', STAR_RATINGS
)]:
339 x
= metadata
.get(rating
, '').upper()
341 metadata
[rating
] = ratings
[x
]
351 def basic(full_path
):
352 base_path
, name
= os
.path
.split(full_path
)
353 title
, ext
= os
.path
.splitext(name
)
354 mtime
= os
.stat(unicode(full_path
, 'utf-8')).st_mtime
357 originalAirDate
= datetime
.utcfromtimestamp(mtime
)
359 metadata
= {'title': title
,
360 'originalAirDate': originalAirDate
.isoformat()}
362 if ext
in ['.mp4', '.m4v', '.mov']:
363 metadata
.update(from_moov(full_path
))
364 elif ext
in ['.dvr-ms', '.asf', '.wmv']:
365 metadata
.update(from_dvrms(full_path
))
366 elif 'plistlib' in sys
.modules
and base_path
.endswith('.eyetv'):
367 metadata
.update(from_eyetv(full_path
))
368 metadata
.update(from_nfo(full_path
))
369 metadata
.update(from_text(full_path
))
373 def from_container(xmldoc
):
376 keys
= {'title': 'Title', 'episodeTitle': 'EpisodeTitle',
377 'description': 'Description', 'programId': 'ProgramId',
378 'seriesId': 'SeriesId', 'episodeNumber': 'EpisodeNumber',
379 'tvRating': 'TvRating', 'displayMajorNumber': 'SourceChannel',
380 'callsign': 'SourceStation', 'showingBits': 'ShowingBits',
381 'mpaaRating': 'MpaaRating'}
383 details
= xmldoc
.getElementsByTagName('Details')[0]
386 data
= tag_data(details
, keys
[key
])
388 if key
== 'description':
389 data
= data
.replace(TRIBUNE_CR
, '')
390 elif key
== 'tvRating':
392 elif key
== 'displayMajorNumber':
394 data
, metadata
['displayMinorNumber'] = data
.split('-')
399 def from_details(xml
):
402 xmldoc
= minidom
.parseString(xml
)
403 showing
= xmldoc
.getElementsByTagName('showing')[0]
404 program
= showing
.getElementsByTagName('program')[0]
406 items
= {'description': 'program/description',
407 'title': 'program/title',
408 'episodeTitle': 'program/episodeTitle',
409 'episodeNumber': 'program/episodeNumber',
410 'programId': 'program/uniqueId',
411 'seriesId': 'program/series/uniqueId',
412 'seriesTitle': 'program/series/seriesTitle',
413 'originalAirDate': 'program/originalAirDate',
414 'isEpisode': 'program/isEpisode',
415 'movieYear': 'program/movieYear',
416 'partCount': 'partCount',
417 'partIndex': 'partIndex',
421 data
= tag_data(showing
, items
[item
])
423 if item
== 'description':
424 data
= data
.replace(TRIBUNE_CR
, '')
425 metadata
[item
] = data
427 vItems
= ['vActor', 'vChoreographer', 'vDirector',
428 'vExecProducer', 'vProgramGenre', 'vGuestStar',
429 'vHost', 'vProducer', 'vWriter']
432 data
= _vtag_data(program
, item
)
434 metadata
[item
] = data
436 sb
= showing
.getElementsByTagName('showingBits')
438 metadata
['showingBits'] = sb
[0].attributes
['value'].value
440 #for tag in ['starRating', 'mpaaRating', 'colorCode']:
441 for tag
in ['starRating', 'mpaaRating']:
442 value
= _tag_value(program
, tag
)
444 metadata
[tag
] = value
446 rating
= _tag_value(showing
, 'tvRating')
448 metadata
['tvRating'] = rating
452 def _nfo_vitems(source
, metadata
):
454 vItems
= {'vGenre': 'genre',
455 'vWriter': 'credits',
456 'vDirector': 'director',
457 'vActor': 'actor/name'}
460 data
= _vtag_data_alternate(source
, vItems
[key
])
462 metadata
.setdefault(key
, [])
464 if not dat
in metadata
[key
]:
465 metadata
[key
].append(dat
)
467 if 'vGenre' in metadata
:
468 metadata
['vSeriesGenre'] = metadata
['vProgramGenre'] = metadata
['vGenre']
472 def _parse_nfo(nfo_path
, nfo_data
=None):
473 # nfo files can contain XML or a URL to seed the XBMC metadata scrapers
474 # It's also possible to have both (a URL after the XML metadata)
475 # pyTivo only parses the XML metadata, but we'll try to stip the URL
476 # from mixed XML/URL files. Returns `None` when XML can't be parsed.
478 nfo_data
= [line
.strip() for line
in file(nfo_path
, 'rU')]
481 xmldoc
= minidom
.parseString(os
.linesep
.join(nfo_data
))
482 except expat
.ExpatError
, err
:
483 if expat
.ErrorString(err
.code
) == expat
.errors
.XML_ERROR_INVALID_TOKEN
:
484 # might be a URL outside the xml
485 while len(nfo_data
) > err
.lineno
:
486 if len(nfo_data
[-1]) == 0:
490 if len(nfo_data
) == err
.lineno
:
491 # last non-blank line contains the error
493 return _parse_nfo(nfo_path
, nfo_data
)
496 def _from_tvshow_nfo(tvshow_nfo_path
):
497 if tvshow_nfo_path
in nfo_cache
:
498 return nfo_cache
[tvshow_nfo_path
]
500 items
= {'description': 'plot',
502 'seriesTitle': 'showtitle',
503 'starRating': 'rating',
506 nfo_cache
[tvshow_nfo_path
] = metadata
= {}
508 xmldoc
= _parse_nfo(tvshow_nfo_path
)
512 tvshow
= xmldoc
.getElementsByTagName('tvshow')
519 data
= tag_data(tvshow
, items
[item
])
521 metadata
[item
] = data
523 metadata
= _nfo_vitems(tvshow
, metadata
)
525 nfo_cache
[tvshow_nfo_path
] = metadata
528 def _from_episode_nfo(nfo_path
, xmldoc
):
531 items
= {'description': 'plot',
532 'episodeTitle': 'title',
533 'seriesTitle': 'showtitle',
534 'originalAirDate': 'aired',
535 'starRating': 'rating',
541 basepath
= os
.path
.dirname(path
)
545 tv_nfo
= os
.path
.join(path
, 'tvshow.nfo')
546 if os
.path
.exists(tv_nfo
):
547 metadata
.update(_from_tvshow_nfo(tv_nfo
))
550 episode
= xmldoc
.getElementsByTagName('episodedetails')
556 metadata
['isEpisode'] = 'true'
558 data
= tag_data(episode
, items
[item
])
560 metadata
[item
] = data
562 season
= tag_data(episode
, 'displayseason')
563 if not season
or season
== "-1":
564 season
= tag_data(episode
, 'season')
568 ep_num
= tag_data(episode
, 'displayepisode')
569 if not ep_num
or ep_num
== "-1":
570 ep_num
= tag_data(episode
, 'episode')
571 if ep_num
and ep_num
!= "-1":
572 metadata
['episodeNumber'] = "%d%02d" % (int(season
), int(ep_num
))
574 if 'originalAirDate' in metadata
:
575 metadata
['originalAirDate'] += 'T00:00:00Z'
577 metadata
= _nfo_vitems(episode
, metadata
)
581 def _from_movie_nfo(xmldoc
):
584 movie
= xmldoc
.getElementsByTagName('movie')
590 items
= {'description': 'plot',
593 'starRating': 'rating',
594 'mpaaRating': 'mpaa'}
596 metadata
['isEpisode'] = 'false'
599 data
= tag_data(movie
, items
[item
])
601 metadata
[item
] = data
603 metadata
['movieYear'] = "%04d" % int(metadata
.get('movieYear', 0))
605 metadata
= _nfo_vitems(movie
, metadata
)
608 def from_nfo(full_path
):
609 if full_path
in nfo_cache
:
610 return nfo_cache
[full_path
]
612 metadata
= nfo_cache
[full_path
] = {}
614 nfo_path
= "%s.nfo" % os
.path
.splitext(full_path
)[0]
615 if not os
.path
.exists(nfo_path
):
618 xmldoc
= _parse_nfo(nfo_path
)
622 if xmldoc
.getElementsByTagName('episodedetails'):
624 metadata
.update(_from_episode_nfo(nfo_path
, xmldoc
))
625 elif xmldoc
.getElementsByTagName('movie'):
627 metadata
.update(_from_movie_nfo(xmldoc
))
630 if 'starRating' in metadata
:
631 # .NFO 0-10 -> TiVo 1-7
632 rating
= int(float(metadata
['starRating']) * 6 / 10 + 1.5)
633 metadata
['starRating'] = rating
635 for key
, mapping
in [('mpaaRating', MPAA_RATINGS
),
636 ('tvRating', TV_RATINGS
)]:
638 rating
= mapping
.get(metadata
[key
], None)
640 metadata
[key
] = str(rating
)
644 nfo_cache
[full_path
] = metadata
647 def _tdcat_bin(tdcat_path
, full_path
, tivo_mak
):
648 fname
= unicode(full_path
, 'utf-8')
650 fname
= fname
.encode('iso8859-1')
651 tcmd
= [tdcat_path
, '-m', tivo_mak
, '-2', fname
]
652 tdcat
= subprocess
.Popen(tcmd
, stdout
=subprocess
.PIPE
)
653 return tdcat
.stdout
.read()
655 def _tdcat_py(full_path
, tivo_mak
):
658 tfile
= open(full_path
, 'rb')
659 header
= tfile
.read(16)
660 offset
, chunks
= struct
.unpack('>LH', header
[10:])
661 rawdata
= tfile
.read(offset
- 16)
665 for i
in xrange(chunks
):
666 chunk_size
, data_size
, id, enc
= struct
.unpack('>LLHH',
667 rawdata
[count
:count
+ 12])
669 data
= rawdata
[count
:count
+ data_size
]
670 xml_data
[id] = {'enc': enc
, 'data': data
, 'start': count
+ 16}
671 count
+= chunk_size
- 12
674 details
= chunk
['data']
676 xml_key
= xml_data
[3]['data']
678 hexmak
= hashlib
.md5('tivo:TiVo DVR:' + tivo_mak
).hexdigest()
679 key
= hashlib
.sha1(hexmak
+ xml_key
).digest()[:16] + '\0\0\0\0'
681 turkey
= hashlib
.sha1(key
[:17]).digest()
682 turiv
= hashlib
.sha1(key
).digest()
684 xor_data
= turing
.Turing(turkey
, turiv
).gen(chunk
['start'],
686 fmt
= '%dB' % len(details
)
687 d2
= struct
.unpack(fmt
, details
)
688 x2
= struct
.unpack(fmt
, xor_data
)
689 details
= struct
.pack(fmt
, *(a ^ b
for a
, b
in izip(d2
, x2
)))
693 def from_tivo(full_path
):
694 if full_path
in tivo_cache
:
695 return tivo_cache
[full_path
]
697 tdcat_path
= config
.get_bin('tdcat')
698 tivo_mak
= config
.get_server('tivo_mak')
702 details
= _tdcat_bin(tdcat_path
, full_path
, tivo_mak
)
704 details
= _tdcat_py(full_path
, tivo_mak
)
705 metadata
= from_details(details
)
706 tivo_cache
[full_path
] = metadata
713 def force_utf8(text
):
714 if type(text
) == str:
716 text
= text
.decode('utf8')
718 if sys
.platform
== 'darwin':
719 text
= text
.decode('macroman')
721 text
= text
.decode('iso8859-1')
722 return text
.encode('utf-8')
724 def dump(output
, metadata
):
726 value
= metadata
[key
]
727 if type(value
) == list:
729 output
.write('%s: %s\n' % (key
, item
.encode('utf-8')))
731 if key
in HUMAN
and value
in HUMAN
[key
]:
732 output
.write('%s: %s\n' % (key
, HUMAN
[key
][value
]))
734 output
.write('%s: %s\n' % (key
, value
.encode('utf-8')))
736 if __name__
== '__main__':
737 if len(sys
.argv
) > 1:
740 logging
.basicConfig()
741 fname
= force_utf8(sys
.argv
[1])
742 ext
= os
.path
.splitext(fname
)[1].lower()
744 metadata
.update(from_tivo(fname
))
745 elif ext
in ['.mp4', '.m4v', '.mov']:
746 metadata
.update(from_moov(fname
))
747 elif ext
in ['.dvr-ms', '.asf', '.wmv']:
748 metadata
.update(from_dvrms(fname
))
750 vInfo
= plugins
.video
.transcode
.video_info(fname
)
751 metadata
.update(from_mscore(vInfo
['rawmeta']))
752 dump(sys
.stdout
, metadata
)