#!/usr/bin/env python3 # -*- coding: utf-8 -*- ############################################################################## # epgdiff -- MythTV EPG differences utility # # Lists the programmes in the EPG data that are present in future EPG data but # not in the past EPG data. # # Author: J S Worthington # Created: 2021-10-24 ############################################################################## import argparse import datetime import MythTV import os import pprint import sys from enum import Enum,IntEnum import MythTV.services_api.send as api import MythTV.services_api.utilities as util program_name = os.path.basename(sys.argv[0]) ############################################################################## # # Version: 0.1 2021-10-26 # ############################################################################## VERSION = '0.1' ############################################################################## # Configuration ############################################################################## # Set this to True get debug output. #DEBUG_OUTPUT = True DEBUG_OUTPUT = False # List of channel numbers (channel.channum) to do EPG diffs on. #DEFAULT_CHANNUM_LIST = [17, 4005, 4009, 4018, 4071, 4075, 4076, 4083, 4210] DEFAULT_CHANNUM_LIST = [] # List of chanids (channel.chanid) to do EPG diffs on. DEFAULT_CHANID_LIST = [] ############################################################################## # Constants ############################################################################## CONFIG_XML = '/etc/mythtv/config.xml' # The IP address of the mythbackend to talk to. Default: 127.0.0.1 HOST = '127.0.0.1' ############################################################################## # Debug output. ############################################################################## if DEBUG_OUTPUT: global epgdiff_debug epgdiff_debug = None def dprint_init(): global epgdiff_debug if epgdiff_debug == None: epgdiff_debug = open(program_name + '-debug.log', 'w', 1) dprint(program_name + ' debug output started') def dprint(s): global epgdiff_debug if epgdiff_debug != None: print(datetime.datetime.now().strftime('%H:%M:%S.%f')[:-3] + ' ' + s, file=epgdiff_debug) else: def dprint_init(): pass def dprint(s): pass dprint_init() ############################################################################## # Recording status. ############################################################################## class RecStatus(IntEnum): Pending = -15 Failing = -14 #OtherRecording = -13 (obsolete) #OtherTuning = -12 (obsolete) MissedFuture = -11 Tuning = -10 Failed = -9 TunerBusy = -8 LowDiskSpace = -7 Cancelled = -6 Missed = -5 Aborted = -4 Recorded = -3 Recording = -2 WillRecord = -1 Unknown = 0 DontRecord = 1 PreviousRecording = 2 CurrentRecording = 3 EarlierShowing = 4 TooManyRecordings = 5 NotListed = 6 Conflict = 7 LaterShowing = 8 Repeat = 9 Inactive = 10 NeverRecord = 11 Offline = 12 #OtherShowing = 13 (obsolete) ############################################################################## # Status of a title in the upcoming recordings list. ############################################################################## class RecStatus2(IntEnum): WillNotRecord = 0 WillRecord = 1 LaterShowing = 2 Conflict = 3 NotListed = 4 BadRecording = 5 def __str__(self): return ' RLCNB'[int(self)] def map_RecStatus(status: RecStatus) -> RecStatus2: if status == RecStatus.Conflict: return RecStatus2.Conflict elif status == RecStatus.LaterShowing: return RecStatus2.LaterShowing elif status == RecStatus.NotListed: return RecStatus2.NotListed elif int(status) <= RecStatus.Aborted: return RecStatus2.BadRecording elif int(status) < 0: return RecStatus2.WillRecord else: return RecStatus2.WillNotRecord def update_RecStatus(status: RecStatus, old_status:RecStatus2) -> RecStatus2: status2 = map_RecStatus(status) if status2 > old_status: return status2 else: return old_status ############################################################################## # Backend class. Handles communication with a backend and database selected # using a config.xml file. If no config.xml file is provided, the local # database and backend are used. ############################################################################## class Backend(): def __init__(self, config_xml=''): dprint('config_xml="' + config_xml + '"') self.config_xml = config_xml if self.config_xml != '': self.remote_config = ConfigXml() if not self.remote_config.readXML(self.config_xml): dprint('Failed to read or correctly parse ' + self.config_xml + ' file, using ' + HOST) self.config_xml = '' self.host = HOST # dprint(str(self.remote_config.DBHostName)) # dprint(str(self.remote_config.DBUsername)) # dprint(str(self.remote_config.DBPassword)) # dprint(str(self.remote_config.DBName)) # dprint(str(self.remote_config.DBPort)) self.host = self.remote_config.DBHostName dprint('self.host = ' + self.host) else: self.host = HOST self.backend = api.Send(self.host) # Initialise the UTCOffset so that the conversion to local time works. api.Send(self.host) util.get_utc_offset(self.backend) # Connect to the database. if self.config_xml != '': try: self.db = MythTV.MythDB( DBHostName = self.remote_config.DBHostName, DBUsername = self.remote_config.DBUsername, DBPassword = self.remote_config.DBPassword, DBName = self.remote_config.DBName, DBPort = self.remote_config.DBPort ) except MythTV.exceptions.MythDBError as e: if e.ecode == MythTV.MythError.DB_SCHEMAMISMATCH: dprint('Schema mismatch connecting to ' + self.host + ' database, trying again with schema ' + str(e.remote)) MythTV.MythDB._schema_local = e.remote self.db = MythTV.MythDB( DBHostName = self.remote_config.DBHostName, DBUsername = self.remote_config.DBUsername, DBPassword = self.remote_config.DBPassword, DBName = self.remote_config.DBName, DBPort = self.remote_config.DBPort ) dprint('After retry') global abort_request dprint('Backend: abort_request=' + str(abort_request)) else: raise MythTV.MythSchema._schema_local = MythTV.SCHEMA_VERSION else: self.db = MythTV.MythDB() self.dbc = self.db.cursor() def get_upcoming_recordings(self): try: resp_dict = self.backend.send(endpoint='Dvr/GetUpcomingList?ShowAll=True') except: dprint(self.host + ' get_upcoming_recordings(): Exception') return [] if list(resp_dict.keys())[0] in ['Abort', 'Warning']: #sys.exit('\n{}\n'.format(list(resp_dict.values())[0])) dprint(self.host + ' get_upcoming_recordings(): Abort or Warning') dprint(str(resp_dict)) progs = [] else: progs = resp_dict['ProgramList']['Programs'] return progs ############################################################################## # Main ############################################################################## parser = argparse.ArgumentParser( description='MythTV EPG Diff (Version: ' + VERSION + ')', formatter_class=lambda prog: argparse.HelpFormatter(prog, max_help_position=15) ) parser.add_argument('-A', '--all-channels', dest='all_channels', default=False, action='store_true', help='Ignore chanid and channum lists and diff all chanids that have EPG data') parser.add_argument('-V', '--version', dest='version', default=False, action='store_true', help='Display the version number and exit') parser.add_argument('-a', '--all', dest='all', default=False, action='store_true', help='Display all new programmes (including those that match recording rules)' + ' - also displays recording status for programmes that match recording rules' ) parser.add_argument('-d', '--dump-upcoming', dest='dump_upcoming', default=False, action='store_true', help='Dump the upcoming recordings data (debug option)') parser.add_argument('-c', '--config', type=str, action='store', default='', help='Location of MythTV config.xml file (default: /etc/mythtv/config.xml)') parser.add_argument('-f', '--fast', dest='fast', default=False, action='store_true', help='Do not fetch the upcoming recordings list, which takes a long time. ' + 'This will cause all new programmes to be listed, regardless of their matching existing recording rules.' ) parser.add_argument('-i', '--chanids', nargs='+', type=int, dest='chanids', action='store', default=[], help='Space separated list of chanid values. Can be used with -n') parser.add_argument('-n', '--channums', nargs='+', type=int, dest='channums', action='store', default=[], help='Space separated list of chanid values. Can be used with -i') args = parser.parse_args() if args.version: print('Version '+VERSION) exit(1) be = Backend(args.config) if not args.fast: upcoming = be.get_upcoming_recordings() rec_status = {} if upcoming==[]: print('No upcoming recordings') elif args.dump_upcoming: print('Upcoming recordings = ') pprint.pprint(upcoming) rec_status_count = {} for recording in upcoming: status = recording['Recording']['Status'] title = recording['Channel']['ChanId'] + '|' + recording['Title'] count = rec_status_count.get(status) if count == None: rec_status_count[status] = 1 else: rec_status_count[status] = count + 1 old_status = rec_status.get(title) if old_status == None: rec_status[title] = map_RecStatus(status) else: rec_status[title] = update_RecStatus(status, old_status) for key, value in rec_status_count.items(): print(key, RecStatus(int(key)), value) channum_chanids = [] if args.all_channels: args.chanids = [] rows = be.dbc.execute("SELECT DISTINCT chanid FROM program ORDER BY chanid;") for chanid_row in be.dbc: args.chanids.append(chanid_row[0]) else: if args.chanids == [] and args.channums == []: args.channums = DEFAULT_CHANNUM_LIST args.chanids = DEFAULT_CHANID_LIST for channum in args.channums: rows = be.dbc.execute("SELECT chanid FROM channel WHERE channum={};".format(channum)) for chanid_row in be.dbc: channum_chanids.append(chanid_row[0]) for chanid in channum_chanids + args.chanids: rows = be.dbc.execute("SELECT chanid,channum,callsign,name FROM channel WHERE chanid={};".format(chanid)) row = be.dbc.fetchone() if row == None: print('Invalid chanid: ' + str(chanid)) else: (chanid, channum2, callsign, name) = row print(chanid, '-', channum2, '-', callsign, '-', name) sql = """ select distinct title from program where chanid={} and starttime >= utc_timestamp() and title not in (select distinct title from program where chanid={} and starttime < utc_timestamp()) order by title; """.format(chanid, chanid) rows = be.dbc.execute(sql) for fetched in be.dbc: title = fetched[0] if args.fast: print(' ' + title) else: found_status = rec_status.get(str(chanid) + '|' + title, '') if args.all: print(' ' + title + ' ' + "'" + str(found_status) + "'") else: if found_status == '': print(' ' + title) exit(0)