Difference between revisions of "Python API Examples"

From MythTV Official Wiki
Jump to: navigation, search
(Test new template)
(2nd version of the module and explainations)
Line 2: Line 2:
 
=Python Services API Cookbook=
 
=Python Services API Cookbook=
  
Some working examples using the MythTV [[Services API]].
+
What follows are some working ''Python'' programs that use
If the user's language of choice is Perl, then see:
+
the MythTV [[Services API]]. If the user's language of
[[Perl API examples]]
+
choice is Perl, then see: [[Perl API examples]]
  
The following will be demonstrated using a Python
+
Unlike [[Python Bindings]], only basic access to
module (included at the bottom of this page.) Users
+
the Services API is provided here. Users will need to retrieve
can choose to place the module in the same
+
data from the Services/endpoints of interest to them. N.B. not
directory as programs that use it, or '''alongside'''
+
everything available in ''Python Bindings' is available in the API.
the existing MythTV Python bindings.
 
  
There are excellent links that list the available endpoints
+
There are excellent links that list the available services
(the services and actions following the port number) here:
+
and endpoints. See these: [[API parameters 0.27]] and [[API parameters 0.28]].
[[API parameters 0.27]] and [[API parameters 0.28]].
 
  
These examples were written and tested using 0.28-pre.
+
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 [[#The_module_used_in_the_examples_above|module]]===
 
===About the [[#The_module_used_in_the_examples_above|module]]===
 
First, it's not necessary to use it! Users can write their own code
 
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
 
to send information to the back/frontend. The module provides a
reasonable template of what might be done. Included are checking
+
reasonable template of things that should be done. Included are checking
of the parameters, some optional debugging statements, optional
+
of the arguments, some optional debugging statements, optional
 
header choices and error handling.
 
header choices and error handling.
  
Regarding the options. The module will set the value of any uninitialized
+
The module formats a ''JSON'' response from the server into a ''Python'' ''dict''.
option to False. But the caller should set them perhaps based on a
+
=== Installing  and ''import''ing the module ===
command line argument (or just hardcode them as appropriate
+
Where the module is installed is up to the user. The
for their own use.) If any option isn't set, a warning messages will be printed.
+
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 as the program(s) that
 +
use it, then include a line like this in programs that use it:
 +
<pre>
 +
import Utilities as api
 +
</pre>
 +
The ''as api'' is optional, but convenient.
 +
All examples in this page will assume this
 +
method (on the next.)
 +
====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:
 +
<pre>
 +
export PYTHONPATH=/home/bin/python
 +
</pre>
 +
Import it as in the above.
 +
====Install in ''dist-packages'' directory (hardest)====
 +
Create a new directory '''next to''' the existing
 +
''MythTV'' directory that might be found in in directories
 +
like these:
 +
<pre>
 +
/usr/local/lib/python2.7/dist-packages/
 +
/usr/lib/python2.7/dist-packages/
 +
</pre>
 +
Create a new directory named (for example) MythTVServicesAPI
 +
there and put the Utilities.py (and an empty  __init__.py)
 +
file there. Savy users will use a ''setup.py'' file to do
 +
this (and create ''.pyc'' files.)
  
Importantly, the module reformats the JSON or XML response from
+
''MacPorts'' users might find the bindings under:
the server into a Python ''dict'' or an ''Element Tree'' respectively.
+
''/opt/dvr/bin/python2.7''.
 +
 
 +
Importing the module would then be done like this:
 +
<pre>
 +
from MythTVServicesAPI import Utilities as api
 +
</pre>
  
As with any Python module, built-in help is available. Try the following:
+
===Displaying the module's help information===
 +
As with any proper ''Python'' module, built-in help
 +
is available. Try the following:
 
<pre>
 
<pre>
 
$ python
 
$ python
>>> from MythTVServicesAPI import Utilities as util # Or:
 
 
>>> import Utilities as util
 
>>> import Utilities as util
 +
</pre>
 +
Or:
 +
<pre>
 +
$ python
 +
>>> from MythTVServicesAPI import Utilities as util
 +
</pre>
 +
Followed by:
 +
<pre>
 
>>> help(util)
 
>>> help(util)
 
>>> control-D # After the help prints
 
>>> control-D # After the help prints
 
</pre>
 
</pre>
 +
==Examples==
 +
Once the module is installed, the following examples will work.
  
The following examples will all request the response from the
+
Notice that in the following, ''localhost'' is used for
back/frontend and return it in Python dictionary format.
+
the ''host'' argument to ''Send()''. That means these will be run on
=== Load the module ===
+
a ''MythTV'' backend. But that isn't required. The
Choose one of the following. If the module is installed (for example)
+
module and examples can  be put on any client that has
in the system's dist-packages directory, use this:
+
access to the backend and run from there. Just replace
<pre>from MythTVServicesAPI import Utilities as api</pre>
+
''host='localhost'' with ''host='backendHostNameOrIpAddress''.
That assumes there is a MythTVServicesAPI directory with the
+
===''Myth/GetHostName'' endpoint example===
Utilities.py file underneath it (and an empty __init__.py
 
file there too.) An example of the directory where the above
 
would be installed is: ''/usr/local/lib/python2.7/dist-packages''
 
which is where users would likely find the existing MythTV Python
 
bindings. ''MacPorts'' users might find the bindings under:
 
''/opt/dvr/bin/python2.7''.
 
 
 
Or, if the module is installed in the same directory as the program(s) that
 
use it, do this:
 
<pre>
 
import Utilities as api
 
</pre>
 
 
 
==''Myth/GetHostName'' endpoint example==
 
 
Getting the backend's hostname returns the simplest response
 
Getting the backend's hostname returns the simplest response
 
in the API. It's the easiest to parse and has the hostname
 
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
 
of the backend (really, it's the profile name used in the DB
 
and honors the LocalHostName line in config.xml, but that's
 
and honors the LocalHostName line in config.xml, but that's
another Wiki.))
+
another Wiki.) It's a great place to start learning about the
 +
API.
  
 
Put the following in a file, perhaps simpleAPItest.py.
 
Put the following in a file, perhaps simpleAPItest.py.
Make it executable and run it.
+
Make it executable and run it. Adjust the ''#!'' line
 
+
as required. For example, ''MacPorts'' users might replace the 1st
MacPorts users replace the 1st line below with ''#!/opt/dvr/bin/python2.7''
+
line below with ''#!/opt/dvr/bin/python2.7''
  
 
{{Python|simpleAPItest.py|<pre>
 
{{Python|simpleAPItest.py|<pre>
 
#!/usr/bin/env python
 
#!/usr/bin/env python
 
import Utilities as api
 
import Utilities as api
 
opts = {'debug':False, 'etree':False, 'nogzip':False, 'wrmi':False}
 
  
 
resultDict = api.Send(host='localhost', endpoint='Myth/GetHostName', opts=opts)
 
resultDict = api.Send(host='localhost', endpoint='Myth/GetHostName', opts=opts)
  
print 'Dictionary contents:', resultDict
+
print 'Entire response:', resultDict
print 'My backend hostname (MythTV profile name) is: ', resultDict['String']
+
print 'Backend hostname (MythTV profile name) is: ', resultDict['String']
 
</pre>}}
 
</pre>}}
  
Look at the 2 lines of output.
+
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.
 
<pre>
 
<pre>
Dictionary contents: {u'String': u'backendHostName'}
+
Entire response: {u'String': u'backendHostName'}
My backend hostname (MythTV profile name) is: backendHostName
+
Backend hostname (MythTV profile name) is: aBackendHostName
 
</pre>
 
</pre>
==The ''Myth/GetTimeZone'' endpoint example==
+
===The ''Myth/GetTimeZone'' endpoint example===
A slightly more complex example. Import the module
+
A slightly more complex example:
and set the opts as above and then try:
 
  
 
{{Python|getTimeZoneInfo.py|<pre>
 
{{Python|getTimeZoneInfo.py|<pre>
 +
#!/usr/bin/env python
 +
import Utilities as api
 +
 
r = api.Send(host='backendHostName', endpoint='Myth/GetTimeZone')
 
r = api.Send(host='backendHostName', endpoint='Myth/GetTimeZone')
  
 
print r
 
print r
 +
print r['TimeZoneInfo']
 
print r['TimeZoneInfo']['TimeZoneID']
 
print r['TimeZoneInfo']['TimeZoneID']
 
print r['TimeZoneInfo']['UTCOffset']
 
print r['TimeZoneInfo']['UTCOffset']
 
print r['TimeZoneInfo']['CurrentDateTime']
 
print r['TimeZoneInfo']['CurrentDateTime']
 
</pre>}}
 
</pre>}}
Expect to see something like this:
+
The GetTimeZone JSON response contains a dictionary (''TimeZoneInfo'') with dictionary inside it
 +
that has three ''keys''. Expect to see something like this:
 
<pre>
 
<pre>
 
{u'TimeZoneInfo': {u'TimeZoneID': u'America/Chicago', u'UTCOffset': u'-21600', u'CurrentDateTime': u'2015-11-13T19:26:18Z'}}
 
{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
 
America/Chicago
 
-21600
 
-21600
 
2015-11-13T19:26:18Z
 
2015-11-13T19:26:18Z
 
</pre>
 
</pre>
 
+
===Working program using the ''Dvr/GetUpcomingList'' endpoint===
==Working program using the ''Dvr/GetUpcomingList'' endpoint==
 
 
Here's a simple script, with a bit of formatting, that
 
Here's a simple script, with a bit of formatting, that
does a practical task.
+
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 ''dict''s easier to understand.
  
 
Put the following in a file, like: ''shortPrintUpcoming.py'' and
 
Put the following in a file, like: ''shortPrintUpcoming.py'' and
Line 121: Line 171:
 
''resp_dict['ProgramList']['Programs'][upcoming]'', they can be examined
 
''resp_dict['ProgramList']['Programs'][upcoming]'', they can be examined
 
as: ''progs[upcoming]...''.
 
as: ''progs[upcoming]...''.
 +
''upcoming'' here is the ''index'' into a ''list'' contained in the ''Programs''
 +
''key''.
  
Also note that there's yet another level under ''progs'', it's
+
Also note that there's yet another ''dict'' under ''progs'', it's
the ''['Recording']'' portion where just the ''StartTs'' is
+
the ''['Recording']'' ''dict'' where only the ''StartTs'' is
 
used in this example.
 
used in this example.
 
{{Python|shortPrintUpcoming.py|<pre>
 
{{Python|shortPrintUpcoming.py|<pre>
Line 131: Line 183:
 
# shortPrintUpcoming.py: Print Start Times, Titles and SubTitles
 
# shortPrintUpcoming.py: Print Start Times, Titles and SubTitles
  
import os.path as path, sys, Utilities as api
+
import os.path as path, sys, MythTVServicesAPI.Utilities as API
  
 
try:
 
try:
     host = sys.argv[1]
+
     Host = sys.argv[1]
 
except:
 
except:
 
     sys.exit( '\nUsage: {} HostnameOrIP'.format(path.basename(sys.argv[0])))
 
     sys.exit( '\nUsage: {} HostnameOrIP'.format(path.basename(sys.argv[0])))
  
resp_dict = api.Send(host=host, endpoint='Dvr/GetUpcomingList')
+
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'])
 
count = int(resp_dict['ProgramList']['Count'])
Line 146: Line 201:
  
 
for upcoming in range(count):
 
for upcoming in range(count):
     print u'  {}  {:45.45}  {:15.15}'.format(
+
     print(u'  {}  {:45.45}  {:15.15}'.format(
 
         progs[upcoming]['Recording']['StartTs'],
 
         progs[upcoming]['Recording']['StartTs'],
 
         progs[upcoming]['Title'],
 
         progs[upcoming]['Title'],
         progs[upcoming]['SubTitle']).encode('utf-8')
+
         progs[upcoming]['SubTitle']).encode('utf-8'))
 
</pre>}}
 
</pre>}}
  
 
To see the way the response is returned, try adding lines like
 
To see the way the response is returned, try adding lines like
 +
these to the end of the program:
 
<pre>
 
<pre>
 
print resp_dict
 
print resp_dict
Line 161: Line 217:
 
the 2nd just the 0th program returned and the last
 
the 2nd just the 0th program returned and the last
 
is the same as the 2nd.
 
is the same as the 2nd.
==Full example using ''Dvr/GetUpcomingList''==
+
===Full example using ''Dvr/GetUpcomingList''===
 
Using the ideas above, the following is a complete program
 
Using the ideas above, the following is a complete program
 
with command line arguments used to set some of the opts values,
 
with command line arguments used to set some of the opts values,
Line 172: Line 228:
  
 
#
 
#
# printUpcoming.py: Print Start Times, Titles and SubTitles
+
# printUpcoming.py: Print Start Times, Titles and SubTitles,
# (optionally filtering by Titles/ChanId or limited by days.)
+
# 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
 
# This is an 'overly-commented', and simple example of using the Services
Line 189: Line 246:
 
##############################################################
 
##############################################################
 
from  datetime import datetime
 
from  datetime import datetime
from  MythTVServicesAPI import Utilities as API
+
import Utilities as API
 
import argparse, re, sys, time
 
import argparse, re, sys, time
  
Line 203: Line 260:
  
 
mandatory = parser.add_argument_group('requrired arguments')
 
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>',
 
parser.add_argument('--chanid', type=int, required=False, metavar='<chanid>',
Line 233: Line 293:
  
 
endpoint = 'Dvr/GetUpcomingList'
 
endpoint = 'Dvr/GetUpcomingList'
opts = { 'debug':False, 'etree':False, 'nogzip':False, 'wrmi':False }
+
opts     = {'debug':args.debug, 'nogzip':args.nogzip}
 
rest    = 'ShowAll=false'
 
rest    = 'ShowAll=false'
  
 
resp_dict = API.Send(host=args.host, port=args.port, opts=opts,
 
resp_dict = API.Send(host=args.host, port=args.port, opts=opts,
 
                         endpoint=endpoint, rest=rest)
 
                         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 get the backend's timezone offset so that the     #
+
# And also set the module's timezone offset so that the     #
 
# start times in the response can be converted to local time.#
 
# start times in the response can be converted to local time.#
 
##############################################################
 
##############################################################
  
tz_dict = API.Send(host=args.host, port=args.port,
+
API.GetUTCOffset(host=args.host, port=args.port)
                  endpoint='Myth/GetTimeZone', opts=opts)
 
 
 
tzoffset = int(tz_dict['TimeZoneInfo']['UTCOffset'])
 
  
 
##############################################################
 
##############################################################
Line 263: Line 323:
 
time_now = time.mktime(datetime.now().timetuple())
 
time_now = time.mktime(datetime.now().timetuple())
  
count = int(resp_dict['ProgramList']['Count'])
+
count = int(resp_dict['ProgramList']['TotalAvailable'])
 
progs = resp_dict['ProgramList']['Programs']
 
progs = resp_dict['ProgramList']['Programs']
  
 
if args.debug:
 
if args.debug:
     print 'Debug: Upcoming recording count =', count
+
     print('Debug: Upcoming recording count = {}'.format(count))
  
 
if count < 1:
 
if count < 1:
 
     sys.exit('\nNo upcoming recordings found.\n')
 
     sys.exit('\nNo upcoming recordings found.\n')
  
print '\nUpcoming programs sorted by StartTime'
+
print('\nPrinting {} days of upcoming programs sorted by StartTime'.format(
print '\n  {}{:19}  {:45.45}  {}{}' \
+
        args.days))
     .format(YELLOW, 'StartTime', 'Title', 'SubTitle', WHITE)
+
print('\n  {}{:19}  {:45.45}  {}{}' \
 +
     .format(YELLOW, 'StartTime', 'Title', 'SubTitle', WHITE))
  
 
for upcoming in range(count):
 
for upcoming in range(count):
Line 281: Line 342:
 
     subtitle = progs[upcoming]['SubTitle']
 
     subtitle = progs[upcoming]['SubTitle']
 
     chanid  = int(progs[upcoming]['Channel']['ChanId'])
 
     chanid  = int(progs[upcoming]['Channel']['ChanId'])
     startts  = API.UTCToLocal(progs[upcoming]['Recording']['StartTs'],
+
     startts  = API.UTCToLocal(progs[upcoming]['Recording']['StartTs'])
                                tzoffset=tzoffset)
 
  
 
     future_time = time.mktime(datetime.strptime(startts,
 
     future_time = time.mktime(datetime.strptime(startts,
Line 294: Line 354:
 
         (args.chanid == None or args.chanid == chanid):
 
         (args.chanid == None or args.chanid == chanid):
  
         print u'  {}  {:45.45}  {:15.15}' \
+
         print(u'  {}  {:45.45}  {:15.15}' \
                 .format(startts, title, subtitle).encode('utf-8')
+
                 .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 :
 
# vim: set expandtab tabstop=4 shiftwidth=4 :
Line 301: Line 372:
 
By getting the time zone information from the backend, each start time
 
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
 
in the output is converted to local time rather than the UTC returned
in the Services API. Another important concept in the above is the use
+
by the Services API. Another important concept in the above is the use
 
of ''print u'....'' followed by ''.encode('utf-8)''. That's critical
 
of ''print u'....'' followed by ''.encode('utf-8)''. That's critical
 
for Titles/SubTitles for example where there are characters like: ó.
 
for Titles/SubTitles for example where there are characters like: ó.
==The module used in the examples above==
+
===The module used in the examples above===
 
{{Python|Utilities.py|<pre>
 
{{Python|Utilities.py|<pre>
 
# -*- coding: utf-8 -*-
 
# -*- coding: utf-8 -*-
Line 311: Line 382:
  
 
from datetime  import datetime, timedelta
 
from datetime  import datetime, timedelta
from StringIO  import StringIO
 
from xml.etree import cElementTree as ET
 
  
import gzip, httplib, json, re, sys, urllib2
+
import re, sys
  
__version__ = '0.28.a1'
+
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()'
 
ServerVersion = 'Set to back/frontend version after calls to Send()'
  
def Send(method='GET', host='', port=6544, endpoint='', rest='', opts={}):
+
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
 
     Form a URL and send it to the back/frontend. Error handling is done
 
     here too.
 
     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:
 
     Input:
 
     ======
 
     ======
  
     method:   Set to 'POST' for endpoints that change data. Omit method (or
+
     host:     Must be set and is the hostname or IP of the back/frontend.
               set it to 'GET' for all other endpoints. Defaults to 'GET'.
+
 
 +
    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
  
     host:     Must be set and is the hostname or IP of the back/frontend.
+
     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.
  
     port:    Used if the backend is using a different port (unlikely) or
+
     rest:    May be set if the endpoint allows it. For example, endpoint=
               set to 6547 for frontend endpoints. Defaults to 6544.
+
              Myth/GetRecordedList, rest='Count=10&StorageGroup=Sports'
 +
               Don't use if postdata is used. The HTTP method will be a GET.
  
     endpoint: Should be set. Example: Myth/GetHostName
+
     opts      SHORT DESCRIPTION:
  
    rest:    May be set if the endpoint allows it. Example for
+
              It's OK to call this function without any options set and:
              Myth/GetRecordedList: rest='Count=10&StorageGroup=Sports'
 
  
    opts      A dictionary of options that may be set in the calling
+
                  • If there's postdata, nothing will be sent to the server
              program. Default values will be used if callers don't
+
                  • No "Debug:..." messages will print from this function
              pass their own.
+
                  • The server response will be gzipped and decompressed
  
               The defaults (typically changed by command line arguments) are
+
               DETAILS:
              all False.
 
  
    opts['debug']:  Set to True and the developed URL will be printed (for
+
              opts is a dictionary of options that may be set in the calling
                    debugging.)
+
              program. Default values will be used if callers don't pass all
 +
              or some of their own. The defaults are all False.
  
     opts['etree']: If True, the response returned TO THIS MODULE will be
+
     opts['debug']:   Set to True and some informational messages will be
                    in XML. If False, JSON will be returned.
+
                    printed.
  
                    N.B. JSON will then be loaded into Python Dictionary
+
    opts['nogzip']:  Don't request the back/frontend to gzip it's response.
                    format. XML will be in an Element Tree.
+
                    Useful if watching protocol with a tool that doesn't
 +
                    uncompress it.
  
     opts['nogzip']: Don't request the back/frontend to gzip it's response
+
     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 method='POST', the URL is actually sent
+
     opts['wrmi']:   If True and there is postdata, the URL is actually sent
                    to the server.
+
                    to the server.
  
                    If opts['wrmi']=False and method='POST' ***Nothing*** is
+
                    If opts['wrmi'] is False and there is postdata, *NOTHING*
                    sent to the server.
+
                    is sent to the server.
                   
 
                    This is a failsafe that allows testing. You can examine
 
                    what's about to be sent before doing it (wrmi = We Really
 
                    Mean It.)
 
  
                        The response is formatted based on the value of
+
                    This is a failsafe that allows testing. Users can examine
                        opts['etree'] (assume the caller chooses to
+
                    what's about to be sent before doing it (wrmi = We Really
                        put the response in r.)
+
                    Mean It.)
  
                        True;  r.tag = 'Warning', r.text = 'wrmi=False'
+
    opts['wsdl']:  If True return WSDL from the back/frontend. Accepts no rest
                        False; r['Warning'] = 'wrmi=False'
+
                    or postdata, only a service name, e.g. Myth, Video, Dvr...
  
 
     Output:
 
     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
  
     An error message and program exit, or the JSON response loaded into
+
     However, some errors returned by the server are in XML, e.g. if an
     a dictionary or the XML response in ElementTree format.
+
    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
 
     Whenever Send() is used, the global 'ServerVersion' is set to the value
     returned by the back/frontend in the HTTP Server: header. It is returned
+
     returned by the back/frontend in the HTTP Server: header. It is saved as
     as just the version, e.g. 0.28. Callers can check if and *may* choose to
+
     just the version, e.g. 0.28. Callers can check it and *may* choose to
 
     adjust their code work with other versions.
 
     adjust their code work with other versions.
  
    IF USING method='POST', TAKE EXTREME CAUTION!!! Use opts['wrmi']=False
 
    1st.
 
 
     """
 
     """
  
     optionList = [ 'debug', 'etree', 'nogzip', 'wrmi' ]
+
    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:
 
     for option in optionList:
Line 398: Line 532:
 
             opts[option]
 
             opts[option]
 
         except:
 
         except:
             print 'Warning: opts["{}"] is not set, using: False'.format(option)
+
             missingList = missingList + option + ', '
 
             opts[option] = False
 
             opts[option] = False
  
     #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!#
+
     if opts['debug'] and missingList:
    # The version should never be changed without testing.      #
+
        print('Debug: Missing opts set to False: {}'.format(missingList[:-2]))
    # 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.  #
 
    #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!#
 
 
 
    global ServerVersion
 
    version = '0.28'
 
  
 
     ##############################################################
 
     ##############################################################
     # If there's no endpoint leave now; otherwise, form the URL. #
+
     # Make sure there's an endpoint and optionally *either* resp #
 +
    # or postdata. If so, then form the URL.                     #
 
     ##############################################################
 
     ##############################################################
  
 
     if endpoint == '':
 
     if endpoint == '':
         sys.exit('\nAbort! No endpoint (e.g. Myth/GetHostName.)')
+
         return { 'Abort':'No endpoint (e.g. Myth/GetHostName.)' }
 +
 
 +
    if postdata and rest:
 +
        return { 'Abort':'Use either postdata or rest' }
  
     if rest == '': qmark = ''
+
     if rest == '': qmark = ''
     else:           qmark = '?'
+
     else:         qmark = '?'
  
 
     url='http://{}:{}/{}{}{}'.format(host, port, endpoint, qmark, rest)
 
     url='http://{}:{}/{}{}{}'.format(host, port, endpoint, qmark, rest)
  
 
     ##############################################################
 
     ##############################################################
     # Form the request to the API. Unless specified, an HTTP GET #
+
     # Create a session. If postdata was supplied and wrmi wasn't #
    # will be used, change it to a POST if asked. The debugging  #
+
     # set,then return immediately. Make sure postdata was passed #
    # provides the URL used and can be copied and tested with a  #
+
     # as a dict.                                                 #
     # browser or curl/wget if needed. Unless specified, requests #
 
     # will get a JSON response from the server. The User-Agent  #
 
    # header will show up in server logs with the value of the  #
 
    # version set earlier included. And finally, responses will  #
 
    # be gzipped by the server unless the nogzip option is True. #
 
 
     ##############################################################
 
     ##############################################################
  
     request = urllib2.Request(url)
+
     if not session:
    request.get_method = lambda: method
+
        session = requests.Session()
 +
        if opts['debug']: print('Debug: New session: {}'.format(session))
  
 
     if opts['debug']:
 
     if opts['debug']:
         print '\nDebug: URL = {} [{}]'.format(url, method)
+
         print('Debug: URL = {}'.format(url))
 +
        if postdata:
 +
            print('      postdata = {}'.format(postdata))
 +
 
 +
    if postdata and not opts['wrmi']:
 +
        return { 'Warning':'wrmi=False' }
  
     if method == 'POST' and not opts['wrmi']:
+
     if postdata:
         if opts['etree']:
+
         if not isinstance(postdata, dict):
            tree = ET.ElementTree(
+
             return { 'Abort':'usage: postdata must be passed as a dict' }
                    ET.fromstring('<Warning>wrmi=False</Warning>'))
 
            return tree.getroot()
 
        else:
 
             return { "Warning": "wrmi=False" }
 
  
     request = urllib2.Request(url)
+
     if opts['wsdl'] and (rest or postdata):
 +
            return { 'Abort':'usage: rest/postdata aren\'t allowed with WSDL' }
  
     if not opts['etree']:
+
     ##############################################################
        request.add_header('Accept', 'application/json')
+
    # Add the required headers. Adjust as requested by opts.    #
 +
    ##############################################################
  
     request.add_header('User-Agent', '{} Python Services API'.format(version))
+
     headers = {
 +
        'User-Agent':'{} Python Services API Client'.format(version),
 +
        'Accept':'application/json',
 +
        'Accept-Encoding':'gzip,deflate'
 +
    }
  
     if not opts['nogzip']:
+
     if opts['usexml']:
         request.add_header('Accept-Encoding', 'gzip,deflate')
+
        del headers['Accept']
 +
 
 +
    if opts['nogzip']:
 +
         headers['Accept-Encoding'] = ''
  
 
     ##############################################################
 
     ##############################################################
     # Actually try to get the data and handle errors. Get the    #
+
     # Actually try to get the data and handle errors.           #
    # Server: header and see if the MythTV version is recognized.#
 
    # Contents of server for 0.27 and 0.28 as of this writing:  #
 
    # 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  #
 
    # Then decompress the gzipped response if need be. Finally,  #
 
    # load the data into a dictionary (if JSON) or an Element    #
 
    # Tree (if XML.)                                            #
 
 
     ##############################################################
 
     ##############################################################
  
 
     try:
 
     try:
         response = urllib2.urlopen(request)
+
         if postdata:
     except urllib2.HTTPError, e:
+
            response = session.post(url, headers=headers, data=postdata)
         sys.exit('\nHTTP Error: {}. URL was:\n\t{}'.format(e.code, url))
+
        else:
     except urllib2.URLError, e:
+
            response = session.get(url, headers=headers)
         sys.exit('\nError: {}. URL was:\n\t{}'.format(e.args, url))
+
     except requests.exceptions.HTTPError:
     except httplib.UnknownProtocol, e:
+
         return { 'Abort':'HTTP Error. URL was: {}'.format(url) }
         sys.exit('Unknown Protocol: {}'.format(e.args))
+
    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:
 
     except:
         sys.exit('\nUndefined error. Is the backend running? URL was:\n\t{}.' \
+
         return { 'Abort':'Unexpected error: URL was: {}'.format(url) }
             .format(url))
+
 
 +
    if response.status_code > 299:
 +
        return { 'Abort':'Unexpected status returned: {}: URL was: {}'.format(
 +
             response.status_code, url) }
  
     server = response.info().get('Server')
+
    ##################################################################
 +
    # 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:
 
     if server == None:
         sys.exit('\nWarning: No HTTP "Server:" header returned.')
+
         return { 'Abort':'No HTTP "Server:" header returned.' }
 
     else:
 
     else:
 
         if re.search(version, server):
 
         if re.search(version, server):
Line 490: Line 643:
 
         elif re.search('0.27', server):
 
         elif re.search('0.27', server):
 
             ServerVersion = '0.27'
 
             ServerVersion = '0.27'
             print '\nWarning: {} Services API module may not work with {}.' \
+
             print('Warning: {} Services API module may not work with {}.' \
                 .format(version, ServerVersion)
+
                 .format(version, ServerVersion))
 
         else:
 
         else:
             sys.exit('Abort: Module only tested on 0.27 and 0.28, not: {}.' \
+
             return { 'Abort':'Tested on 0.27 & 0.28, not: {}.'.format(server) }
                .format(server))
 
  
     if response.info().get('Content-Encoding') == 'gzip':
+
     ##############################################################
        response_string = gzip.GzipFile(fileobj = StringIO(response.read()))
+
    # Finally, return the response after converting the JSON to  #
        unzipped_response = response_string.read()
+
    # a dict. Or, if the wesdl option is set return that         #
         if opts['debug']:
+
    ##############################################################
            print 'Debug: Received bytes = {}, Unzipped = {}' \
 
                .format(int(response.info().getheaders("Content-Length")[0]),
 
                    len(unzipped_response))
 
  
        if opts['etree']:
+
    if opts['wsdl']:
            return ET.fromstring(unzipped_response)
+
        return { 'WSDL':response.text}
        else:
+
 
            return json.loads(unzipped_response)
+
     if opts['debug']:
     else:
+
        print('Debug: 1st 60 bytes of response: {}'.format(response.text[:60]))
        if opts['etree']:
+
 
            return ET.fromstring(request)
+
    try:
        else:
+
        return response.json()
            return json.load(response)
+
    except Exception as err:
 +
        return { 'Abort':err }
  
 
def URLEncode(value=''):
 
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
 
     Input:  A string. E.g a program's title or anything that has
             special characters like ?, & and special UTF characters.
+
             special characters like ?, & and UTF characters beyond
 +
            the ASCII set.
  
 
     Output: The URL encoded string. E.g. ó becomes: %C3%B3 or ?
 
     Output: The URL encoded string. E.g. ó becomes: %C3%B3 or ?
Line 524: Line 681:
  
 
     if value == '':
 
     if value == '':
         print 'Warning: MythTVServicesAPI.URLEncode called without any value'
+
         print('Warning: Utilities.URLEncode() called without any value')
 
         return value
 
         return value
  
     return '{}'.format(urllib2.quote(value).encode('utf-8'))
+
     return quote(value)
  
def CreateFindTime(host='', port='', time='', opts={}):
+
def CreateFindTime(time=''):
 
     """
 
     """
     Normally be used to take a timestamp and convert it for use in adding
+
     Normally be used to take a starttime and convert it for use in adding
     new recordings. This always gets the UTCOffset, which the caller should
+
     new recordings. GetUTCOffset() should be called before this is, but
     probably do once and pass it to this function. But, since it's called
+
     that only needs to be done once. TODO: shouldn't this be removed and
     infrequently, just do it here every time.
+
     just use UTCToLocal with omityear=True?
  
 
     Input:  Full UTC timestamp, e.g. 2014-08-12T22:00:00 (with or without
 
     Input:  Full UTC timestamp, e.g. 2014-08-12T22:00:00 (with or without
Line 541: Line 698:
 
     Output: Time portion of the above in local time.
 
     Output: Time portion of the above in local time.
 
     """
 
     """
 +
 +
    global UTCOffset
  
 
     if time == '':
 
     if time == '':
         print 'Warning: MythTVServicesAPI.CreateFindTime called without any time'
+
         print('Warning: CreateFindTime called without any time')
 
         return None
 
         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', '')
 
     time = time.replace('Z', '')
 
    opts['etree'] = False
 
 
    resp_dict = Send(host=host, port=6544, endpoint='Myth/GetTimeZone', \
 
                            opts=opts)
 
 
    tzoffset = resp_dict['TimeZoneInfo']['UTCOffset']
 
    tzoffset = int(tzoffset)
 
  
 
     dt = datetime.strptime(time, '%Y-%m-%dT%H:%M:%S')
 
     dt = datetime.strptime(time, '%Y-%m-%dT%H:%M:%S')
  
     return (dt + timedelta(seconds = tzoffset)).strftime('%H:%M:%S')
+
     return (dt + timedelta(seconds = utc_offset)).strftime('%H:%M:%S')
  
def UTCToLocal(utctime='', tzoffset='', omityear=False):
+
def UTCToLocal(utctime='', omityear=False):
 
     """
 
     """
     Does exactly that conversion. This is likely to be called frequently
+
     Does exactly that conversion. GetUTCOffset() should be run once before
     (for example if printing guide timestamps) so force the caller to get
+
     calling this function. A UTC offset of 0 will be used if UTCOffset
     the TZ offset and pass it.
+
     isn't available, so the function won't abort.
  
 
     Inputs:  utctime  = Full UTC timestamp, e.g. 2014-08-12T22:00:00[Z].
 
     Inputs:  utctime  = Full UTC timestamp, e.g. 2014-08-12T22:00:00[Z].
            tzoffset = Offset from UTC. Probably from Myth/GetTimeZone.
 
 
             omityear = If True, then drop the 4 digit year and following -.
 
             omityear = If True, then drop the 4 digit year and following -.
  
 
     Output: Local time, also a string. Possibly without the year- and always
 
     Output: Local time, also a string. Possibly without the year- and always
             without the T between the data/time and trailing Z.
+
             without the T between the data/time and no trailing Z.
 
     """
 
     """
  
     if utctime == '' or tzoffset == '':
+
     global UTCOffset
         sys.exit('Abort, utctime and/or tzoffset are empty!.')
+
 
 +
    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', ' ')
 
     utctime = utctime.replace('Z', '').replace('T', ' ')
Line 584: Line 750:
 
     else:        fromstring = '%Y-%m-%d %H:%M:%S'
 
     else:        fromstring = '%Y-%m-%d %H:%M:%S'
  
     return (dt + timedelta(seconds = tzoffset)).strftime(fromstring)
+
     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
 
</pre>}}
 
</pre>}}
  
[[Category:Python Scripts]]
 
[[Category:Developer Documentation]]
 
 
[[Category:Services API]]
 
[[Category:Services API]]

Revision as of 19:15, 3 January 2016


Existing sources of information

  • 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!
  • 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 as the program(s) that use it, then include a line like this in programs that use it:

import Utilities as api

The as api is optional, but convenient. All examples in this page will assume this method (on the next.)

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 dist-packages directory (hardest)

Create a new directory next to the existing MythTV directory that might be found in in directories like these:

/usr/local/lib/python2.7/dist-packages/
/usr/lib/python2.7/dist-packages/

Create a new directory named (for example) MythTVServicesAPI there and put the Utilities.py (and an empty __init__.py) file there. Savy users will use a setup.py file to do this (and create .pyc files.)

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


PythonIcon.png simpleAPItest.py
#!/usr/bin/env python
import Utilities as api

resultDict = api.Send(host='localhost', endpoint='Myth/GetHostName', opts=opts)

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:


PythonIcon.png getTimeZoneInfo.py
#!/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.

PythonIcon.png shortPrintUpcoming.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# shortPrintUpcoming.py: Print Start Times, Titles and SubTitles

import os.path as path, sys, MythTVServicesAPI.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.

PythonIcon.png printUpcoming.py
#!/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

PythonIcon.png Utilities.py
# -*- 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