#!/usr/bin/env python3 # -*- coding: utf-8 -*- ############################################################################## # Find gaps between MythTV recordings. # # Author: J S Worthington ############################################################################## from __future__ import print_function import argparse import datetime import dateutil.parser import sys from MythTV.services_api import send as api py_version = '3' if sys.version_info.major == 2: py_version = '2' try: from MythTV import MythBE except: print('Please install the MythTV Python ' + py_version + ' bindings') exit(2) #try: # import Utilities as api #except: # print('Please install the MythTV Utilities.py file - suggested location: /usr/local/bin.') # print('The Utilities.py file is available as text from here:') # print(' https://www.mythtv.org/wiki/Python_API_Examples') # print('Please ensure that it is saved in UTF-8 format.') # exit(2) try: from enum import IntEnum except: print('Please install the Python ' + py_version + ' enum34 package') exit(2) try: import dateutil.parser except: print('Please install the Python ' + py_version + ' dateutil package') exit(2) """ Version 1.5: - Convert from Python 2 to Python 2 and Python 3 compatible. - Handle exception when unable to connect to mythbackend. This used to return an "Error" value from "send", but now in MythTV v31 and Python 3 raises an exception. Version 1.5.1: """ VERSION = '1.5.1' HOST = 'localhost' PORT = 6544 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) HERE = dateutil.tz.tzlocal() UTC = dateutil.tz.gettz('UTC') ############################################################################## # Round off the microseconds in a naive datetime and zero the microseconds # field. ############################################################################## def round_to_s(dt): microseconds = dt.microsecond if microseconds >= 500000: dt += datetime.timedelta(0, 0, 1000000-microseconds) else: dt -= datetime.timedelta(0, 0, microseconds) return dt ############################################################################## # Convert an aware UTC datetime to a naive UTC datetime. ############################################################################## def naive(dt): return dt.astimezone(dateutil.tz.tzutc()).replace(tzinfo=None) ############################################################################## # Convert a naive UTC datetime to an aware local datetime. ############################################################################## def utc_to_local(dt): return dt.replace(tzinfo=UTC).astimezone(HERE) ############################################################################## # Main ############################################################################## host = HOST port = PORT try: mythtv_bindings_ok = True import MythTV except: mythtv_bindings_ok = False print('Unable to load MythTV Python bindings, and hence unable to read config.xml. Using default settings.') if mythtv_bindings_ok: try: db = MythTV.MythDB() dbc = db.cursor() rows = dbc.execute("select data,hostname from settings where value='BackendStatusPort'") if rows == 0: print('MythTV database does not have BackendStatusPort settings, using defaults') else: row = dbc.fetchone() port = int(row[0]) host = row[1] except: print('Unable to get BackendStatusPort settings from MythTV database, using defaults') #print('host='+host, 'port='+str(port)) parser = argparse.ArgumentParser( description='Find gaps in the MythTV recording schedule (Version: ' + VERSION + ')', formatter_class=lambda prog: argparse.HelpFormatter(prog, max_help_position=15) ) parser.add_argument('-V', '--version', dest='version', default=False, action='store_true', help='display the version number and exit') parser.add_argument('-n', '--host', action='store', help='MythTV backend hostname (default: '+host+')') parser.add_argument('-p', '--port', type=int, action='store', help='MythTV backend API port number (default: '+str(port)+')') parser.add_argument('hours', type=float, nargs='?', default=0.0, help='Minimum gap duration to search for, in hours (floating point allowed)') parser.add_argument('minutes', type=float, nargs='?', default=0.0, help='Minimum gap duration to search for, in minutes (floating point allowed)') args = parser.parse_args() if args.version: print('Version '+VERSION) exit(1) if args.port != None: port = args.port if args.host != None: host = args.host min_duration = datetime.timedelta(0, 0, 0, 0, args.minutes, args.hours) if min_duration != datetime.timedelta(0): print('Searching for a minimum duration of '+str(min_duration)) backend = api.Send(host=host, port=port) try: resp_dict = backend.send(endpoint='Dvr/GetUpcomingList') except RuntimeError: print('Connection to backend failed - is mythbackend running?') exit(2) if list(resp_dict.keys())[0] in ['Abort', 'Warning']: print('GetUpcomingList failed') exit(2) pending = resp_dict['ProgramList']['Programs'] # pending is in StartTs order. The following code relies on this. pending_count = int(resp_dict['ProgramList']['Count']) if pending_count == 0: print('No recordings scheduled!') exit(0) furthest_end = round_to_s(datetime.datetime.utcnow()) #print('furthest_end='+str(utc_to_local(furthest_end))) i = 0 recording = pending[i]['Recording'] while int(recording['Status']) == RecStatus.Recording: endts = naive(dateutil.parser.parse(recording['EndTs'])) #print('1 endts='+str(utc_to_local(endts))) if endts > furthest_end: furthest_end = endts; #print('1 furthest_end='+str(utc_to_local(furthest_end))) i += 1 if i >= pending_count: break recording = pending[i]['Recording'] #print("1 recording['Status']="+recording['Status']) while i < pending_count: while i < pending_count: if int(recording['Status']) == RecStatus.WillRecord: willrecord = 'WillRecord' else: willrecord='' startts = naive(dateutil.parser.parse(recording['StartTs'])) #print("2 startts="+str(utc_to_local(startts)), willrecord) if int(recording['Status']) == RecStatus.WillRecord: if naive(dateutil.parser.parse(recording['StartTs'])) > furthest_end: break endts = naive(dateutil.parser.parse(recording['EndTs'])) #print(' 2 endts='+str(utc_to_local(endts))) if endts > furthest_end: furthest_end = endts #print(' 2 furthest_end='+str(utc_to_local(furthest_end))) i += 1 if i >= pending_count: break recording = pending[i]['Recording'] if i >= pending_count: break endts = naive(dateutil.parser.parse(recording['EndTs'])) duration = startts - furthest_end if duration >= min_duration: local_furthest_end = utc_to_local(furthest_end) print('Gap: start='+local_furthest_end.strftime("%a")+' '+str(local_furthest_end)+' end='+str(utc_to_local(startts))+' duration='+str(duration)) furthest_end = endts print('End of scheduled recordings end='+str(utc_to_local(furthest_end))) exit(0) # Example of the dictionary data returned by Dvr/GetUpcomingList API call via Utilities.py: #{u'Category': u'Education/Science/Factual', u'SubProps': u'0', u'CatType': u'series', u'Title': u'24 Hours In A&E', u'LastModified': u'2017-04-18T14:39:29Z', u'Stars': u'0', u'Episode': u'0', u'EndTime': u'2017-04-18T19:25:00Z', u'Channel': {u'Programs': [], u'ChanFilters': u'', u'UseEIT': u'false', u'Visible': u'true', u'IconURL': u'', u'SourceId': u'6', u'FineTune': u'0', u'Format': u'Default', u'ATSCMajorChan': u'0', u'MplexId': u'0', u'ChanNum': u'4074', u'ChanId': u'10074', u'CommFree': u'false', u'CallSign': u'KNOWLGE', u'InputId': u'0', u'DefaultAuth': u'', u'ServiceId': u'1059', u'FrequencyId': u'', u'ChannelName': u'BBC Knowledge', u'ATSCMinorChan': u'0', u'XMLTVID': u'documentary.sky.co.nz'}, u'SeriesId': u'1490837', u'Repeat': u'false', u'Description': u'54-year-old epileptic Mike arrives in minors with a bandage round his jaw after falling face first onto a pavement.', u'Season': u'0', u'HostName': u'mypvr', u'ProgramFlags': u'0', u'SubTitle': u'Always On My Mind', u'VideoProps': u'0', u'TotalEpisodes': u'0', u'Artwork': {u'ArtworkInfos': []}, u'Recording': {u'Status': u'-1', u'RecordedId': u'0', u'EncoderName': u'SAT>IP 1.1 (90)', u'RecGroup': u'CRW and JSW', u'DupMethod': u'8', u'RecordId': u'11099', u'LastModified': u'2017-04-18T14:39:29Z', u'HostName': u'mypvr', u'StartTs': u'2017-04-18T18:35:00Z', u'FileName': u'', u'Priority': u'0', u'EncoderId': u'90', u'Profile': u'Default', u'StorageGroup': u'Default', u'EndTs': u'2017-04-18T19:30:00Z', u'DupInType': u'15', u'RecType': u'4', u'PlayGroup': u'Default', u'FileSize': u'0'}, u'Cast': {u'CastMembers': []}, u'Inetref': u'', u'Airdate': u'', u'AudioProps': u'0', u'FileName': u'', u'ProgramId': u'', u'FileSize': u'0', u'StartTime': u'2017-04-18T18:35:00Z'}