Difference between revisions of "Python API Examples"
m (fix import of Utilities to match instructions) |
m (remove unnecessary opts arg) |
||
Line 118: | Line 118: | ||
import Utilities as api | import Utilities as api | ||
− | resultDict = api.Send(host='localhost', endpoint='Myth/GetHostName' | + | resultDict = api.Send(host='localhost', endpoint='Myth/GetHostName') |
print 'Entire response:', resultDict | print 'Entire response:', resultDict | ||
Line 137: | Line 137: | ||
Backend hostname (MythTV profile name) is: aBackendHostName | Backend hostname (MythTV profile name) is: aBackendHostName | ||
</pre> | </pre> | ||
+ | |||
===The ''Myth/GetTimeZone'' endpoint example=== | ===The ''Myth/GetTimeZone'' endpoint example=== | ||
A slightly more complex example: | A slightly more complex example: |
Revision as of 01:40, 5 January 2016
Contents
Existing sources of information
- Services API: Wiki pages
- Local backend: e.g. http://<backendHostNameOrIP>:6544/Channel/wsdl
- The 'old' protocol version. Use of this mechanism is discouraged but its pages are a still a good source for (say) program data. Myth Protocol
- The the MythTV forum has a Services API section – fast and helpful responses!
- Doxygen pages
- Source code (if desperate!): For example, the Dvr service
Getting information from the backend
The backend can be read using a standard web interface and there are a number of ways of doing this..
Web browser
In its simplest form, a browser will show the data e.g.
http:/<backendHostNameOrIP>:6544/Dvr/GetRecordedList?StartIndex=2&Count=1&Descending=true
will show a single entry from the recorded list.
This is a useful development aid but note that the browser helpfully inserts newlines or tabs for readability but these will not be present if the same page is read with perl code.
Notice that empty values like <XMLTVID></XMLTVID> will display as: <XMLTV/>.
curl
From the shell, curl can do the same, e.g.
curl <backendHostNameOrIP>:6544/Dvr/GetRecordedList?StartIndex=2\&Count=1\&Descending=true | sed "s/></>\n</g"
Note the \ to prevent the shell from spinning off the command at the &. The sed inserts newlines for readability.
Python Services API Cookbook
What follows are some working Python programs that use the MythTV Services API. If the user's language of choice is Perl, then see: Perl API examples
Unlike Python Bindings, only basic access to the Services API is provided here. Users will need to retrieve data from the Services/endpoints of interest to them. N.B. not everything available in Python Bindings' is available in the API.
There are excellent links that list the available services and endpoints. See these: API parameters 0.27 and API parameters 0.28.
The examples depend on a module (included at the bottom of this page.)
This page was written and tested using 0.28-pre under python 2.7 and 3.4.
About the module
First, it's not necessary to use it! Users can write their own code to send information to the back/frontend. The module provides a reasonable template of things that should be done. Included are checking of the arguments, some optional debugging statements, optional header choices and error handling.
The module formats a JSON response from the server into a Python dict.
Installing and importing the module
Where the module is installed is up to the user. The recommendation is to choose one of the following. But first, decide which host(s) the module is to be installed on. It could be a backend, but that isn't necessary. Install it on any host(s) that want to access the Services API
Install in the user's bin directory (easiest)
If the module is installed in the same directory (typically: ~/bin) as the program(s) that use it, then include a line like this in those programs:
import Utilities as api
The as api is optional, but convenient. All examples in this page will assume this method.
Install elsewhere and set PYTHONPATH
Put the module somewhere other than something in the existing $PATH where programs using it are located. Then set PYTHONPATH to point to that directory. For example, the following line could be added to a .profile or .bashrc file:
export PYTHONPATH=/home/bin/python
Import it as in the above.
Install in a dist-packages directory (hardest)
Create a new directory next to the existing MythTV directory that might be found in directories like these:
/usr/local/lib/python2.7/dist-packages/ /usr/lib/python2.7/dist-packages/ /usr/lib/python3.4/dist-packages /usr/local/lib/python3.4/dist-packages
Create a new directory named (for example) MythTVServicesAPI and put the Utilities.py and an empty __init__.py file under it. Savy users will use a Makefile and setup.py program to do this (and create .pyc files etc.)
MacPorts users might find the bindings under: /opt/dvr/bin/python2.7.
Importing the module would then be done like this:
from MythTVServicesAPI import Utilities as api
Displaying the module's help information
As with any proper Python module, built-in help is available. Try the following:
$ python >>> import Utilities as util
Or:
$ python >>> from MythTVServicesAPI import Utilities as util
Followed by:
>>> help(util) >>> control-D # After the help prints
Examples
Once the module is installed, the following examples will work.
Notice that in the following, localhost is used for the host argument to Send(). That means these will be run on a MythTV backend. But that isn't required. The module and examples can be put on any client that has access to the backend and run from there. Just replace host='localhost with host='backendHostNameOrIpAddress.
Myth/GetHostName endpoint example
Getting the backend's hostname returns the simplest response in the API. It's the easiest to parse and has the hostname of the backend (really, it's the profile name used in the DB and honors the LocalHostName line in config.xml, but that's another Wiki.) It's a great place to start learning about the API.
Put the following in a file, perhaps simpleAPItest.py. Make it executable and run it. Adjust the #! line as required. For example, MacPorts users might replace the 1st line below with #!/opt/dvr/bin/python2.7

#!/usr/bin/env python import Utilities as api resultDict = api.Send(host='localhost', endpoint='Myth/GetHostName') print 'Entire response:', resultDict print 'Backend hostname (MythTV profile name) is: ', resultDict['String']
As mentioned above as api is optional. Calling Utilities.Send(...) is also valid. There's nothing special about resultDict other than it reminds the user that the results are being returned in a Python dict format.
Look at the two lines of output. The 1st is the complete response, a simple dictionary with one element, the key String contains the value someHostName. This is important as later examples will be more complex dictionaries.
Entire response: {u'String': u'backendHostName'} Backend hostname (MythTV profile name) is: aBackendHostName
The Myth/GetTimeZone endpoint example
A slightly more complex example:

#!/usr/bin/env python import Utilities as api r = api.Send(host='backendHostName', endpoint='Myth/GetTimeZone') print r print r['TimeZoneInfo'] print r['TimeZoneInfo']['TimeZoneID'] print r['TimeZoneInfo']['UTCOffset'] print r['TimeZoneInfo']['CurrentDateTime']
The GetTimeZone JSON response contains a dictionary (TimeZoneInfo) with dictionary inside it that has three keys. Expect to see something like this:
{u'TimeZoneInfo': {u'TimeZoneID': u'America/Chicago', u'UTCOffset': u'-21600', u'CurrentDateTime': u'2015-11-13T19:26:18Z'}} {u'TimeZoneID': u'America/Chicago', u'UTCOffset': u'-21600', u'CurrentDateTime': u'2015-11-13T19:26:18Z'}} America/Chicago -21600 2015-11-13T19:26:18Z
Working program using the Dvr/GetUpcomingList endpoint
Here's a simple script, with a bit of formatting, that does a practical task. Refer to the tools in the beginning of this page to see the XML output of this endpoint. Viewing it with a browser may make the dicts easier to understand.
Put the following in a file, like: shortPrintUpcoming.py and type: ./shortPrintUpcoming.py backendHostNameOrIPaddress
Note the line: progs = resp_dict['ProgramList']['Programs'] below. Rather than referencing each upcoming program as: resp_dict['ProgramList']['Programs'][upcoming], they can be examined as: progs[upcoming].... upcoming here is the index into a list contained in the Programs key.
Also note that there's yet another dict under progs, it's the ['Recording'] dict where only the StartTs is used in this example.

#!/usr/bin/env python # -*- coding: utf-8 -*- # shortPrintUpcoming.py: Print Start Times, Titles and SubTitles import os.path as path, sys, Utilities as API try: Host = sys.argv[1] except: sys.exit( '\nUsage: {} HostnameOrIP'.format(path.basename(sys.argv[0]))) resp_dict = API.Send(host=Host, endpoint='Dvr/GetUpcomingList') if list(resp_dict.keys())[0] in ['Abort', 'Warning']: sys.exit('\n{}\n'.format(list(resp_dict.values())[0])) count = int(resp_dict['ProgramList']['Count']) progs = resp_dict['ProgramList']['Programs'] if count < 1: sys.exit('\nNo upcoming recordings found.\n') for upcoming in range(count): print(u' {} {:45.45} {:15.15}'.format( progs[upcoming]['Recording']['StartTs'], progs[upcoming]['Title'], progs[upcoming]['SubTitle']).encode('utf-8'))
To see the way the response is returned, try adding lines like these to the end of the program:
print resp_dict print resp_dict['ProgramList']['Programs'][0] print progs[0]
The 1st displays the entire response in a dictionary, the 2nd just the 0th program returned and the last is the same as the 2nd.
Full example using Dvr/GetUpcomingList
Using the ideas above, the following is a complete program with command line arguments used to set some of the opts values, and set a variable host etc. Type: ./printUpcoming --help for details. Try the --debug argument to see the URLs generated by the program.

#!/usr/bin/env python # -*- coding: utf-8 -*- # # printUpcoming.py: Print Start Times, Titles and SubTitles, # optionally filtering by Titles/ChanId or limited by days. # Also optionally print the cast members for each recording. # # This is an 'overly-commented', and simple example of using the Services # API to do a task. It also forms a template for working with the API. # # Good Start: printUpcoming.py --help # Watch what it sends: printUpcoming.py --host=Name/IP --debug # ############################################################## # MythTVServicesAPI.Utilities handles backend connections # # re is used to allow things like --title to be partial and # # case agnostic # # argparse is used in the following section # ############################################################## from datetime import datetime import Utilities as API import argparse, re, sys, time ############################################################## # Add command arguments, as required, here. --host is likely # # a minimum and mandatory unless the host is hardcoded into # # the script. Examples include string, integer and bool type # # arguments. Printing the default values is also shown. # ############################################################## parser = argparse.ArgumentParser(description='Print Upcoming Programs', epilog='Default values are in ()s') mandatory = parser.add_argument_group('requrired arguments') parser.add_argument('--cast', action='store_true', help='include cast member names (%(default)s)') parser.add_argument('--chanid', type=int, required=False, metavar='<chanid>', help='filter on MythTV chanid, e.g. 1091 (none).') parser.add_argument('--days', type=int, required=False, metavar='<days>', default=7, help='number of days of programs to print (%(default)s)') parser.add_argument('--debug', action='store_true', help='turn on debug messages (%(default)s)') mandatory.add_argument('--host', type=str, required=True, metavar='<hostname>', help='backend hostname') parser.add_argument('--nogzip', action='store_true', help='tell the backend not to return gzipped data (%(default)s)') parser.add_argument('--port', type=int, default=6544, metavar='<port>', help='port number of the Services API (%(default)s)') parser.add_argument('--title', type=str, default='', required=False, metavar='<prog>', help='filter by title (none)') args = parser.parse_args() ############################################################## # Prepare the data to send to the backend via the API and # # then do it. # ############################################################## endpoint = 'Dvr/GetUpcomingList' opts = {'debug':args.debug, 'nogzip':args.nogzip} rest = 'ShowAll=false' resp_dict = API.Send(host=args.host, port=args.port, opts=opts, endpoint=endpoint, rest=rest) if list(resp_dict.keys())[0] in ['Abort', 'Warning']: sys.exit('\n{}\n'.format(list(resp_dict.values())[0])) ############################################################## # And also set the module's timezone offset so that the # # start times in the response can be converted to local time.# ############################################################## API.GetUTCOffset(host=args.host, port=args.port) ############################################################## # Finally, loop through the results and select the items # # of interest. Ignore Titles that don't match the reqular # # expression if the --title argument was used. Same for # # chanids. The results are sorted by time, so this makes an # # attempt to only print --days worth of data. # ############################################################## WHITE = '\033[0m' YELLOW = '\033[93m' headerPrinted = False one_day_in_seconds = 24 * 60 * 60 time_now = time.mktime(datetime.now().timetuple()) count = int(resp_dict['ProgramList']['TotalAvailable']) progs = resp_dict['ProgramList']['Programs'] if args.debug: print('Debug: Upcoming recording count = {}'.format(count)) if count < 1: sys.exit('\nNo upcoming recordings found.\n') print('\nPrinting {} days of upcoming programs sorted by StartTime'.format( args.days)) print('\n {}{:19} {:45.45} {}{}' \ .format(YELLOW, 'StartTime', 'Title', 'SubTitle', WHITE)) for upcoming in range(count): title = progs[upcoming]['Title'] subtitle = progs[upcoming]['SubTitle'] chanid = int(progs[upcoming]['Channel']['ChanId']) startts = API.UTCToLocal(progs[upcoming]['Recording']['StartTs']) future_time = time.mktime(datetime.strptime(startts, "%Y-%m-%d %H:%M:%S").timetuple()) futureDays = int((future_time - time_now ) / one_day_in_seconds) if futureDays >= args.days: break if (args.title == '' or re.search(args.title, title, re.IGNORECASE)) and \ (args.chanid == None or args.chanid == chanid): print(u' {} {:45.45} {:15.15}' \ .format(startts, title, subtitle).encode('utf-8')) if args.cast: cast = progs[upcoming]['Cast']['CastMembers'] for a in range(100): try: print(u'\t{} ({})'.format (cast[a]['Name'], cast[a]['TranslatedRole']).encode('utf-8')) except IndexError: break # vim: set expandtab tabstop=4 shiftwidth=4 :
By getting the time zone information from the backend, each start time in the output is converted to local time rather than the UTC returned by the Services API. Another important concept in the above is the use of print u'.... followed by .encode('utf-8). That's critical for Titles/SubTitles for example where there are characters like: ó.
The module used in the examples above

# -*- coding: utf-8 -*- """ Basic access utilities. """ from datetime import datetime, timedelta import re, sys try: import requests except ImportError: sys.exit('Debian: apt-get install python-requests or python3-requests') try: from urllib import quote except ImportError: from urllib.parse import quote __version__ = '0.28.a2' ServerVersion = 'Set to back/frontend version after calls to Send()' global session session = None def Send(host='', port=6544, endpoint='', postdata='', rest='', opts={}): """ Form a URL and send it to the back/frontend. Error handling is done here too. Examples: ========= import Utilities as api api.Send(host='someHostName', endpoint='Myth/GetHostName') {u'String': u'someHostName'} api.Send(host='ofc0', port=6547, endpoint='Frontend/GetStatus') {'FrontendStatus': {'AudioTracks':... Input: ====== host: Must be set and is the hostname or IP of the back/frontend. port: Only needed if the backend is using a different port (unlikely) or set to 6547 for frontend endpoints. Defaults to 6544. endpoint: Must be set. Example: Myth/GetHostName postdata: May be set if the endpoint allows it. Used when information is to be added/changed/deleted. postdata is passed as a JSON dict e.g. {'ChanId':1071, ...}. Don't use if rest is used. The HTTP method will be a POST (as opposed to a GET.) If using postdata, TAKE EXTREME CAUTION!!! Use opts['wrmi']=False 1st, set opts['debug']=True and watch what's sent. When happy with the data, make wrmi True. N.B. The MythTV Services API is still evolving and the wise user will backup their DB before including postdata. rest: May be set if the endpoint allows it. For example, endpoint= Myth/GetRecordedList, rest='Count=10&StorageGroup=Sports' Don't use if postdata is used. The HTTP method will be a GET. opts SHORT DESCRIPTION: It's OK to call this function without any options set and: • If there's postdata, nothing will be sent to the server • No "Debug:..." messages will print from this function • The server response will be gzipped and decompressed DETAILS: opts is a dictionary of options that may be set in the calling program. Default values will be used if callers don't pass all or some of their own. The defaults are all False. opts['debug']: Set to True and some informational messages will be printed. opts['nogzip']: Don't request the back/frontend to gzip it's response. Useful if watching protocol with a tool that doesn't uncompress it. opts['usexml']: For testing only! If True, causes the backend to send its response in XML rather than JSON. This will force an error when parsing the response. Defaults to False. opts['wrmi']: If True and there is postdata, the URL is actually sent to the server. If opts['wrmi'] is False and there is postdata, *NOTHING* is sent to the server. This is a failsafe that allows testing. Users can examine what's about to be sent before doing it (wrmi = We Really Mean It.) opts['wsdl']: If True return WSDL from the back/frontend. Accepts no rest or postdata, only a service name, e.g. Myth, Video, Dvr... Output: ======= Either the response from the server in a Python dict format or an error message in a dict (currently with an 'Abort' or 'Warning' key.) Callers can handle the response like this: response = api.Send(...) if list(response.keys())[0] in ['Abort', 'Warning']: sys.exit('{}'.format(list(response.values())[0])) normal processing However, some errors returned by the server are in XML, e.g. if an endpoint is invalid. That will cause the JSON decoder to fail. Use the debug opt to view the failing response. Whenever Send() is used, the global 'ServerVersion' is set to the value returned by the back/frontend in the HTTP Server: header. It is saved as just the version, e.g. 0.28. Callers can check it and *may* choose to adjust their code work with other versions. """ global session global ServerVersion #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!# # The version should never be changed without testing. If # # you're just getting data, no harm will be done. But if # # you Add/Delete/Update anything, then all bets are off! # # Anything requiring an HTTP POST is potentially dangerous. # #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!# version = '0.28' ############################################################## # Set missing options to False and if debug is True, tell # # the user if which options were changed. # ############################################################## optionList = [ 'debug', 'nogzip', 'usexml', 'wrmi', 'wsdl' ] missingList = '' for option in optionList: try: opts[option] except: missingList = missingList + option + ', ' opts[option] = False if opts['debug'] and missingList: print('Debug: Missing opts set to False: {}'.format(missingList[:-2])) ############################################################## # Make sure there's an endpoint and optionally *either* resp # # or postdata. If so, then form the URL. # ############################################################## if endpoint == '': return { 'Abort':'No endpoint (e.g. Myth/GetHostName.)' } if postdata and rest: return { 'Abort':'Use either postdata or rest' } if rest == '': qmark = '' else: qmark = '?' url='http://{}:{}/{}{}{}'.format(host, port, endpoint, qmark, rest) ############################################################## # Create a session. If postdata was supplied and wrmi wasn't # # set,then return immediately. Make sure postdata was passed # # as a dict. # ############################################################## if not session: session = requests.Session() if opts['debug']: print('Debug: New session: {}'.format(session)) if opts['debug']: print('Debug: URL = {}'.format(url)) if postdata: print(' postdata = {}'.format(postdata)) if postdata and not opts['wrmi']: return { 'Warning':'wrmi=False' } if postdata: if not isinstance(postdata, dict): return { 'Abort':'usage: postdata must be passed as a dict' } if opts['wsdl'] and (rest or postdata): return { 'Abort':'usage: rest/postdata aren\'t allowed with WSDL' } ############################################################## # Add the required headers. Adjust as requested by opts. # ############################################################## headers = { 'User-Agent':'{} Python Services API Client'.format(version), 'Accept':'application/json', 'Accept-Encoding':'gzip,deflate' } if opts['usexml']: del headers['Accept'] if opts['nogzip']: headers['Accept-Encoding'] = '' ############################################################## # Actually try to get the data and handle errors. # ############################################################## try: if postdata: response = session.post(url, headers=headers, data=postdata) else: response = session.get(url, headers=headers) except requests.exceptions.HTTPError: return { 'Abort':'HTTP Error. URL was: {}'.format(url) } except requests.exceptions.URLRequired: return { 'Abort':'URL Required. URL was: {}'.format(url) } except requests.exceptions.ConnectTimeout: return { 'Abort':'Connect Timeout: URL was {}'.format(url) } except requests.exceptions.ReadTimeout: return { 'Abort':'Read Timeout: URL was {}'.format(url) } except requests.exceptions.ConnectionError: return { 'Abort':'Connection Error: URL was {}'.format(url) } except requests.exceptions.InvalidURL: return { 'Abort':'Invalid URL: URL was {}'.format(url) } except KeyboardInterrupt: return { 'Abort':'Keyboard Interrupt' } except: return { 'Abort':'Unexpected error: URL was: {}'.format(url) } if response.status_code > 299: return { 'Abort':'Unexpected status returned: {}: URL was: {}'.format( response.status_code, url) } ################################################################## # Process the contents of the HTTP Server: header. Try to see # # what version the server is running on. As of this writing the # # expected contents for 0.28 and 0.27 are: # # # # MythTV/0.28-pre-3094-g349d3a4 Linux/3.13.0-66-generic UPnP/1.0 # # Linux 3.13.0-65-generic, UPnP/1.0, MythTV 0.27.20150622-1 # ################################################################## server = response.headers['Server'] if server == None: return { 'Abort':'No HTTP "Server:" header returned.' } else: if re.search(version, server): ServerVersion = '0.28' elif re.search('0.27', server): ServerVersion = '0.27' print('Warning: {} Services API module may not work with {}.' \ .format(version, ServerVersion)) else: return { 'Abort':'Tested on 0.27 & 0.28, not: {}.'.format(server) } ############################################################## # Finally, return the response after converting the JSON to # # a dict. Or, if the wesdl option is set return that # ############################################################## if opts['wsdl']: return { 'WSDL':response.text} if opts['debug']: print('Debug: 1st 60 bytes of response: {}'.format(response.text[:60])) try: return response.json() except Exception as err: return { 'Abort':err } def URLEncode(value=''): """ This is really unnecessary. It's more of a reminder about how to use urllib.[parse]quote(). At least as of this writing, 0.28-pre doesn't decode the escaped values and the endpoints just get the percent encoded text. E.g. don't use it. How show titles with & or = in them work isn't clear. Input: A string. E.g a program's title or anything that has special characters like ?, & and UTF characters beyond the ASCII set. Output: The URL encoded string. E.g. ó becomes: %C3%B3 or ? becomes %3F. """ if value == '': print('Warning: Utilities.URLEncode() called without any value') return value return quote(value) def CreateFindTime(time=''): """ Normally be used to take a starttime and convert it for use in adding new recordings. GetUTCOffset() should be called before this is, but that only needs to be done once. TODO: shouldn't this be removed and just use UTCToLocal with omityear=True? Input: Full UTC timestamp, e.g. 2014-08-12T22:00:00 (with or without the trailing 'Z'.) Output: Time portion of the above in local time. """ global UTCOffset if time == '': print('Warning: CreateFindTime called without any time') return None try: int(UTCOffset) utc_offset = UTCOffset except (NameError, ValueError): print('Warning: Run GetUTCOffset() first. using UTC offset of 0.') utc_offset = 0 time = time.replace('Z', '') dt = datetime.strptime(time, '%Y-%m-%dT%H:%M:%S') return (dt + timedelta(seconds = utc_offset)).strftime('%H:%M:%S') def UTCToLocal(utctime='', omityear=False): """ Does exactly that conversion. GetUTCOffset() should be run once before calling this function. A UTC offset of 0 will be used if UTCOffset isn't available, so the function won't abort. Inputs: utctime = Full UTC timestamp, e.g. 2014-08-12T22:00:00[Z]. omityear = If True, then drop the 4 digit year and following -. Output: Local time, also a string. Possibly without the year- and always without the T between the data/time and no trailing Z. """ global UTCOffset try: int(UTCOffset) utc_offset = UTCOffset except (NameError, ValueError): print('Warning: Run GetUTCOffset() first, using UTC offset of 0.') utc_offset = 0 if utctime == '': return 'Error: UTCToLocal(): utctime is empty!' utctime = utctime.replace('Z', '').replace('T', ' ') dt = datetime.strptime(utctime, '%Y-%m-%d %H:%M:%S') if omityear: fromstring = '%m-%d %H:%M:%S' else: fromstring = '%Y-%m-%d %H:%M:%S' return (dt + timedelta(seconds = utc_offset)).strftime(fromstring) def GetUTCOffset(host='', port=6544): """ Get the backend's offset from UTC. Once retrieved, it's saved value is available in UTCOffset and is returned too. Additional calls to the function aren't necessary, but won't ask for the backend for it again. Input: host, optional port. Output: The offset (in seconds) or -1 and a message prints """ global UTCOffset if host == '': print('GetUTCOffset(): Error: host is empty.') return -1 try: int(UTCOffset) return UTCOffset except (NameError, ValueError): utcopts = { 'debug':False, 'nogzip':False, 'usexml':False,'wrmi':False } resp_dict = Send(host=host, port=port, endpoint='Myth/GetTimeZone',\ opts=utcopts) if list(resp_dict.keys())[0] in ['Abort', 'Warning']: print('GetUTCOffset(): {}'.format(resp_dict)) return -1 else: UTCOffset = int(resp_dict['TimeZoneInfo']['UTCOffset']) return UTCOffset