#!/usr/bin/env python3 # -*- coding: utf-8 -*- #--------------------------- # Name: mythtv_recording_rules #--------------------------- __title__ = "mythtv_recording_rules" __version__= "v0.0.5" ''' Create a new recording rule for the title passed on the command line. Use: mythtv_recording_rules.py --help to get started. The --title must match a program name exactly and it will also become the name of the new recording rule. If the rule already exists, the program will abort. This is pep8 and pylint compliant. Tested using Python 3. ''' #from __future__ import print_function #from __future__ import absolute_import from datetime import tzinfo, timedelta, datetime, timezone import argparse import json import logging import sys import re try: from mythtv_services_api import (send as api, utilities as util) except ImportError: sys.exit('See: github.com/billmeek/MythTVServicesAPI/blob/master/dist\n') weekdayafter = (lambda date, day: date + timedelta(days = (day - date.weekday() + 7) % 7)) TYPES = ( "Single Record", "Record All", "Record One", "Record Daily", "Record Weekly", ) def process_command_line(): '''All command line processing is done here.''' parser = argparse.ArgumentParser(description='Add a recording rule', epilog='Default values are in ()s') mandatory = parser.add_argument_group('required arguments') parser.add_argument('--debug', action='store_true', help='turn on debug messages (%(default)s)') parser.add_argument('--digest', type=str, metavar='<user:pass>', help='digest username:password (%(default)s)') mandatory.add_argument('--host', type=str, required=True, metavar='<hostname>', help='backend hostname') parser.add_argument('--port', type=int, default=6544, metavar='<port>', help='port number of the Services API (%(default)s)') parser.add_argument('--quiet', action='store_true', help='suppress progress messages (%(default)s)') parser.add_argument('--template', type=str, required=False, default='Default', metavar='<temp>', help='template name, (%(default)s)') mandatory.add_argument('--title', type=str, required=False, metavar='<title>', help='full program name, no wild cards/regex') parser.add_argument('--sources', action='store_true', help='List configured video sources (%(default)s)') parser.add_argument('--channels', type=int, required=False, metavar="<soruceid>", help='List configured channels for sourceid (%(default)s)') parser.add_argument('--chanid', type=str, required=False, metavar="<chanid>", help='Record on this channel') parser.add_argument('--manual', action='store_true', help='Create manual record rule (%(default)s)') parser.add_argument('--datetime', type=str, required=False, metavar="<datetime>", help='Manual record start datetime ' 'in ISO format. i.e. "2018-08-05T05:00:00" ' '(%(default)s))') parser.add_argument('--duration', type=str, required=False, metavar="<duration>", default = (60 * 60), help='Manual record duration in seconds (%(default)s)') values = ', '.join(TYPES) parser.add_argument('--type', type=str, required=False, choices=(TYPES), default='Record All', metavar='<type>', help='Record <type> [{}] (%(default)s)'.format(values)) parser.add_argument('--version', action='version', version='%(prog)s 0.11') parser.add_argument('--wrmi', action='store_true', help='allow data to be changed (%(default)s)') return vars(parser.parse_args()) def setup(backend, opts): ''' Make sure the backend is up (GetHostName) and then set the backend's UTC offset for other methods to use. ''' try: backend.send(endpoint='Myth/GetHostName', opts=opts) int(util.get_utc_offset(backend=backend, opts=opts)) except ValueError: sys.exit('\nAbort, non integer response from get_utc_offset.') except RuntimeError as error: sys.exit('\nAbort on fatal API error: "{}"'.format(error)) def get_sources(backend, args): ''' See: https://www.mythtv.org/wiki/Channel_Service#GetVideoSourceList ''' endpoint = 'Channel/GetVideoSourceList' try: resp_dict = backend.send(endpoint=endpoint) except RuntimeError as error: sys.exit('\nFatal error: "{}"'.format(error)) if args['debug']: print(json.dumps(resp_dict['VideoSoruceList'], sort_keys=True, indent=4, separators=(',', ': '))) return resp_dict['VideoSourceList']['VideoSources'] def schedule_already_exists(backend, args, opts): ''' See if there's already a rule for the title. ''' endpoint = 'Dvr/GetRecordScheduleList' try: resp_dict = backend.send(endpoint=endpoint, opts=opts) except RuntimeError as error: sys.exit('\nAbort, Get Existing Rule: Fatal error; "{}"'.format(error)) if int(resp_dict['RecRuleList']['Count']) < 1: sys.exit('\nAbort, no recording rules found.\n') for rule in resp_dict['RecRuleList']['RecRules']: if rule['Title'] == args['title']: if args['debug']: print(json.dumps(rule, sort_keys=True, indent=4, separators=(',', ': '))) return True return False def get_template(backend, args, opts): ''' Gets the requested (or default) template. This will be modified with guide data for the title of interest, then send to the backend in a POST. Misspelled template names return the Default template. Only the template name is used, not the trailing: (Template) text. ''' endpoint = 'Dvr/GetRecordSchedule' rest = 'Template={}'.format(args['template']) try: resp_dict = backend.send(endpoint=endpoint, rest=rest, opts=opts) except RuntimeError as error: sys.exit('\nAbort, Get Template: Fatal error; "{}"'.format(error)) if args['debug']: print(json.dumps(resp_dict['RecRule'], sort_keys=True, indent=4, separators=(',', ': '))) # Templates are always Id -1, just double checking here... if resp_dict['RecRule']['Id'] != '-1': return None return resp_dict['RecRule'] def get_program_data(backend, args, opts): ''' Find matching program(s) from the guide. Note that if --title=Blah, then any title with the string Blah in it will be returned by GetProgramList. ''' endpoint = 'Guide/GetProgramList' rest = 'Details=True&TitleFilter={}'.format(args['title']) try: resp_dict = backend.send(endpoint=endpoint, rest=rest, opts=opts) except RuntimeError as error: sys.exit('\nAbort, Get Upcoming: Fatal error; "{}"'.format(error)) count = int(resp_dict['ProgramList']['TotalAvailable']) if args['debug']: print('\nDebug: Programs matching --title {} = {}' .format(args['title'], count)) if count < 1: sys.exit('\nAbort, No programs in the guide matching: {}.\n' .format(args['title'])) for program in resp_dict['ProgramList']['Programs']: if args['debug']: print('Comparing {} to {}'.format(args['title'], program['Title'])) if program['Title'] == args['title']: if args['debug']: print(json.dumps(program, sort_keys=True, indent=4, separators=(',', ': '))) return program continue return None def update_template(template, guide_data, args): ''' Put selected guide information into the template to be sent as postdata for the new rule. ''' try: template['StartTime'] = guide_data['StartTime'] template['EndTime'] = guide_data['EndTime'] template['Title'] = guide_data['Title'] template['Type'] = args['type'] template['Station'] = guide_data['Channel']['CallSign'] template['ChanId'] = guide_data['Channel']['ChanId'] template['SearchType'] = 'None' template['Category'] = guide_data['Category'] template['SeriesId'] = guide_data['SeriesId'] template['FindTime'] = util.create_find_time(guide_data['StartTime']) template['Description'] = 'Rule created by add_recording_rule.py' except KeyError: return False return True def add_rule(backend, template, args, opts): ''' Send the changed data to the backend. ''' endpoint = 'Dvr/AddRecordSchedule' params_not_sent = ('AverageDelay', 'CallSign', 'Id', 'LastDeleted', 'LastRecorded', 'NextRecording', 'ParentId') for param in params_not_sent: try: del template[param] except KeyError: pass opts['wrmi'] = args['wrmi'] try: resp_dict = backend.send(endpoint=endpoint, postdata=template, opts=opts) except RuntimeWarning as error: sys.exit('Abort, Unable to add rule: {}. Warning was: {}.' .format(template['Title'], error)) except RuntimeError as error: sys.exit('\nAbort, Fatal API Error response: {}\n'.format(error)) opts['wrmi'] = False if isinstance(resp_dict, dict) and isinstance(resp_dict['uint'], str): recording_rule = int(resp_dict['uint']) if recording_rule < 4294967295: vprint('\nAdded: "{}" (RecordId {}).' .format(template['Title'], recording_rule), args) else: recording_rule = -1 vprint('Backend failed to add: "{}" (RecordId {}).' .format(template['Title'], recording_rule), args) return False else: vprint('Expected a "uint: int" dictionary response, but got {}' .format(resp_dict), args) return False return True def record_title(backend, args, opts): template = get_template(backend, args, opts) if not template: sys.exit('\nAbort, no template found for: {}.'.format(args['title'])) guide_data = get_program_data(backend, args, opts) if not guide_data: sys.exit('\nAbort, no match in guide for: {}'.format(args['title'])) if update_template(template, guide_data, args): add_rule(backend, template, args, opts) else: sys.exit('\nAbort, error while copying guide data to template.') def get_channels(backend, sourceid): ''' See: https://www.mythtv.org/wiki/Channel_Service#GetChannelInfoList ''' endpoint = 'Channel/GetChannelInfoList' rest = 'SoruceID={}&OnlyVisible=true&Details=true'.format(sourceid) try: resp_dict = backend.send(endpoint=endpoint, rest=rest) except RuntimeError as error: sys.exit('\nFatal error: "{}"'.format(error)) return resp_dict['ChannelInfoList']['ChannelInfos'] def get_channel(backend, chanid): ''' See: https://www.mythtv.org/wiki/Channel_Service#GetChannelInfo ''' endpoint = 'Channel/GetChannelInfo' rest = 'ChanID={}&OnlyVisible=true&Details=true'.format(chanid) try: resp_dict = backend.send(endpoint=endpoint, rest=rest) except RuntimeError as error: sys.exit('\nFatal error: "{}"'.format(error)) return resp_dict['ChannelInfo'] def record_manual_type(backend, args, opts, type, chaninfo, template, starttime, duration): if not starttime: sys.exit('\nAbort, manul record: no starttime provided.') localtz = datetime.now().astimezone().tzinfo # Convert to UTC start = starttime.replace(tzinfo=localtz).astimezone(tz=timezone.utc) end = starttime + timedelta(seconds=duration) end = end.replace(tzinfo=localtz).astimezone(tz=timezone.utc) template['StartTime'] = "{}".format(start.isoformat() .replace('+00:00', 'Z')) template['EndTime'] = "{}".format(end.isoformat() .replace('+00:00', 'Z')) template['Description'] = ('{} (Manual Record)' .format(starttime.strftime('%H'))) template['FindTime'] = starttime.strftime('%H:%M:%S') template['Type'] = type template['Title'] = args['title'] template['Station'] = chaninfo['CallSign'] template['CallSign'] = chaninfo['CallSign'] print("{}\n".format(template)) return add_rule(backend, template, args, opts) def record_manual_24x7(backend, args, opts, chaninfo, rec24x7): localtz = datetime.now().astimezone().tzinfo # First Saturday in July, resuling in 7-day/week schedule saturday = weekdayafter(datetime(2018, 7, 1, tzinfo=localtz), 5) duration = 60 * 60 for hour in list(range(24)): start = saturday.replace(hour=hour) rec24x7['SubTitle'] = 'hour {}'.format(start.strftime('%H')) if not record_manual_type(backend, args, opts, 'Record Daily', chaninfo, rec24x7, start, duration): return False return True def record_manual(backend, args, opts): template = get_template(backend, args, opts) if not template: sys.exit('\nAbort, no template found for: {}.'.format(args['template'])) if not args['chanid']: sys.exit('\nAbort, no chanid provided for manual record.') template['ChanId'] = args['chanid'] template['SearchType'] = 'Manual Search' template['Category'] = '' template['SeriesId'] = '' chaninfo = get_channel(backend, args['chanid']) if not chaninfo: print('Channel ID {} not found in available channels.' .format(args['chanid'])) return False if (args['type'] == 'Record All'): record_manual_24x7(backend, args, opts, chaninfo, template) else: record_manual_type(backend, args, opts, args['type'], chaninfo, template, args['datetime'], args['duration']) def vprint(message, args): ''' Verbose Print: print recording rule information unless --quiet was used. Not fully implemented, as there are still lots of print()s here. The intention is that if run out of some other program, this will can remain quiet. sys.exit()s will return 1 for failures. This may get expanded to put messages in a log... ''' if not args['quiet']: print(message) def main(): ''' The primary job of main is to get the arguments from the command line, setup logging (and possibly) handle the digest user/password then: • Create an instance of the Send class • See if a rule exists for --title • Get the selected template • Get data for command line title from the guide • Update the template with the guide data • Add the rule on the backend. ''' args = process_command_line() opts = dict() logging.basicConfig(level=logging.DEBUG if args['debug'] else logging.INFO) logging.getLogger('requests.packages.urllib3').setLevel(logging.WARNING) logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING) try: opts['user'], opts['pass'] = args['digest'].split(':', 1) except (AttributeError, ValueError): pass backend = api.Send(host=args['host'], port=args['port']) setup(backend, opts) if args['sources']: sources = get_sources(backend, args) for source in sources: print('{}: {}'.format(source['Id'], source['SourceName'])) elif args['channels']: for channels in get_channels(backend, args['channels']): print('{0:>6}: {1:>5} {2:10} {3}'.format(channels['ChanId'], channels['ChanNum'], channels['CallSign'], channels['ChannelName'])) else: if schedule_already_exists(backend, args, opts): sys.exit('\nAbort, rule for: {} already exists.' .format(args['title'])) if (int(args['manual']) > 0): record_manual(backend, args, opts) else: record_title(backend, args, opts) if __name__ == '__main__': main() # vim: set expandtab tabstop=4 shiftwidth=4 smartindent noai colorcolumn=80: