Difference between revisions of "Mythvidexport.py"
From MythTV Official Wiki
Jasonblewis (talk | contribs) m |
m (ensure no overwrites of existing files) |
||
(17 intermediate revisions by 4 users not shown) | |||
Line 5: | Line 5: | ||
|long=A python script intended to be run as a user job allowing for exporting recordings into MythVideo. | |long=A python script intended to be run as a user job allowing for exporting recordings into MythVideo. | ||
|category=User Job Scripts | |category=User Job Scripts | ||
− | |file=mythvidexport.py}} | + | |file=mythvidexport.py |
+ | |S25=yes|S26=yes|S27=yes}} | ||
− | This python script is intended to function as a | + | This python script is intended to function as a [[User_Jobs|user job]], capable of exporting recordings into [[MythVideo]]. |
− | |||
− | |||
− | |||
<pre> | <pre> | ||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
This script can be run from the command line, or called through the mythtv | This script can be run from the command line, or called through the mythtv | ||
jobqueue. The input format will be: | jobqueue. The input format will be: | ||
Line 42: | Line 25: | ||
use EPG data rather than grabbers for metadata | use EPG data rather than grabbers for metadata | ||
will still try to grab episode and season information from ttvdb.py | will still try to grab episode and season information from ttvdb.py | ||
+ | --seekdata copy seek data from recording | ||
+ | --skiplist copy commercial detection from recording | ||
+ | --cutlist copy manual commercial cutlist from recording | ||
Additional functions are available beyond exporting video | Additional functions are available beyond exporting video | ||
Line 51: | Line 37: | ||
--tformat <string> replace existing TV format | --tformat <string> replace existing TV format | ||
--gformat <string> replace existing Generic format | --gformat <string> replace existing Generic format | ||
− | """ | + | </pre> |
+ | |||
+ | {{Python|mythvidexport.py| | ||
+ | <pre> | ||
+ | #!/usr/bin/env python | ||
+ | # -*- coding: UTF-8 -*- | ||
+ | #--------------------------- | ||
+ | # Name: mythvidexport.py | ||
+ | # Python Script | ||
+ | # Author: Raymond Wagner | ||
+ | # Purpose | ||
+ | # This python script is intended to function as a user job, run through | ||
+ | # mythjobqueue, capable of exporting recordings into MythVideo. | ||
+ | #--------------------------- | ||
+ | __title__ = "MythVidExport" | ||
+ | __author__ = "Raymond Wagner" | ||
+ | __version__= "v0.7.5" | ||
− | from MythTV import MythDB, Job, Video, VideoGrabber, MythLog, MythError | + | from MythTV import MythDB, Job, Recorded, Video, VideoGrabber,\ |
+ | MythLog, MythError, static, MythBE | ||
+ | from optparse import OptionParser, OptionGroup | ||
from socket import gethostname | from socket import gethostname | ||
− | |||
− | |||
− | |||
− | import sys | + | import os |
+ | import re | ||
+ | import sys | ||
+ | import time | ||
+ | import hashlib | ||
+ | |||
+ | def create_dummy_video(db=None): | ||
+ | db = MythDB(db) | ||
+ | def hashfile(fd): | ||
+ | hasher = hashlib.sha1() | ||
+ | while True: | ||
+ | buff = fd.read(2**16) | ||
+ | if len(buff) == 0: | ||
+ | break | ||
+ | hasher.update(buff) | ||
+ | return hasher.hexdigest() | ||
class VIDEO: | class VIDEO: | ||
Line 67: | Line 83: | ||
self.job = Job(jobid) | self.job = Job(jobid) | ||
self.chanid = self.job.chanid | self.chanid = self.job.chanid | ||
− | self.starttime = | + | self.starttime = self.job.starttime |
− | + | self.job.update(status=Job.STARTING) | |
− | self.job.update(status= | ||
else: | else: | ||
self.job = None | self.job = None | ||
self.chanid = opts.chanid | self.chanid = opts.chanid | ||
− | self. | + | self.starttime = opts.starttime |
self.opts = opts | self.opts = opts | ||
Line 80: | Line 95: | ||
# load setting strings | # load setting strings | ||
− | |||
self.get_format() | self.get_format() | ||
− | # | + | # prep objects |
− | self. | + | self.rec = Recorded((self.chanid,self.starttime), db=self.db) |
− | + | self.log(MythLog.GENERAL, MythLog.INFO, 'Using recording', | |
− | self. | + | '%s - %s' % (self.rec.title.encode('utf-8'), |
− | + | self.rec.subtitle.encode('utf-8'))) | |
− | + | self.vid = Video(db=self.db).create({'title':'', 'filename':'', | |
− | self.vid = Video( | + | 'host':gethostname()}) |
− | |||
+ | # process data | ||
self.get_meta() | self.get_meta() | ||
self.get_dest() | self.get_dest() | ||
+ | # bug fix to work around limitation in the bindings where DBDataRef classes | ||
+ | # are mapped to the filename at time of Video element creation. since the | ||
+ | # filename is specified as blank when the video is created, the markup | ||
+ | # handler is not properly initialized | ||
+ | self.vid.markup._refdat = (self.vid.filename,) | ||
# save file | # save file | ||
self.copy() | self.copy() | ||
− | + | if opts.seekdata: | |
− | + | self.copy_seek() | |
− | + | if opts.skiplist: | |
− | + | self.copy_markup(static.MARKUP.MARK_COMM_START, | |
− | + | static.MARKUP.MARK_COMM_END) | |
− | + | if opts.cutlist: | |
− | + | self.copy_markup(static.MARKUP.MARK_CUT_START, | |
− | + | static.MARKUP.MARK_CUT_END) | |
− | + | self.vid.update() | |
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | # | + | # delete old file |
− | self. | + | if opts.delete: |
+ | self.rec.delete() | ||
def get_format(self): | def get_format(self): | ||
− | host = gethostname() | + | host = self.db.gethostname() |
# TV Format | # TV Format | ||
if self.opts.tformat: | if self.opts.tformat: | ||
Line 148: | Line 158: | ||
def get_meta(self): | def get_meta(self): | ||
− | self.vid.hostname = gethostname() | + | self.vid.hostname = self.db.gethostname() |
− | if self.rec. | + | if self.rec.inetref: |
− | self. | + | # good data is available, use it |
− | + | if self.rec.season > 0 or self.rec.episode > 0: | |
− | + | self.log(self.log.GENERAL, self.log.INFO, | |
− | + | 'Performing TV export with local data.') | |
− | + | self.type = 'TV' | |
− | + | grab = VideoGrabber(self.type) | |
− | + | metadata = grab.grabInetref(self.rec.inetref, self.rec.season, self.rec.episode) | |
− | + | else: | |
− | + | self.log(self.log.GENERAL, self.log.INFO, | |
− | + | 'Performing Movie export with local data.') | |
− | + | self.type = 'MOVIE' | |
− | elif | + | grab = VideoGrabber(self.type) |
− | # | + | metadata = grab.grabInetref(self.rec.inetref) |
− | + | elif self.opts.listingonly: | |
− | + | # force use of local data | |
− | + | if self.rec.subtitle: | |
− | + | self.log(self.log.GENERAL, self.log.INFO, | |
− | + | 'Forcing TV export with local data.') | |
− | + | self.type = 'TV' | |
− | + | else: | |
− | + | self.log(self.log.GENERAL, self.log.INFO, | |
− | + | 'Forcing Movie export with local data.') | |
− | + | self.type = 'MOVIE' | |
− | + | metadata = self.rec.exportMetadata() | |
− | |||
− | |||
− | self. | ||
else: | else: | ||
− | + | if self.rec.subtitle: | |
− | + | # subtitle exists, assume tv show | |
− | + | self.type = 'TV' | |
− | + | self.log(self.log.GENERAL, self.log.INFO, | |
− | + | 'Attempting TV export.') | |
− | + | grab = VideoGrabber(self.type) | |
− | self. | + | match = grab.sortedSearch(self.rec.title, self.rec.subtitle) |
− | + | else: # assume movie | |
+ | self.type = 'MOVIE' | ||
+ | self.log(self.log.GENERAL, self.log.INFO, | ||
+ | 'Attempting Movie export.') | ||
+ | grab = VideoGrabber(self.type) | ||
+ | match = grab.sortedSearch(self.rec.title) | ||
− | + | if len(match) == 0: | |
− | + | # no match found | |
− | + | self.log(self.log.GENERAL, self.log.INFO, | |
− | + | 'Falling back to generic export.') | |
− | + | self.type = 'GENERIC' | |
− | + | metadata = self.rec.exportMetadata() | |
− | + | elif (len(match) > 1) & (match[0].levenshtein > 0): | |
− | + | # multiple matches found, and closest is not exact | |
− | + | self.vid.delete() | |
− | + | raise MythError('Multiple metadata matches found: '\ | |
− | + | +self.rec.title) | |
− | + | else: | |
− | + | self.log(self.log.GENERAL, self.log.INFO, | |
− | + | 'Importing content from', match[0].inetref) | |
− | + | metadata = grab.grabInetref(match[0]) | |
− | |||
− | |||
− | + | self.vid.importMetadata(metadata) | |
− | self.vid. | + | self.log(self.log.GENERAL, self.log.INFO, 'Import complete') |
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | self. | ||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
def get_dest(self): | def get_dest(self): | ||
Line 235: | Line 230: | ||
#print self.vid.data | #print self.vid.data | ||
ext = '.'+self.rec.basename.rsplit('.',1)[1] | ext = '.'+self.rec.basename.rsplit('.',1)[1] | ||
− | rep = ( ('%TITLE%','title','%s'), | + | rep = ( ('%TITLE%','title','%s'), ('%SUBTITLE%','subtitle','%s'), |
− | ('%SEASON%','season','%d'), | + | ('%SEASON%','season','%d'), ('%SEASONPAD%','season','%02d'), |
− | ('%EPISODE%','episode','%d'), | + | ('%EPISODE%','episode','%d'), ('%EPISODEPAD%','episode','%02d'), |
− | ('%YEAR%','year','%s'), | + | ('%YEAR%','year','%s'), ('%DIRECTOR%','director','%s')) |
for tag, data, format in rep: | for tag, data, format in rep: | ||
if self.vid[data]: | if self.vid[data]: | ||
Line 246: | Line 241: | ||
# replace fields from program data | # replace fields from program data | ||
− | rep = ( ('%HOSTNAME','hostname','%s'),('%STORAGEGROUP%','storagegroup','%s')) | + | rep = ( ('%HOSTNAME%', 'hostname', '%s'), |
+ | ('%STORAGEGROUP%','storagegroup','%s')) | ||
for tag, data, format in rep: | for tag, data, format in rep: | ||
data = getattr(self.rec, data) | data = getattr(self.rec, data) | ||
fmt = fmt.replace(tag,format % data) | fmt = fmt.replace(tag,format % data) | ||
− | # | + | # fmt = fmt.replace('%CARDID%',self.rec.cardid) |
− | # | + | # fmt = fmt.replace('%CARDNAME%',self.rec.cardid) |
− | # | + | # fmt = fmt.replace('%SOURCEID%',self.rec.cardid) |
− | # | + | # fmt = fmt.replace('%SOURCENAME%',self.rec.cardid) |
− | # | + | # fmt = fmt.replace('%CHANNUM%',self.rec.channum) |
− | # | + | # fmt = fmt.replace('%CHANNAME%',self.rec.cardid) |
− | if len(self.genre): | + | if len(self.vid.genre): |
− | fmt = fmt.replace('%GENRE%',self.genre[0]) | + | fmt = fmt.replace('%GENRE%',self.vid.genre[0].genre) |
else: | else: | ||
fmt = fmt.replace('%GENRE%','') | fmt = fmt.replace('%GENRE%','') | ||
− | # | + | # if len(self.country): |
− | # | + | # fmt = fmt.replace('%COUNTRY%',self.country[0]) |
− | # | + | # else: |
− | # | + | # fmt = fmt.replace('%COUNTRY%','') |
return fmt+ext | return fmt+ext | ||
def copy(self): | def copy(self): | ||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
stime = time.time() | stime = time.time() | ||
srcsize = self.rec.filesize | srcsize = self.rec.filesize | ||
htime = [stime,stime,stime,stime] | htime = [stime,stime,stime,stime] | ||
− | self | + | self.log(MythLog.GENERAL|MythLog.FILE, MythLog.INFO, "Copying myth://%s@%s/%s"\ |
% (self.rec.storagegroup, self.rec.hostname, self.rec.basename)\ | % (self.rec.storagegroup, self.rec.hostname, self.rec.basename)\ | ||
+" to myth://Videos@%s/%s"\ | +" to myth://Videos@%s/%s"\ | ||
% (self.vid.host, self.vid.filename)) | % (self.vid.host, self.vid.filename)) | ||
srcfp = self.rec.open('r') | srcfp = self.rec.open('r') | ||
− | dstfp = self.vid.open('w') | + | dstfp = self.vid.open('w', nooverwrite=True) |
if self.job: | if self.job: | ||
− | self.job.setStatus( | + | self.job.setStatus(Job.RUNNING) |
tsize = 2**24 | tsize = 2**24 | ||
while tsize == 2**24: | while tsize == 2**24: | ||
Line 305: | Line 293: | ||
self.vid.hash = self.vid.getHash() | self.vid.hash = self.vid.getHash() | ||
− | self | + | self.log(MythLog.GENERAL|MythLog.FILE, MythLog.INFO, "Transfer Complete", |
− | "% seconds elapsed" % int(time.time()-stime)) | + | "%d seconds elapsed" % int(time.time()-stime)) |
+ | |||
+ | if self.opts.reallysafe: | ||
+ | if self.job: | ||
+ | self.job.setComment("Checking file hashes") | ||
+ | self.log(MythLog.GENERAL|MythLog.FILE, MythLog.INFO, "Checking file hashes.") | ||
+ | srchash = hashfile(self.rec.open('r')) | ||
+ | dsthash = hashfile(self.rec.open('r')) | ||
+ | if srchash != dsthash: | ||
+ | raise MythError('Source hash (%s) does not match destination hash (%s)' \ | ||
+ | % (srchash, dsthash)) | ||
+ | elif self.opts.safe: | ||
+ | self.log(MythLog.GENERAL|MythLog.FILE, MythLog.INFO, "Checking file sizes.") | ||
+ | be = MythBE(db=self.vid._db) | ||
+ | try: | ||
+ | srcsize = be.getSGFile(self.rec.hostname, self.rec.storagegroup, \ | ||
+ | self.rec.basename)[1] | ||
+ | dstsize = be.getSGFile(self.vid.host, 'Videos', self.vid.filename)[1] | ||
+ | except: | ||
+ | raise MythError('Could not query file size from backend') | ||
+ | if srcsize != dstsize: | ||
+ | raise MythError('Source size (%d) does not match destination size (%d)' \ | ||
+ | % (srcsize, dstsize)) | ||
+ | |||
if self.job: | if self.job: | ||
self.job.setComment("Complete - %d seconds elapsed" % \ | self.job.setComment("Complete - %d seconds elapsed" % \ | ||
(int(time.time()-stime))) | (int(time.time()-stime))) | ||
− | self.job.setStatus( | + | self.job.setStatus(Job.FINISHED) |
− | def | + | def copy_seek(self): |
− | for | + | for seek in self.rec.seek: |
− | + | self.vid.markup.add(seek.mark, seek.offset, seek.type) | |
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
+ | def copy_markup(self, start, stop): | ||
+ | for mark in self.rec.markup: | ||
+ | if mark.type in (start, stop): | ||
+ | self.vid.markup.add(mark.mark, 0, mark.type) | ||
def usage_format(): | def usage_format(): | ||
usagestr = """The default strings are: | usagestr = """The default strings are: | ||
− | + | Television: Television/%TITLE%/Season %SEASON%/%TITLE% - S%SEASON%E%EPISODEPAD% - %SUBTITLE% | |
− | + | Movie: Movies/%TITLE% | |
− | + | Generic: Videos/%TITLE% | |
Available strings: | Available strings: | ||
− | + | %TITLE%: series title | |
− | + | %SUBTITLE%: episode title | |
− | + | %SEASON%: season number | |
− | + | %SEASONPAD%: season number, padded to 2 digits | |
− | + | %EPISODE%: episode number | |
− | + | %EPISODEPAD%: episode number, padded to 2 digits | |
− | + | %YEAR%: year | |
− | + | %DIRECTOR%: director | |
− | + | %HOSTNAME%: backend used to record show | |
− | + | %STORAGEGROUP%: storage group containing recorded show | |
− | + | %GENRE%: first genre listed for recording | |
""" | """ | ||
− | # | + | # %CARDID%: ID of tuner card used to record show |
− | # | + | # %CARDNAME%: name of tuner card used to record show |
− | # | + | # %SOURCEID%: ID of video source used to record show |
− | # | + | # %SOURCENAME%: name of video source used to record show |
− | # | + | # %CHANNUM%: ID of channel used to record show |
− | # | + | # %CHANNAME%: name of channel used to record show |
− | # | + | # %COUNTRY%: first country listed for recording |
print usagestr | print usagestr | ||
Line 395: | Line 373: | ||
gfmt = 'Videos/%TITLE%' | gfmt = 'Videos/%TITLE%' | ||
print "Current output formats:" | print "Current output formats:" | ||
− | print " | + | print " TV: "+tfmt |
− | print " | + | print " Movies: "+mfmt |
− | print " | + | print " Generic: "+gfmt |
def main(): | def main(): | ||
parser = OptionParser(usage="usage: %prog [options] [jobid]") | parser = OptionParser(usage="usage: %prog [options] [jobid]") | ||
− | parser.add_option("-f", "--helpformat", action="store_true", default=False, dest="fmthelp", | + | formatgroup = OptionGroup(parser, "Formatting Options", |
+ | "These options are used to display and manipulate the output file formats.") | ||
+ | formatgroup.add_option("-f", "--helpformat", action="store_true", default=False, dest="fmthelp", | ||
help="Print explination of file format string.") | help="Print explination of file format string.") | ||
− | + | formatgroup.add_option("-p", "--printformat", action="store_true", default=False, dest="fmtprint", | |
help="Print current file format string.") | help="Print current file format string.") | ||
− | + | formatgroup.add_option("--tformat", action="store", type="string", dest="tformat", | |
help="Use TV format for current task. If no task, store in database.") | help="Use TV format for current task. If no task, store in database.") | ||
− | + | formatgroup.add_option("--mformat", action="store", type="string", dest="mformat", | |
help="Use Movie format for current task. If no task, store in database.") | help="Use Movie format for current task. If no task, store in database.") | ||
− | + | formatgroup.add_option("--gformat", action="store", type="string", dest="gformat", | |
help="Use Generic format for current task. If no task, store in database.") | help="Use Generic format for current task. If no task, store in database.") | ||
− | parser.add_option("--chanid", action="store", type="int", dest="chanid", | + | formatgroup.add_option("--listingonly", action="store_true", default=False, dest="listingonly", |
+ | help="Use data from listing provider, rather than grabber") | ||
+ | parser.add_option_group(formatgroup) | ||
+ | |||
+ | sourcegroup = OptionGroup(parser, "Source Definition", | ||
+ | "These options can be used to manually specify a recording to operate on "+\ | ||
+ | "in place of the job id.") | ||
+ | sourcegroup.add_option("--chanid", action="store", type="int", dest="chanid", | ||
help="Use chanid for manual operation") | help="Use chanid for manual operation") | ||
− | + | sourcegroup.add_option("--starttime", action="store", type="int", dest="starttime", | |
help="Use starttime for manual operation") | help="Use starttime for manual operation") | ||
− | parser.add_option( | + | parser.add_option_group(sourcegroup) |
− | help=" | + | |
− | parser. | + | actiongroup = OptionGroup(parser, "Additional Actions", |
− | help=" | + | "These options perform additional actions after the recording has been exported.") |
− | + | actiongroup.add_option('--safe', action='store_true', default=False, dest='safe', | |
− | + | help='Perform quick sanity check of exported file using file size.') | |
− | help= | + | actiongroup.add_option('--really-safe', action='store_true', default=False, dest='reallysafe', |
+ | help='Perform slow sanity check of exported file using SHA1 hash.') | ||
+ | actiongroup.add_option("--delete", action="store_true", default=False, | ||
+ | help="Delete source recording after successful export. Enforces use of --safe.") | ||
+ | parser.add_option_group(actiongroup) | ||
+ | |||
+ | othergroup = OptionGroup(parser, "Other Data", | ||
+ | "These options copy additional information from the source recording.") | ||
+ | othergroup.add_option("--seekdata", action="store_true", default=False, dest="seekdata", | ||
+ | help="Copy seekdata from source recording.") | ||
+ | othergroup.add_option("--skiplist", action="store_true", default=False, dest="skiplist", | ||
+ | help="Copy commercial detection from source recording.") | ||
+ | othergroup.add_option("--cutlist", action="store_true", default=False, dest="cutlist", | ||
+ | help="Copy manual commercial cuts from source recording.") | ||
+ | parser.add_option_group(othergroup) | ||
+ | |||
+ | MythLog.loadOptParse(parser) | ||
opts, args = parser.parse_args() | opts, args = parser.parse_args() | ||
Line 439: | Line 442: | ||
print_format() | print_format() | ||
sys.exit(0) | sys.exit(0) | ||
+ | |||
+ | if opts.delete: | ||
+ | opts.safe = True | ||
if opts.chanid and opts.starttime: | if opts.chanid and opts.starttime: | ||
Line 446: | Line 452: | ||
export = VIDEO(opts,int(args[0])) | export = VIDEO(opts,int(args[0])) | ||
except Exception, e: | except Exception, e: | ||
− | Job(int(args[0])).update({'status': | + | Job(int(args[0])).update({'status':Job.ERRORED, |
'comment':'ERROR: '+e.args[0]}) | 'comment':'ERROR: '+e.args[0]}) | ||
+ | MythLog(module='mythvidexport.py').logTB(MythLog.GENERAL) | ||
sys.exit(1) | sys.exit(1) | ||
else: | else: | ||
Line 455: | Line 462: | ||
if opts.tformat: | if opts.tformat: | ||
print "Changing TV format to: "+opts.tformat | print "Changing TV format to: "+opts.tformat | ||
− | db. | + | db.settings[host]['mythvideo.TVexportfmt'] = opts.tformat |
if opts.mformat: | if opts.mformat: | ||
print "Changing Movie format to: "+opts.mformat | print "Changing Movie format to: "+opts.mformat | ||
− | db. | + | db.settings[host]['mythvideo.MOVIEexportfmt'] = opts.mformat |
if opts.gformat: | if opts.gformat: | ||
print "Changing Generic format to: "+opts.gformat | print "Changing Generic format to: "+opts.gformat | ||
− | db. | + | db.settings[host]['mythvideo.GENERICexportfmt'] = opts.gformat |
sys.exit(0) | sys.exit(0) | ||
else: | else: |
Revision as of 16:19, 1 September 2013
Note: The correct title of this article is mythvidexport.py. It appears incorrectly here due to technical restrictions.
Author | Raymond Wagner |
Description | A python script intended to be run as a user job allowing for exporting recordings into MythVideo. |
Supports |
This python script is intended to function as a user job, capable of exporting recordings into MythVideo.
This script can be run from the command line, or called through the mythtv jobqueue. The input format will be: mythvidexport.py [options] <--chanid <channel id>> <--starttime <start time>> --- or --- mythvidexport.py [options] %JOBID% Options are: --mformat <format string> --tformat <format string> --gformat <format string> overrides the stored format string for a single run --listingonly use EPG data rather than grabbers for metadata will still try to grab episode and season information from ttvdb.py --seekdata copy seek data from recording --skiplist copy commercial detection from recording --cutlist copy manual commercial cutlist from recording Additional functions are available beyond exporting video mythvidexport.py <options> -h, --help show this help message -p, --printformat print existing format strings -f, --helpformat lengthy description for formatting strings --mformat <string> replace existing Movie format --tformat <string> replace existing TV format --gformat <string> replace existing Generic format
#!/usr/bin/env python # -*- coding: UTF-8 -*- #--------------------------- # Name: mythvidexport.py # Python Script # Author: Raymond Wagner # Purpose # This python script is intended to function as a user job, run through # mythjobqueue, capable of exporting recordings into MythVideo. #--------------------------- __title__ = "MythVidExport" __author__ = "Raymond Wagner" __version__= "v0.7.5" from MythTV import MythDB, Job, Recorded, Video, VideoGrabber,\ MythLog, MythError, static, MythBE from optparse import OptionParser, OptionGroup from socket import gethostname import os import re import sys import time import hashlib def create_dummy_video(db=None): db = MythDB(db) def hashfile(fd): hasher = hashlib.sha1() while True: buff = fd.read(2**16) if len(buff) == 0: break hasher.update(buff) return hasher.hexdigest() class VIDEO: def __init__(self, opts, jobid=None): if jobid: self.job = Job(jobid) self.chanid = self.job.chanid self.starttime = self.job.starttime self.job.update(status=Job.STARTING) else: self.job = None self.chanid = opts.chanid self.starttime = opts.starttime self.opts = opts self.db = MythDB() self.log = MythLog(module='mythvidexport.py', db=self.db) # load setting strings self.get_format() # prep objects self.rec = Recorded((self.chanid,self.starttime), db=self.db) self.log(MythLog.GENERAL, MythLog.INFO, 'Using recording', '%s - %s' % (self.rec.title.encode('utf-8'), self.rec.subtitle.encode('utf-8'))) self.vid = Video(db=self.db).create({'title':'', 'filename':'', 'host':gethostname()}) # process data self.get_meta() self.get_dest() # bug fix to work around limitation in the bindings where DBDataRef classes # are mapped to the filename at time of Video element creation. since the # filename is specified as blank when the video is created, the markup # handler is not properly initialized self.vid.markup._refdat = (self.vid.filename,) # save file self.copy() if opts.seekdata: self.copy_seek() if opts.skiplist: self.copy_markup(static.MARKUP.MARK_COMM_START, static.MARKUP.MARK_COMM_END) if opts.cutlist: self.copy_markup(static.MARKUP.MARK_CUT_START, static.MARKUP.MARK_CUT_END) self.vid.update() # delete old file if opts.delete: self.rec.delete() def get_format(self): host = self.db.gethostname() # TV Format if self.opts.tformat: self.tfmt = self.opts.tformat elif self.db.settings[host]['mythvideo.TVexportfmt']: self.tfmt = self.db.settings[host]['mythvideo.TVexportfmt'] else: self.tfmt = 'Television/%TITLE%/Season %SEASON%/'+\ '%TITLE% - S%SEASON%E%EPISODEPAD% - %SUBTITLE%' # Movie Format if self.opts.mformat: self.mfmt = self.opts.mformat elif self.db.settings[host]['mythvideo.MOVIEexportfmt']: self.mfmt = self.db.settings[host]['mythvideo.MOVIEexportfmt'] else: self.mfmt = 'Movies/%TITLE%' # Generic Format if self.opts.gformat: self.gfmt = self.opts.gformat elif self.db.settings[host]['mythvideo.GENERICexportfmt']: self.gfmt = self.db.settings[host]['mythvideo.GENERICexportfmt'] else: self.gfmt = 'Videos/%TITLE%' def get_meta(self): self.vid.hostname = self.db.gethostname() if self.rec.inetref: # good data is available, use it if self.rec.season > 0 or self.rec.episode > 0: self.log(self.log.GENERAL, self.log.INFO, 'Performing TV export with local data.') self.type = 'TV' grab = VideoGrabber(self.type) metadata = grab.grabInetref(self.rec.inetref, self.rec.season, self.rec.episode) else: self.log(self.log.GENERAL, self.log.INFO, 'Performing Movie export with local data.') self.type = 'MOVIE' grab = VideoGrabber(self.type) metadata = grab.grabInetref(self.rec.inetref) elif self.opts.listingonly: # force use of local data if self.rec.subtitle: self.log(self.log.GENERAL, self.log.INFO, 'Forcing TV export with local data.') self.type = 'TV' else: self.log(self.log.GENERAL, self.log.INFO, 'Forcing Movie export with local data.') self.type = 'MOVIE' metadata = self.rec.exportMetadata() else: if self.rec.subtitle: # subtitle exists, assume tv show self.type = 'TV' self.log(self.log.GENERAL, self.log.INFO, 'Attempting TV export.') grab = VideoGrabber(self.type) match = grab.sortedSearch(self.rec.title, self.rec.subtitle) else: # assume movie self.type = 'MOVIE' self.log(self.log.GENERAL, self.log.INFO, 'Attempting Movie export.') grab = VideoGrabber(self.type) match = grab.sortedSearch(self.rec.title) if len(match) == 0: # no match found self.log(self.log.GENERAL, self.log.INFO, 'Falling back to generic export.') self.type = 'GENERIC' metadata = self.rec.exportMetadata() elif (len(match) > 1) & (match[0].levenshtein > 0): # multiple matches found, and closest is not exact self.vid.delete() raise MythError('Multiple metadata matches found: '\ +self.rec.title) else: self.log(self.log.GENERAL, self.log.INFO, 'Importing content from', match[0].inetref) metadata = grab.grabInetref(match[0]) self.vid.importMetadata(metadata) self.log(self.log.GENERAL, self.log.INFO, 'Import complete') def get_dest(self): if self.type == 'TV': self.vid.filename = self.process_fmt(self.tfmt) elif self.type == 'MOVIE': self.vid.filename = self.process_fmt(self.mfmt) elif self.type == 'GENERIC': self.vid.filename = self.process_fmt(self.gfmt) def process_fmt(self, fmt): # replace fields from viddata #print self.vid.data ext = '.'+self.rec.basename.rsplit('.',1)[1] rep = ( ('%TITLE%','title','%s'), ('%SUBTITLE%','subtitle','%s'), ('%SEASON%','season','%d'), ('%SEASONPAD%','season','%02d'), ('%EPISODE%','episode','%d'), ('%EPISODEPAD%','episode','%02d'), ('%YEAR%','year','%s'), ('%DIRECTOR%','director','%s')) for tag, data, format in rep: if self.vid[data]: fmt = fmt.replace(tag,format % self.vid[data]) else: fmt = fmt.replace(tag,'') # replace fields from program data rep = ( ('%HOSTNAME%', 'hostname', '%s'), ('%STORAGEGROUP%','storagegroup','%s')) for tag, data, format in rep: data = getattr(self.rec, data) fmt = fmt.replace(tag,format % data) # fmt = fmt.replace('%CARDID%',self.rec.cardid) # fmt = fmt.replace('%CARDNAME%',self.rec.cardid) # fmt = fmt.replace('%SOURCEID%',self.rec.cardid) # fmt = fmt.replace('%SOURCENAME%',self.rec.cardid) # fmt = fmt.replace('%CHANNUM%',self.rec.channum) # fmt = fmt.replace('%CHANNAME%',self.rec.cardid) if len(self.vid.genre): fmt = fmt.replace('%GENRE%',self.vid.genre[0].genre) else: fmt = fmt.replace('%GENRE%','') # if len(self.country): # fmt = fmt.replace('%COUNTRY%',self.country[0]) # else: # fmt = fmt.replace('%COUNTRY%','') return fmt+ext def copy(self): stime = time.time() srcsize = self.rec.filesize htime = [stime,stime,stime,stime] self.log(MythLog.GENERAL|MythLog.FILE, MythLog.INFO, "Copying myth://%s@%s/%s"\ % (self.rec.storagegroup, self.rec.hostname, self.rec.basename)\ +" to myth://Videos@%s/%s"\ % (self.vid.host, self.vid.filename)) srcfp = self.rec.open('r') dstfp = self.vid.open('w', nooverwrite=True) if self.job: self.job.setStatus(Job.RUNNING) tsize = 2**24 while tsize == 2**24: tsize = min(tsize, srcsize - dstfp.tell()) dstfp.write(srcfp.read(tsize)) htime.append(time.time()) rate = float(tsize*4)/(time.time()-htime.pop(0)) remt = (srcsize-dstfp.tell())/rate if self.job: self.job.setComment("%02d%% complete - %d seconds remaining" %\ (dstfp.tell()*100/srcsize, remt)) srcfp.close() dstfp.close() self.vid.hash = self.vid.getHash() self.log(MythLog.GENERAL|MythLog.FILE, MythLog.INFO, "Transfer Complete", "%d seconds elapsed" % int(time.time()-stime)) if self.opts.reallysafe: if self.job: self.job.setComment("Checking file hashes") self.log(MythLog.GENERAL|MythLog.FILE, MythLog.INFO, "Checking file hashes.") srchash = hashfile(self.rec.open('r')) dsthash = hashfile(self.rec.open('r')) if srchash != dsthash: raise MythError('Source hash (%s) does not match destination hash (%s)' \ % (srchash, dsthash)) elif self.opts.safe: self.log(MythLog.GENERAL|MythLog.FILE, MythLog.INFO, "Checking file sizes.") be = MythBE(db=self.vid._db) try: srcsize = be.getSGFile(self.rec.hostname, self.rec.storagegroup, \ self.rec.basename)[1] dstsize = be.getSGFile(self.vid.host, 'Videos', self.vid.filename)[1] except: raise MythError('Could not query file size from backend') if srcsize != dstsize: raise MythError('Source size (%d) does not match destination size (%d)' \ % (srcsize, dstsize)) if self.job: self.job.setComment("Complete - %d seconds elapsed" % \ (int(time.time()-stime))) self.job.setStatus(Job.FINISHED) def copy_seek(self): for seek in self.rec.seek: self.vid.markup.add(seek.mark, seek.offset, seek.type) def copy_markup(self, start, stop): for mark in self.rec.markup: if mark.type in (start, stop): self.vid.markup.add(mark.mark, 0, mark.type) def usage_format(): usagestr = """The default strings are: Television: Television/%TITLE%/Season %SEASON%/%TITLE% - S%SEASON%E%EPISODEPAD% - %SUBTITLE% Movie: Movies/%TITLE% Generic: Videos/%TITLE% Available strings: %TITLE%: series title %SUBTITLE%: episode title %SEASON%: season number %SEASONPAD%: season number, padded to 2 digits %EPISODE%: episode number %EPISODEPAD%: episode number, padded to 2 digits %YEAR%: year %DIRECTOR%: director %HOSTNAME%: backend used to record show %STORAGEGROUP%: storage group containing recorded show %GENRE%: first genre listed for recording """ # %CARDID%: ID of tuner card used to record show # %CARDNAME%: name of tuner card used to record show # %SOURCEID%: ID of video source used to record show # %SOURCENAME%: name of video source used to record show # %CHANNUM%: ID of channel used to record show # %CHANNAME%: name of channel used to record show # %COUNTRY%: first country listed for recording print usagestr def print_format(): db = MythDB() host = gethostname() tfmt = db.settings[host]['mythvideo.TVexportfmt'] if not tfmt: tfmt = 'Television/%TITLE%/Season %SEASON%/%TITLE% - S%SEASON%E%EPISODEPAD% - %SUBTITLE%' mfmt = db.settings[host]['mythvideo.MOVIEexportfmt'] if not mfmt: mfmt = 'Movies/%TITLE%' gfmt = db.settings[host]['mythvideo.GENERICexportfmt'] if not gfmt: gfmt = 'Videos/%TITLE%' print "Current output formats:" print " TV: "+tfmt print " Movies: "+mfmt print " Generic: "+gfmt def main(): parser = OptionParser(usage="usage: %prog [options] [jobid]") formatgroup = OptionGroup(parser, "Formatting Options", "These options are used to display and manipulate the output file formats.") formatgroup.add_option("-f", "--helpformat", action="store_true", default=False, dest="fmthelp", help="Print explination of file format string.") formatgroup.add_option("-p", "--printformat", action="store_true", default=False, dest="fmtprint", help="Print current file format string.") formatgroup.add_option("--tformat", action="store", type="string", dest="tformat", help="Use TV format for current task. If no task, store in database.") formatgroup.add_option("--mformat", action="store", type="string", dest="mformat", help="Use Movie format for current task. If no task, store in database.") formatgroup.add_option("--gformat", action="store", type="string", dest="gformat", help="Use Generic format for current task. If no task, store in database.") formatgroup.add_option("--listingonly", action="store_true", default=False, dest="listingonly", help="Use data from listing provider, rather than grabber") parser.add_option_group(formatgroup) sourcegroup = OptionGroup(parser, "Source Definition", "These options can be used to manually specify a recording to operate on "+\ "in place of the job id.") sourcegroup.add_option("--chanid", action="store", type="int", dest="chanid", help="Use chanid for manual operation") sourcegroup.add_option("--starttime", action="store", type="int", dest="starttime", help="Use starttime for manual operation") parser.add_option_group(sourcegroup) actiongroup = OptionGroup(parser, "Additional Actions", "These options perform additional actions after the recording has been exported.") actiongroup.add_option('--safe', action='store_true', default=False, dest='safe', help='Perform quick sanity check of exported file using file size.') actiongroup.add_option('--really-safe', action='store_true', default=False, dest='reallysafe', help='Perform slow sanity check of exported file using SHA1 hash.') actiongroup.add_option("--delete", action="store_true", default=False, help="Delete source recording after successful export. Enforces use of --safe.") parser.add_option_group(actiongroup) othergroup = OptionGroup(parser, "Other Data", "These options copy additional information from the source recording.") othergroup.add_option("--seekdata", action="store_true", default=False, dest="seekdata", help="Copy seekdata from source recording.") othergroup.add_option("--skiplist", action="store_true", default=False, dest="skiplist", help="Copy commercial detection from source recording.") othergroup.add_option("--cutlist", action="store_true", default=False, dest="cutlist", help="Copy manual commercial cuts from source recording.") parser.add_option_group(othergroup) MythLog.loadOptParse(parser) opts, args = parser.parse_args() if opts.verbose: if opts.verbose == 'help': print MythLog.helptext sys.exit(0) MythLog._setlevel(opts.verbose) if opts.fmthelp: usage_format() sys.exit(0) if opts.fmtprint: print_format() sys.exit(0) if opts.delete: opts.safe = True if opts.chanid and opts.starttime: export = VIDEO(opts) elif len(args) == 1: try: export = VIDEO(opts,int(args[0])) except Exception, e: Job(int(args[0])).update({'status':Job.ERRORED, 'comment':'ERROR: '+e.args[0]}) MythLog(module='mythvidexport.py').logTB(MythLog.GENERAL) sys.exit(1) else: if opts.tformat or opts.mformat or opts.gformat: db = MythDB() host = gethostname() if opts.tformat: print "Changing TV format to: "+opts.tformat db.settings[host]['mythvideo.TVexportfmt'] = opts.tformat if opts.mformat: print "Changing Movie format to: "+opts.mformat db.settings[host]['mythvideo.MOVIEexportfmt'] = opts.mformat if opts.gformat: print "Changing Generic format to: "+opts.gformat db.settings[host]['mythvideo.GENERICexportfmt'] = opts.gformat sys.exit(0) else: parser.print_help() sys.exit(2) if __name__ == "__main__": main()