Advertisement
Guest User

mythtv_record.py

a guest
Feb 2nd, 2023
68
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 17.12 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. # ---------------------------
  4. # Name: mythtv_recording_rules
  5. # ---------------------------
  6.  
  7. '''
  8. Create a new recording rule for the title passed on the command line.
  9.  
  10. Use: mythtv_recording_rules.py --help to get started.
  11.  
  12. The --title <title> must match a program name exactly and it will also
  13. become the name of the new recording rule. If the rule already exists,
  14. the program will abort.
  15.  
  16. This is pep8 and pylint compliant. Tested using Python 3.
  17. '''
  18.  
  19. from __future__ import print_function
  20. import argparse
  21. import json
  22. import logging
  23. import sys
  24. from datetime import timedelta, datetime, timezone
  25. from MythTV.services_api import (send as api, utilities as util)
  26.  
  27. __title__ = "mythtv_recording_rules"
  28. __version__ = "v0.0.6"
  29.  
  30. WEEKDAYAFTER = (lambda date, day: date +
  31. timedelta(days=(day - date.weekday() + 7) % 7))
  32.  
  33. TYPES = (
  34. "Single Record",
  35. "Record All",
  36. "Record One",
  37. "Record Daily",
  38. "Record Weekly",
  39. )
  40.  
  41.  
  42. def process_command_line():
  43. '''All command line processing is done here.'''
  44.  
  45. parser = argparse.ArgumentParser(description='Add a recording rule',
  46. epilog='Default values are in ()s')
  47.  
  48. mandatory = parser.add_argument_group('required arguments')
  49.  
  50. parser.add_argument('--debug', action='store_true',
  51. help='turn on debug messages (%(default)s)')
  52.  
  53. parser.add_argument('--digest', type=str, metavar='<user:pass>',
  54. help='digest username:password (%(default)s)')
  55.  
  56. mandatory.add_argument('--host', type=str, required=True,
  57. metavar='<hostname>', help='backend hostname')
  58.  
  59. mandatory.add_argument('--title', type=str, required=False,
  60. metavar='<title>',
  61. help='full program name, no wild cards/regex')
  62.  
  63. parser.add_argument('--port', type=int, default=6544, metavar='<port>',
  64. help='port number of the Services API (%(default)s)')
  65.  
  66. parser.add_argument('--quiet', action='store_true',
  67. help='suppress progress messages (%(default)s)')
  68.  
  69. parser.add_argument('--template', type=str, required=False,
  70. default='Default', metavar='<temp>',
  71. help='template name, (%(default)s)')
  72.  
  73. parser.add_argument('--sources', action='store_true',
  74. help='List configured video sources (%(default)s)')
  75.  
  76. parser.add_argument('--channels', type=int, required=False,
  77. metavar="<soruceid>",
  78. help='List configured channels for sourceid '
  79. '(%(default)s)')
  80.  
  81. parser.add_argument('--chanid', type=str, required=False,
  82. metavar="<chanid>",
  83. help='Record on this channel')
  84.  
  85. parser.add_argument('--manual', action='store_true',
  86. help='Create manual record rule (%(default)s)')
  87.  
  88. parser.add_argument('--datetime', type=str, required=False,
  89. metavar="<datetime>",
  90. help='Manual record start datetime '
  91. 'in ISO format. i.e. "2018-08-05T05:00:00" '
  92. '(%(default)s))')
  93.  
  94. parser.add_argument('--duration', type=str, required=False,
  95. metavar="<duration>", default=(60 * 60),
  96. help='Manual record duration in seconds (%(default)s)')
  97.  
  98. values = ', '.join(TYPES)
  99. parser.add_argument('--type', type=str, required=False, choices=(TYPES),
  100. default='Record All', metavar='<type>',
  101. help='Record <type> [{}] (%(default)s)'.format(values))
  102.  
  103. parser.add_argument('--version', action='version', version='%(prog)s {}'
  104. .format(__version__))
  105.  
  106. parser.add_argument('--wrmi', action='store_true',
  107. help='allow data to be changed (%(default)s)')
  108.  
  109. return vars(parser.parse_args())
  110.  
  111.  
  112. def setup(backend, opts):
  113. '''
  114. Make sure the backend is up (GetHostName) and then set the backend's UTC
  115. offset for other methods to use.
  116. '''
  117.  
  118. try:
  119. backend.send(endpoint='Myth/GetHostName', opts=opts)
  120. int(util.get_utc_offset(backend=backend, opts=opts))
  121. except ValueError:
  122. sys.exit('\nAbort, non integer response from get_utc_offset.')
  123. except RuntimeError as error:
  124. sys.exit('\nAbort on fatal API error: "{}"'.format(error))
  125.  
  126.  
  127. def get_sources(backend, args):
  128. '''
  129. See: https://www.mythtv.org/wiki/Channel_Service#GetVideoSourceList
  130. '''
  131. endpoint = 'Channel/GetVideoSourceList'
  132.  
  133. try:
  134. resp_dict = backend.send(endpoint=endpoint)
  135. except RuntimeError as error:
  136. sys.exit('\nFatal error: "{}"'.format(error))
  137.  
  138. if args['debug']:
  139. print(json.dumps(resp_dict['VideoSoruceList'],
  140. sort_keys=True, indent=4, separators=(',', ': ')))
  141.  
  142. return resp_dict['VideoSourceList']['VideoSources']
  143.  
  144.  
  145. def schedule_already_exists(backend, args, opts):
  146. '''
  147. See if there's already a rule for the title.
  148. '''
  149.  
  150. endpoint = 'Dvr/GetRecordScheduleList'
  151.  
  152. try:
  153. resp_dict = backend.send(endpoint=endpoint, opts=opts)
  154. except RuntimeError as error:
  155. sys.exit('\nAbort, Get Existing Rule: Fatal error; "{}"'.format(error))
  156.  
  157. if int(resp_dict['RecRuleList']['Count']) < 1:
  158. sys.exit('\nAbort, no recording rules found.\n')
  159.  
  160. for rule in resp_dict['RecRuleList']['RecRules']:
  161. if rule['Title'] == args['title']:
  162. if args['debug']:
  163. print(json.dumps(rule, sort_keys=True, indent=4,
  164. separators=(',', ': ')))
  165.  
  166. return True
  167.  
  168. return False
  169.  
  170.  
  171. def get_template(backend, args, opts):
  172. '''
  173. Gets the requested (or default) template. This will be modified
  174. with guide data for the title of interest, then send to the
  175. backend in a POST. Misspelled template names return the Default
  176. template.
  177.  
  178. Only the template name is used, not the trailing: (Template) text.
  179. '''
  180.  
  181. endpoint = 'Dvr/GetRecordSchedule'
  182. rest = 'Template={}'.format(args['template'])
  183.  
  184. try:
  185. resp_dict = backend.send(endpoint=endpoint, rest=rest, opts=opts)
  186. except RuntimeError as error:
  187. sys.exit('\nAbort, Get Template: Fatal error; "{}"'.format(error))
  188.  
  189. if args['debug']:
  190. print(json.dumps(resp_dict['RecRule'], sort_keys=True, indent=4,
  191. separators=(',', ': ')))
  192.  
  193. # Templates are always Id -1, just double checking here...
  194. if resp_dict['RecRule']['Id'] != '-1':
  195. return None
  196.  
  197. return resp_dict['RecRule']
  198.  
  199.  
  200. def get_program_data(backend, args, opts):
  201. '''
  202. Find matching program(s) from the guide. Note that if --title=Blah,
  203. then any title with the string Blah in it will be returned by
  204. GetProgramList.
  205. '''
  206.  
  207. endpoint = 'Guide/GetProgramList'
  208. rest = 'Details=True&TitleFilter={}'.format(args['title'])
  209.  
  210. try:
  211. resp_dict = backend.send(endpoint=endpoint, rest=rest, opts=opts)
  212. except RuntimeError as error:
  213. sys.exit('\nAbort, Get Upcoming: Fatal error; "{}"'.format(error))
  214.  
  215. count = int(resp_dict['ProgramList']['TotalAvailable'])
  216.  
  217. if args['debug']:
  218. print('\nDebug: Programs matching --title {} = {}'
  219. .format(args['title'], count))
  220.  
  221. if count < 1:
  222. sys.exit('\nAbort, No programs in the guide matching: {}.\n'
  223. .format(args['title']))
  224.  
  225. for program in resp_dict['ProgramList']['Programs']:
  226. if args['debug']:
  227. print('Comparing {} to {}'.format(args['title'], program['Title']))
  228. if program['Title'] == args['title']:
  229. if args['debug']:
  230. print(json.dumps(program, sort_keys=True, indent=4,
  231. separators=(',', ': ')))
  232. return program
  233.  
  234. continue
  235.  
  236. return None
  237.  
  238.  
  239. def update_template(template, guide_data, args):
  240. '''
  241. Put selected guide information into the template to be sent as
  242. postdata for the new rule.
  243. '''
  244.  
  245. try:
  246. template['StartTime'] = guide_data['StartTime']
  247. template['EndTime'] = guide_data['EndTime']
  248. template['Title'] = guide_data['Title']
  249. template['Type'] = args['type']
  250. template['Station'] = guide_data['Channel']['CallSign']
  251. template['ChanId'] = guide_data['Channel']['ChanId']
  252. template['SearchType'] = 'None'
  253. template['Category'] = guide_data['Category']
  254. template['SeriesId'] = guide_data['SeriesId']
  255. template['FindTime'] = util.create_find_time(guide_data['StartTime'])
  256. template['Description'] = 'Rule created by add_recording_rule.py'
  257. except KeyError:
  258. return False
  259.  
  260. return True
  261.  
  262.  
  263. def add_rule(backend, template, args, opts):
  264. '''
  265. Send the changed data to the backend.
  266. '''
  267.  
  268. endpoint = 'Dvr/AddRecordSchedule'
  269.  
  270. params_not_sent = ('AverageDelay', 'CallSign', 'Id', 'LastDeleted',
  271. 'LastRecorded', 'NextRecording', 'ParentId')
  272.  
  273. for param in params_not_sent:
  274. try:
  275. del template[param]
  276. except KeyError:
  277. pass
  278.  
  279. opts['wrmi'] = args['wrmi']
  280.  
  281. try:
  282. resp_dict = backend.send(endpoint=endpoint, postdata=template,
  283. opts=opts)
  284. except RuntimeWarning as error:
  285. sys.exit('Abort, Unable to add rule: {}. Warning was: {}.'
  286. .format(template['Title'], error))
  287. except RuntimeError as error:
  288. sys.exit('\nAbort, Fatal API Error response: {}\n'.format(error))
  289.  
  290. opts['wrmi'] = False
  291.  
  292. if isinstance(resp_dict, dict) and isinstance(resp_dict['uint'], str):
  293.  
  294. recording_rule = int(resp_dict['uint'])
  295.  
  296. if recording_rule < 4294967295:
  297. vprint('\nAdded: "{}" (RecordId {}).'
  298. .format(template['Title'], recording_rule), args)
  299. else:
  300. recording_rule = -1
  301. vprint('Backend failed to add: "{}" (RecordId {}).'
  302. .format(template['Title'], recording_rule), args)
  303. return False
  304. else:
  305. vprint('Expected a "uint: int" dictionary response, but got {}'
  306. .format(resp_dict), args)
  307. return False
  308.  
  309. return True
  310.  
  311.  
  312. def record_title(backend, args, opts):
  313. '''
  314. Create a template based on a single title
  315. '''
  316.  
  317. template = get_template(backend, args, opts)
  318. if not template:
  319. sys.exit('\nAbort, no template found for: {}.'.format(args['title']))
  320.  
  321. guide_data = get_program_data(backend, args, opts)
  322. if not guide_data:
  323. sys.exit('\nAbort, no match in guide for: {}'.format(args['title']))
  324.  
  325. if update_template(template, guide_data, args):
  326. add_rule(backend, template, args, opts)
  327. else:
  328. sys.exit('\nAbort, error while copying guide data to template.')
  329.  
  330.  
  331. def get_channels(backend, sourceid):
  332. '''
  333. See: https://www.mythtv.org/wiki/Channel_Service#GetChannelInfoList
  334. '''
  335.  
  336. endpoint = 'Channel/GetChannelInfoList'
  337. rest = 'SoruceID={}&OnlyVisible=true&Details=true'.format(sourceid)
  338.  
  339. try:
  340. resp_dict = backend.send(endpoint=endpoint, rest=rest)
  341. except RuntimeError as error:
  342. sys.exit('\nFatal error: "{}"'.format(error))
  343.  
  344. return resp_dict['ChannelInfoList']['ChannelInfos']
  345.  
  346.  
  347. def get_channel(backend, chanid):
  348. '''
  349. See: https://www.mythtv.org/wiki/Channel_Service#GetChannelInfo
  350. '''
  351.  
  352. endpoint = 'Channel/GetChannelInfo'
  353. rest = 'ChanID={}&OnlyVisible=true&Details=true'.format(chanid)
  354.  
  355. try:
  356. resp_dict = backend.send(endpoint=endpoint, rest=rest)
  357. except RuntimeError as error:
  358. sys.exit('\nFatal error: "{}"'.format(error))
  359.  
  360. return resp_dict['ChannelInfo']
  361.  
  362.  
  363. def record_manual_type(backend, args, opts, rec_type, chaninfo,
  364. template, starttime, duration):
  365. ''' Create a template for a manual recording '''
  366.  
  367. if not starttime:
  368. sys.exit('\nAbort, manul record: no starttime provided.')
  369.  
  370. localtz = datetime.now().astimezone().tzinfo
  371.  
  372. # Convert to UTC
  373. start = starttime.replace(tzinfo=localtz).astimezone(tz=timezone.utc)
  374. end = starttime + timedelta(seconds=duration)
  375. end = end.replace(tzinfo=localtz).astimezone(tz=timezone.utc)
  376.  
  377. template['StartTime'] = "{}".format(start.isoformat()
  378. .replace('+00:00', 'Z'))
  379. template['EndTime'] = "{}".format(end.isoformat()
  380. .replace('+00:00', 'Z'))
  381. template['Description'] = ('{} (Manual Record)'
  382. .format(starttime.strftime('%H')))
  383. template['FindTime'] = starttime.strftime('%H:%M:%S')
  384.  
  385. template['Type'] = rec_type
  386. template['Title'] = args['title']
  387. template['Station'] = chaninfo['CallSign']
  388. template['CallSign'] = chaninfo['CallSign']
  389.  
  390. print("{}\n".format(template))
  391. return add_rule(backend, template, args, opts)
  392.  
  393.  
  394. def record_manual_24x7(backend, args, opts, chaninfo, rec24x7):
  395. ''' Develop Daily rule'''
  396.  
  397. localtz = datetime.now().astimezone().tzinfo
  398. # First Saturday in July, resuling in 7-day/week schedule
  399. saturday = WEEKDAYAFTER(datetime(2018, 7, 1, tzinfo=localtz), 5)
  400. duration = 60 * 60
  401.  
  402. for hour in list(range(24)):
  403. start = saturday.replace(hour=hour)
  404. rec24x7['SubTitle'] = 'hour {}'.format(start.strftime('%H'))
  405.  
  406. if not record_manual_type(backend, args, opts, 'Record Daily',
  407. chaninfo, rec24x7, start, duration):
  408. return False
  409.  
  410. return True
  411.  
  412.  
  413. def record_manual(backend, args, opts):
  414. ''' Choose Daily or Record All rule'''
  415.  
  416. template = get_template(backend, args, opts)
  417. if not template:
  418. sys.exit('\nAbort, no template found for: {}.'
  419. .format(args['template']))
  420.  
  421. if not args['chanid']:
  422. sys.exit('\nAbort, no chanid provided for manual record.')
  423.  
  424. template['ChanId'] = args['chanid']
  425. template['SearchType'] = 'Manual Search'
  426. template['Category'] = ''
  427. template['SeriesId'] = ''
  428.  
  429. chaninfo = get_channel(backend, args['chanid'])
  430. if not chaninfo:
  431. print('Channel ID {} not found in available channels.'
  432. .format(args['chanid']))
  433. return
  434.  
  435. if args['type'] == 'Record All':
  436. record_manual_24x7(backend, args, opts, chaninfo, template)
  437. else:
  438. record_manual_type(backend, args, opts, args['type'],
  439. chaninfo, template, args['datetime'],
  440. args['duration'])
  441.  
  442.  
  443. def vprint(message, args):
  444. '''
  445. Verbose Print: print recording rule information unless --quiet
  446. was used. Not fully implemented, as there are still lots of
  447. print()s here.
  448.  
  449. The intention is that if run out of some other program, this
  450. will can remain quiet. sys.exit()s will return 1 for failures.
  451. This may get expanded to put messages in a log...
  452. '''
  453.  
  454. if not args['quiet']:
  455. print(message)
  456.  
  457.  
  458. def main():
  459. '''
  460. The primary job of main is to get the arguments from the command line,
  461. setup logging (and possibly) handle the digest user/password then:
  462.  
  463. • Create an instance of the Send class
  464. • See if a rule exists for --title
  465. • Get the selected template
  466. • Get data for command line title from the guide
  467. • Update the template with the guide data
  468. • Add the rule on the backend.
  469. '''
  470.  
  471. args = process_command_line()
  472.  
  473. opts = dict()
  474.  
  475. logging.basicConfig(level=logging.DEBUG if args['debug'] else logging.INFO)
  476. logging.getLogger('requests.packages.urllib3').setLevel(logging.WARNING)
  477. logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING)
  478.  
  479. try:
  480. opts['user'], opts['pass'] = args['digest'].split(':', 1)
  481. except (AttributeError, ValueError):
  482. pass
  483.  
  484. backend = api.Send(host=args['host'], port=args['port'])
  485.  
  486. setup(backend, opts)
  487.  
  488. if args['sources']:
  489. sources = get_sources(backend, args)
  490. for source in sources:
  491. print('{}: {}'.format(source['Id'], source['SourceName']))
  492. elif args['channels']:
  493. for channels in get_channels(backend, args['channels']):
  494. print('{0:>6}: {1:>5} {2:10} {3}'.format(channels['ChanId'],
  495. channels['ChanNum'],
  496. channels['CallSign'],
  497. channels['ChannelName']))
  498. else:
  499. if schedule_already_exists(backend, args, opts):
  500. sys.exit('\nAbort, rule for: {} already exists.'
  501. .format(args['title']))
  502.  
  503. if int(args['manual']) > 0:
  504. record_manual(backend, args, opts)
  505. else:
  506. record_title(backend, args, opts)
  507.  
  508.  
  509. if __name__ == '__main__':
  510. main()
  511.  
  512. # vim: set expandtab tabstop=4 shiftwidth=4 smartindent noai colorcolumn=80:
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement