diff --git a/README.md b/README.md index b428126..70c171c 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# FakeHDHR_NextPVR \ No newline at end of file +# FakeHDHR_NextPVR + +(based off of original code from + + * [tvhProxy by jkaberg](https://github.com/jkaberg/tvhProxy) + * [locast2plex by tgorgdotcom](https://github.com/tgorgdotcom/locast2plex) + * myself coding for locast2plex + + ) + +Until I have time to do the wiki thing for this project, instructions will be in this `README.md`. + +PRs welcome for: + +* Docker support + + +Vague Instructions (specific details intentionally excluded): + +* Install ffmpeg, and verify it is accessible in PATH. Otherwise, you may specify it's path in your configuration later. +* Install Python3 and Python3-pip. There will be no support for Python2. +* Download the zip of the `master` branch, or `git clone`. +* `pip3 install -r requirements.txt` +* Copy the included configuration example to a known path, and adjust as needed. The script will look in the current directory for `config.ini`, but this can be specified with the commandline argument `--config_file=` diff --git a/data/cache/PLACEHOLDER b/data/cache/PLACEHOLDER new file mode 100644 index 0000000..e69de29 diff --git a/data/garamond.ttf b/data/garamond.ttf new file mode 100644 index 0000000..0a059a4 Binary files /dev/null and b/data/garamond.ttf differ diff --git a/data/www/favicon.ico b/data/www/favicon.ico new file mode 100644 index 0000000..9ec58da Binary files /dev/null and b/data/www/favicon.ico differ diff --git a/data/www/images/default-channel-thumb.png b/data/www/images/default-channel-thumb.png new file mode 100644 index 0000000..b228b0c Binary files /dev/null and b/data/www/images/default-channel-thumb.png differ diff --git a/data/www/images/default-content-thumb.png b/data/www/images/default-content-thumb.png new file mode 100644 index 0000000..b228b0c Binary files /dev/null and b/data/www/images/default-content-thumb.png differ diff --git a/epghandler/__init__.py b/epghandler/__init__.py new file mode 100644 index 0000000..2aa5430 --- /dev/null +++ b/epghandler/__init__.py @@ -0,0 +1,252 @@ +import os +import sys +import time +import datetime +from io import BytesIO +import json +import xml.etree.ElementTree as ET + +from . import zap2it + + +def sub_el(parent, name, text=None, **kwargs): + el = ET.SubElement(parent, name, **kwargs) + if text: + el.text = text + return el + + +def clean_exit(): + sys.stderr.flush() + sys.stdout.flush() + os._exit(0) + + +class EPGhandler(): + + def __init__(self, config, serviceproxy): + self.config = config.config + self.serviceproxy = serviceproxy + self.zapepg = zap2it.ZapEPG(config) + + self.epg_cache = None + + self.empty_cache_dir = config.config["main"]["empty_cache"] + self.empty_cache_file = config.config["main"]["empty_cache_file"] + + def get_epg(self): + if self.config["fakehdhr"]["epg_method"] == "empty": + epgdict = self.epg_cache_open() + elif self.config["fakehdhr"]["epg_method"] == "proxy": + epgdict = self.serviceproxy.epg_cache_open() + elif self.config["fakehdhr"]["epg_method"] == "zap2it": + epgdict = self.zapepg.epg_cache_open() + return epgdict + + def epg_cache_open(self): + epg_cache = None + if os.path.isfile(self.empty_cache_file): + with open(self.empty_cache_file, 'r') as epgfile: + epg_cache = json.load(epgfile) + return epg_cache + + def get_xmltv(self, base_url): + epgdict = self.get_epg() + if not epgdict: + return self.dummyxml() + + epg_method = self.config["fakehdhr"]["epg_method"] + + out = ET.Element('tv') + out.set('source-info-url', 'NextPVR') + out.set('source-info-name', 'NextPVR') + out.set('generator-info-name', 'FAKEHDHR') + out.set('generator-info-url', 'FAKEHDHR/FakeHDHR_NextPVR') + + for channel in list(epgdict.keys()): + c_out = sub_el(out, 'channel', id=epgdict[channel]['id']) + sub_el(c_out, 'display-name', + text='%s %s' % (epgdict[channel]['number'], epgdict[channel]['callsign'])) + sub_el(c_out, 'display-name', text=epgdict[channel]['number']) + sub_el(c_out, 'display-name', text=epgdict[channel]['callsign']) + + if epg_method == "empty": + sub_el(c_out, 'icon', src=("http://" + str(base_url) + str(epgdict[channel]['thumbnail']))) + elif epg_method == "proxy": + sub_el(c_out, 'icon', src=("http://" + str(base_url) + str(epgdict[channel]['thumbnail']))) + elif epg_method == "zap2it": + sub_el(c_out, 'icon', src=(str(epgdict[channel]['thumbnail']))) + else: + sub_el(c_out, 'icon', src=(str(epgdict[channel]['thumbnail']))) + + for channel in list(epgdict.keys()): + channel_listing = epgdict[channel]['listing'] + + for program in channel_listing: + + prog_out = sub_el(out, 'programme', + start=program['time_start'], + stop=program['time_end'], + channel=epgdict[channel]["id"]) + + if program['title']: + sub_el(prog_out, 'title', lang='en', text=program['title']) + + if 'movie' in program['genres'] and program['releaseyear']: + sub_el(prog_out, 'sub-title', lang='en', text='Movie: ' + program['releaseyear']) + elif program['episodetitle']: + sub_el(prog_out, 'sub-title', lang='en', text=program['episodetitle']) + + sub_el(prog_out, 'length', units='minutes', text=str(int(program['duration_minutes']))) + + for f in program['genres']: + sub_el(prog_out, 'category', lang='en', text=f) + sub_el(prog_out, 'genre', lang='en', text=f) + + if program["thumbnail"] is not None: + if epg_method == "empty": + sub_el(prog_out, 'icon', src=("http://" + str(base_url) + str(program['thumbnail']))) + elif epg_method == "proxy": + sub_el(prog_out, 'icon', src=("http://" + str(base_url) + str(program['thumbnail']))) + elif epg_method == "zap2it": + sub_el(prog_out, 'icon', src=(str(program['thumbnail']))) + else: + sub_el(prog_out, 'icon', src=(str(program['thumbnail']))) + + if program['rating']: + r = ET.SubElement(prog_out, 'rating') + sub_el(r, 'value', text=program['rating']) + + if 'seasonnumber' in list(program.keys()) and 'episodenumber' in list(program.keys()): + if program['seasonnumber'] and program['episodenumber']: + s_ = int(program['seasonnumber'], 10) + e_ = int(program['episodenumber'], 10) + sub_el(prog_out, 'episode-num', system='common', + text='S%02dE%02d' % (s_, e_)) + sub_el(prog_out, 'episode-num', system='xmltv_ns', + text='%d.%d.' % (int(s_)-1, int(e_)-1)) + sub_el(prog_out, 'episode-num', system='SxxExx">S', + text='S%02dE%02d' % (s_, e_)) + + # if 'New' in event['flag'] and 'live' not in event['flag']: + # sub_el(prog_out, 'new') + + fakefile = BytesIO() + fakefile.write(b'\n') + fakefile.write(ET.tostring(out, encoding='UTF-8')) + return fakefile.getvalue() + + def dummyxml(self): + out = ET.Element('tv') + out.set('source-info-url', 'NextPVR') + out.set('source-info-name', 'NextPVR') + out.set('generator-info-name', 'FAKEHDHR') + out.set('generator-info-url', 'FAKEHDHR/FakeHDHR_NextPVR') + + fakefile = BytesIO() + fakefile.write(b'\n') + fakefile.write(ET.tostring(out, encoding='UTF-8')) + return fakefile.getvalue() + + def update_epg(self): + print('Updating Empty EPG cache file.') + + programguide = {} + + timestamps = [] + todaydate = datetime.date.today() + for x in range(0, 6): + xdate = todaydate + datetime.timedelta(days=x) + xtdate = xdate + datetime.timedelta(days=1) + + for hour in range(0, 24): + time_start = datetime.datetime.combine(xdate, datetime.time(hour, 0)) + if hour + 1 < 24: + time_end = datetime.datetime.combine(xdate, datetime.time(hour + 1, 0)) + else: + time_end = datetime.datetime.combine(xtdate, datetime.time(0, 0)) + timestampdict = { + "time_start": str(time_start.strftime('%Y%m%d%H%M%S')) + " +0000", + "time_end": str(time_end.strftime('%Y%m%d%H%M%S')) + " +0000", + } + timestamps.append(timestampdict) + + for c in self.serviceproxy.get_channels(): + if str(c["formatted-number"]) not in list(programguide.keys()): + programguide[str(c["formatted-number"])] = {} + + channel_thumb_path = ("/images?source=empty&type=channel&id=%s" % (str(c['formatted-number']))) + programguide[str(c["formatted-number"])]["thumbnail"] = channel_thumb_path + + if "name" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["name"] = c["name"] + + if "callsign" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["callsign"] = c["name"] + + if "id" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["id"] = c["id"] + + if "number" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["number"] = c["formatted-number"] + + if "listing" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["listing"] = [] + + for timestamp in timestamps: + clean_prog_dict = {} + + clean_prog_dict["time_start"] = timestamp['time_start'] + clean_prog_dict["time_end"] = timestamp['time_end'] + clean_prog_dict["duration_minutes"] = 60.0 + + content_thumb = ("/images?source=empty&type=content&id=%s" % (str(c['formatted-number']))) + clean_prog_dict["thumbnail"] = content_thumb + + clean_prog_dict["title"] = "Unavailable" + + clean_prog_dict["genres"] = [] + + clean_prog_dict["sub-title"] = "Unavailable" + + clean_prog_dict['releaseyear'] = "" + clean_prog_dict["episodetitle"] = "Unavailable" + + clean_prog_dict["description"] = "Unavailable" + + clean_prog_dict['rating'] = "N/A" + + programguide[str(c["formatted-number"])]["listing"].append(clean_prog_dict) + + self.epg_cache = programguide + with open(self.empty_cache_file, 'w') as epgfile: + epgfile.write(json.dumps(programguide, indent=4)) + print('Wrote updated Empty EPG cache file.') + return programguide + + def update(self): + if self.config["fakehdhr"]["epg_method"] == "empty": + self.update_epg() + elif self.config["fakehdhr"]["epg_method"] == "proxy": + self.serviceproxy.update_epg() + elif self.config["fakehdhr"]["epg_method"] == "zap2it": + self.zapepg.update_epg() + + +def epgServerProcess(config, epghandling): + + if config.config["fakehdhr"]["epg_method"] == "empty": + sleeptime = config.config["main"]["empty_epg_update_frequency"] + elif config.config["fakehdhr"]["epg_method"] == "proxy": + sleeptime = config.config["nextpvr"]["epg_update_frequency"] + elif config.config["fakehdhr"]["epg_method"] == "zap2it": + sleeptime = config.config["zap2xml"]["epg_update_frequency"] + + try: + + while True: + epghandling.update() + time.sleep(sleeptime) + + except KeyboardInterrupt: + clean_exit() diff --git a/epghandler/zap2it.py b/epghandler/zap2it.py new file mode 100644 index 0000000..f3fa88f --- /dev/null +++ b/epghandler/zap2it.py @@ -0,0 +1,203 @@ +import os +import json +import time +import datetime +import urllib.error +import urllib.parse +import urllib.request + + +def xmltimestamp_zap(inputtime): + xmltime = inputtime.replace('Z', '+00:00') + xmltime = datetime.datetime.fromisoformat(xmltime) + xmltime = xmltime.strftime('%Y%m%d%H%M%S %z') + return xmltime + + +def duration_nextpvr_minutes(starttime, endtime): + return ((int(endtime) - int(starttime))/1000/60) + + +class ZapEPG(): + + def __init__(self, config): + + self.config = config.config + self.postalcode = config.config["zap2xml"]["postalcode"] + if not self.postalcode: + self.postalcode = self.get_location() + + self.epg_cache = None + self.cache_dir = config.config["main"]["zap_web_cache"] + self.epg_cache_file = config.config["zap2xml"]["epg_cache"] + self.epg_cache = self.epg_cache_open() + + def get_location(self): + url = 'http://ipinfo.io/json' + response = urllib.request.urlopen(url) + data = json.load(response) + return data["postal"] + + def epg_cache_open(self): + epg_cache = None + if os.path.isfile(self.epg_cache_file): + with open(self.epg_cache_file, 'r') as epgfile: + epg_cache = json.load(epgfile) + return epg_cache + + def get_cached(self, cache_key, delay, url): + cache_path = self.cache_dir.joinpath(cache_key) + if cache_path.is_file(): + print('FROM CACHE:', str(cache_path)) + with open(cache_path, 'rb') as f: + return f.read() + else: + print('Fetching: ', url) + try: + resp = urllib.request.urlopen(url) + result = resp.read() + except urllib.error.HTTPError as e: + if e.code == 400: + print('Got a 400 error! Ignoring it.') + result = ( + b'{' + b'"note": "Got a 400 error at this time, skipping.",' + b'"channels": []' + b'}') + else: + raise + with open(cache_path, 'wb') as f: + f.write(result) + return result + + def remove_stale_cache(self, todaydate): + for p in self.cache_dir.glob('*'): + try: + cachedate = datetime.datetime.strptime(str(p.name), "%Y-%m-%d") + todaysdate = datetime.datetime.strptime(str(todaydate), "%Y-%m-%d") + if cachedate >= todaysdate: + continue + except Exception as e: + print(e) + pass + print('Removing stale cache file:', p.name) + p.unlink() + + def update_epg(self): + print('Updating Zap2it EPG cache file.') + programguide = {} + + # Start time parameter is now rounded down to nearest `zap_timespan`, in s. + zap_time = time.mktime(time.localtime()) + zap_time_window = int(self.config["zap2xml"]["timespan"]) * 3600 + zap_time = int(zap_time - (zap_time % zap_time_window)) + + # Fetch data in `zap_timespan` chunks. + for i in range(int(7 * 24 / int(self.config["zap2xml"]["timespan"]))): + i_time = zap_time + (i * zap_time_window) + + parameters = { + 'aid': self.config["zap2xml"]['affiliate_id'], + 'country': self.config["zap2xml"]['country'], + 'device': self.config["zap2xml"]['device'], + 'headendId': self.config["zap2xml"]['headendid'], + 'isoverride': "true", + 'languagecode': self.config["zap2xml"]['languagecode'], + 'pref': 'm,p', + 'timespan': self.config["zap2xml"]['timespan'], + 'timezone': self.config["zap2xml"]['timezone'], + 'userId': self.config["zap2xml"]['userid'], + 'postalCode': self.config["zap2xml"]['postalcode'], + 'lineupId': '%s-%s-DEFAULT' % (self.config["zap2xml"]['country'], self.config["zap2xml"]['device']), + 'time': i_time, + 'Activity_ID': 1, + 'FromPage': "TV%20Guide", + } + + url = 'https://tvlistings.zap2it.com/api/grid?' + url += urllib.parse.urlencode(parameters) + + result = self.get_cached(str(i_time), self.config["zap2xml"]['delay'], url) + d = json.loads(result) + + for c in d['channels']: + + if str(c['channelNo']) not in list(programguide.keys()): + programguide[str(c['channelNo'])] = {} + + channel_thumb = str(c['thumbnail']).replace("//", "https://").split("?")[0] + programguide[str(c["channelNo"])]["thumbnail"] = channel_thumb + + if "name" not in list(programguide[str(c["channelNo"])].keys()): + programguide[str(c["channelNo"])]["name"] = c["callSign"] + + if "callsign" not in list(programguide[str(c["channelNo"])].keys()): + programguide[str(c["channelNo"])]["callsign"] = c["callSign"] + + if "id" not in list(programguide[str(c["channelNo"])].keys()): + programguide[str(c["channelNo"])]["id"] = c["channelId"] + + if "number" not in list(programguide[str(c["channelNo"])].keys()): + programguide[str(c["channelNo"])]["number"] = c["channelNo"] + + if "listing" not in list(programguide[str(c["channelNo"])].keys()): + programguide[str(c["channelNo"])]["listing"] = [] + + for event in c['events']: + clean_prog_dict = {} + + prog_in = event['program'] + + clean_prog_dict["time_start"] = xmltimestamp_zap(event['startTime']) + clean_prog_dict["time_end"] = xmltimestamp_zap(event['endTime']) + clean_prog_dict["duration_minutes"] = event['duration'] + + content_thumb = str("https://zap2it.tmsimg.com/assets/" + str(event['thumbnail']) + ".jpg") + clean_prog_dict["thumbnail"] = content_thumb + + if 'title' not in list(prog_in.keys()): + prog_in["title"] = "Unavailable" + elif not prog_in["title"]: + prog_in["title"] = "Unavailable" + clean_prog_dict["title"] = prog_in["title"] + + clean_prog_dict["genres"] = [] + if 'filter' in list(event.keys()): + for f in event['filter']: + clean_prog_dict["genres"].append(f.replace('filter-', '')) + + if 'filter-movie' in event['filter'] and prog_in['releaseYear']: + clean_prog_dict["sub-title"] = 'Movie: ' + prog_in['releaseYear'] + elif prog_in['episodeTitle']: + clean_prog_dict["sub-title"] = prog_in['episodeTitle'] + else: + clean_prog_dict["sub-title"] = "Unavailable" + + clean_prog_dict['releaseyear'] = prog_in['releaseYear'] + + if prog_in['shortDesc'] is None: + prog_in['shortDesc'] = "Unavailable" + clean_prog_dict["description"] = prog_in['shortDesc'] + + if 'rating' not in list(event.keys()): + event['rating'] = "N/A" + clean_prog_dict['rating'] = event['rating'] + + if 'season' in list(prog_in.keys()) and 'episode' in list(prog_in.keys()): + clean_prog_dict["seasonnumber"] = prog_in['season'] + clean_prog_dict["episodenumber"] = prog_in['episode'] + clean_prog_dict["episodetitle"] = clean_prog_dict["sub-title"] + else: + if "movie" not in clean_prog_dict["genres"]: + clean_prog_dict["episodetitle"] = clean_prog_dict["sub-title"] + + if 'New' in event['flag'] and 'live' not in event['flag']: + clean_prog_dict["isnew"] = True + + programguide[str(c["channelNo"])]["listing"].append(clean_prog_dict) + + self.epg_cache = programguide + with open(self.epg_cache_file, 'w') as epgfile: + epgfile.write(json.dumps(programguide, indent=4)) + print('Wrote updated Zap2it EPG cache file.') + return programguide diff --git a/fakehdhr/__init__.py b/fakehdhr/__init__.py new file mode 100644 index 0000000..e62d7a2 --- /dev/null +++ b/fakehdhr/__init__.py @@ -0,0 +1,362 @@ +from gevent.pywsgi import WSGIServer +from flask import (Flask, send_from_directory, request, Response, + abort, stream_with_context) +from io import BytesIO +import xml.etree.ElementTree as ET +import json +import time +import requests +import subprocess +import errno +import PIL.Image +import PIL.ImageDraw +import PIL.ImageFont + + +def sub_el(parent, name, text=None, **kwargs): + el = ET.SubElement(parent, name, **kwargs) + if text: + el.text = text + return el + + +def getSize(txt, font): + testImg = PIL.Image.new('RGB', (1, 1)) + testDraw = PIL.ImageDraw.Draw(testImg) + return testDraw.textsize(txt, font) + + +class HDHR_Hub(): + config = None + serviceproxy = None + epghandling = None + station_scan = False + station_list = [] + + def __init__(self): + self.station_scan = False + + def get_xmltv(self, base_url): + return self.epghandling.get_xmltv(base_url) + + def get_image(self, req_args): + imageid = req_args["id"] + + if req_args["source"] == "proxy": + if req_args["type"] == "channel": + imageUri = self.serviceproxy.get_channel_thumbnail(imageid) + elif req_args["type"] == "content": + imageUri = self.serviceproxy.get_content_thumbnail(imageid) + req = requests.get(imageUri) + return req.content + + elif req_args["source"] == "empty": + if req_args["type"] == "channel": + width = 360 + height = 270 + text = req_args["id"] + fontsize = 72 + elif req_args["type"] == "content": + width = 1080 + height = 1440 + fontsize = 100 + text = req_args["id"] + + colorBackground = "#228822" + colorText = "#717D7E" + colorOutline = "#717D7E" + fontname = str(self.config["fakehdhr"]["font"]) + + font = PIL.ImageFont.truetype(fontname, fontsize) + text_width, text_height = getSize(text, font) + img = PIL.Image.new('RGBA', (width+4, height+4), colorBackground) + d = PIL.ImageDraw.Draw(img) + d.text(((width-text_width)/2, (height-text_height)/2), text, fill=colorText, font=font) + d.rectangle((0, 0, width+3, height+3), outline=colorOutline) + + s = BytesIO() + img.save(s, 'png') + return s.getvalue() + + def get_xmldiscover(self, base_url): + out = ET.Element('root') + out.set('xmlns', "urn:schemas-upnp-org:device-1-0") + + sub_el(out, 'URLBase', "http://" + base_url) + + specVersion_out = sub_el(out, 'specVersion') + sub_el(specVersion_out, 'major', "1") + sub_el(specVersion_out, 'minor', "0") + + device_out = sub_el(out, 'device') + sub_el(device_out, 'deviceType', "urn:schemas-upnp-org:device:MediaServer:1") + sub_el(device_out, 'friendlyName', self.config["fakehdhr"]["friendlyname"]) + sub_el(device_out, 'manufacturer', "Silicondust") + sub_el(device_out, 'modelName', self.config["dev"]["reporting_model"]) + sub_el(device_out, 'modelNumber', self.config["dev"]["reporting_model"]) + sub_el(device_out, 'serialNumber') + sub_el(device_out, 'UDN', "uuid:" + self.config["main"]["uuid"]) + + fakefile = BytesIO() + fakefile.write(b'\n') + fakefile.write(ET.tostring(out, encoding='UTF-8')) + return fakefile.getvalue() + + def get_discover_json(self, base_url): + jsondiscover = { + "FriendlyName": self.config["fakehdhr"]["friendlyname"], + "Manufacturer": "Silicondust", + "ModelNumber": self.config["dev"]["reporting_model"], + "FirmwareName": self.config["dev"]["reporting_firmware_name"], + "TunerCount": self.config["fakehdhr"]["tuner_count"], + "FirmwareVersion": self.config["dev"]["reporting_firmware_ver"], + "DeviceID": self.config["main"]["uuid"], + "DeviceAuth": "nextpvrproxy", + "BaseURL": "http://" + base_url, + "LineupURL": "http://" + base_url + "/lineup.json" + } + return jsondiscover + + def get_lineup_status(self): + if self.station_scan: + channel_count = self.serviceproxy.get_station_total() + jsonlineup = { + "ScanInProgress": "true", + "Progress": 99, + "Found": channel_count + } + else: + jsonlineup = { + "ScanInProgress": "false", + "ScanPossible": "true", + "Source": self.config["dev"]["reporting_tuner_type"], + "SourceList": [self.config["dev"]["reporting_tuner_type"]], + } + return jsonlineup + + def get_lineup_xml(self, base_url): + out = ET.Element('Lineup') + station_list = self.serviceproxy.get_station_list(base_url) + for station_item in station_list: + program_out = sub_el(out, 'Program') + sub_el(program_out, 'GuideNumber', station_item['GuideNumber']) + sub_el(program_out, 'GuideName', station_item['GuideName']) + sub_el(program_out, 'URL', station_item['URL']) + + fakefile = BytesIO() + fakefile.write(b'\n') + fakefile.write(ET.tostring(out, encoding='UTF-8')) + return fakefile.getvalue() + + def get_debug(self, base_url): + debugjson = { + "base_url": base_url, + } + return debugjson + + def get_html_error(self, message): + htmlerror = """ + + +

{}

+ + """ + return htmlerror.format(message) + + def station_scan_change(self, enablement): + self.station_scan = enablement + + +hdhr = HDHR_Hub() + + +class HDHR_HTTP_Server(): + app = Flask(__name__,) + + @app.route('/') + def root_path(): + return hdhr.config["fakehdhr"]["friendlyname"] + + @app.route('/favicon.ico', methods=['GET']) + def favicon(): + return send_from_directory(hdhr.config["main"]["www_dir"], + 'favicon.ico', + mimetype='image/vnd.microsoft.icon') + + @app.route('/device.xml', methods=['GET']) + def device_xml(): + base_url = request.headers["host"] + devicexml = hdhr.get_xmldiscover(base_url) + return Response(status=200, + response=devicexml, + mimetype='application/xml') + + @app.route('/discover.json', methods=['GET']) + def discover_json(): + base_url = request.headers["host"] + jsondiscover = hdhr.get_discover_json(base_url) + return Response(status=200, + response=json.dumps(jsondiscover, indent=4), + mimetype='application/json') + + @app.route('/lineup_status.json', methods=['GET']) + def lineup_status_json(): + linup_status_json = hdhr.get_lineup_status() + return Response(status=200, + response=json.dumps(linup_status_json, indent=4), + mimetype='application/json') + + @app.route('/lineup.xml', methods=['GET']) + def lineup_xml(): + base_url = request.headers["host"] + lineupxml = hdhr.get_lineup_xml(base_url) + return Response(status=200, + response=lineupxml, + mimetype='application/xml') + + @app.route('/lineup.json', methods=['GET']) + def lineup_json(): + base_url = request.headers["host"] + station_list = hdhr.serviceproxy.get_station_list(base_url) + return Response(status=200, + response=json.dumps(station_list, indent=4), + mimetype='application/json') + + @app.route('/xmltv.xml', methods=['GET']) + def xmltv_xml(): + base_url = request.headers["host"] + xmltv = hdhr.get_xmltv(base_url) + return Response(status=200, + response=xmltv, + mimetype='application/xml') + + @app.route('/debug.json', methods=['GET']) + def debug_json(): + base_url = request.headers["host"] + debugreport = hdhr.get_debug(base_url) + return Response(status=200, + response=json.dumps(debugreport, indent=4), + mimetype='application/json') + + @app.route('/images', methods=['GET']) + def images_nothing(): + if ('source' not in list(request.args.keys()) or 'id' not in list(request.args.keys()) or 'type' not in list(request.args.keys())): + abort(404) + + image = hdhr.get_image(request.args) + return Response(image, content_type='image/png', direct_passthrough=True) + + @app.route('/watch', methods=['GET']) + def watch_nothing(): + if 'method' in list(request.args.keys()): + if 'channel' in list(request.args.keys()): + + station_list = hdhr.serviceproxy.get_channel_streams() + channelUri = station_list[str(request.args["channel"])] + if not channelUri: + abort(404) + + if request.args["method"] == "direct": + duration = request.args.get('duration', default=0, type=int) + + if not duration == 0: + duration += time.time() + + req = requests.get(channelUri, stream=True) + + def generate(): + yield '' + for chunk in req.iter_content(chunk_size=hdhr.config["direct_stream"]['chunksize']): + if not duration == 0 and not time.time() < duration: + req.close() + break + yield chunk + + return Response(generate(), content_type=req.headers['content-type'], direct_passthrough=True) + + if request.args["method"] == "ffmpeg": + + ffmpeg_command = [hdhr.config["ffmpeg"]["ffmpeg_path"], + "-i", channelUri, + "-c", "copy", + "-f", "mpegts", + "-nostats", "-hide_banner", + "-loglevel", "warning", + "pipe:stdout" + ] + ffmpeg_proc = subprocess.Popen(ffmpeg_command, stdout=subprocess.PIPE) + + def generate(): + + videoData = ffmpeg_proc.stdout.read(int(hdhr.config["ffmpeg"]["bytes_per_read"])) + + while True: + if not videoData: + break + else: + # from https://stackoverflow.com/questions/9932332 + try: + yield videoData + time.sleep(0.1) + except IOError as e: + # Check we hit a broken pipe when trying to write back to the client + if e.errno == errno.EPIPE: + # Send SIGTERM to shutdown ffmpeg + ffmpeg_proc.terminate() + # ffmpeg writes a bit of data out to stderr after it terminates, + # need to read any hanging data to prevent a zombie process. + ffmpeg_proc.communicate() + break + else: + raise + + videoData = ffmpeg_proc.stdout.read(int(hdhr.config["ffmpeg"]["bytes_per_read"])) + + ffmpeg_proc.terminate() + try: + ffmpeg_proc.communicate() + except ValueError: + print("Connection Closed") + + return Response(stream_with_context(generate()), mimetype="audio/mpeg") + abort(404) + + @app.route('/lineup.post', methods=['POST']) + def lineup_post(): + if 'scan' in list(request.args.keys()): + if request.args['scan'] == 'start': + hdhr.station_scan_change(True) + hdhr.station_list = [] + hdhr.station_scan_change(False) + return Response(status=200, mimetype='text/html') + + elif request.args['scan'] == 'abort': + return Response(status=200, mimetype='text/html') + + else: + print("Unknown scan command " + request.args['scan']) + currenthtmlerror = hdhr.get_html_error("501 - " + request.args['scan'] + " is not a valid scan command") + return Response(status=200, response=currenthtmlerror, mimetype='text/html') + + else: + currenthtmlerror = hdhr.get_html_error("501 - not a valid command") + return Response(status=200, response=currenthtmlerror, mimetype='text/html') + + def __init__(self, config): + self.config = config.config + + def run(self): + http = WSGIServer(( + self.config["fakehdhr"]["address"], + int(self.config["fakehdhr"]["port"]) + ), self.app.wsgi_app) + http.serve_forever() + + +def interface_start(config, serviceproxy, epghandling): + hdhr.config = config.config + hdhr.station_scan = False + hdhr.serviceproxy = serviceproxy + hdhr.epghandling = epghandling + fakhdhrserver = HDHR_HTTP_Server(config) + fakhdhrserver.run() diff --git a/fhdhrconfig/__init__.py b/fhdhrconfig/__init__.py new file mode 100644 index 0000000..01e3401 --- /dev/null +++ b/fhdhrconfig/__init__.py @@ -0,0 +1,170 @@ +import os +import sys +import random +import configparser +import pathlib + + +def clean_exit(): + sys.stderr.flush() + sys.stdout.flush() + os._exit(0) + + +class HDHRConfig(): + + config_file = None + config_handler = configparser.ConfigParser() + script_dir = None + + config = { + "main": { + 'uuid': None, + "cache_dir": None, + "empty_epg_update_frequency": 43200, + }, + "nextpvr": { + "address": "localhost", + "port": 8866, + "ssl": False, + "pin": None, + "weight": 300, # subscription priority + "sidfile": None, + "epg_update_frequency": 43200, + }, + "fakehdhr": { + "address": "0.0.0.0", + "port": 5004, + "discovery_address": "0.0.0.0", + "tuner_count": 4, # number of tuners in tvh + "concurrent_listeners": 10, + "friendlyname": "fHDHR-NextPVR", + "stream_type": "direct", + "epg_method": "proxy", + "font": None, + }, + "zap2xml": { + "delay": 5, + "postalcode": None, + "affiliate_id": 'gapzap', + "country": 'USA', + "device": '-', + "headendid": "lineupId", + "isoverride": True, + "languagecode": 'en', + "pref": "", + "timespan": 6, + "timezone": "", + "userid": "-", + "epg_update_frequency": 43200, + }, + "ffmpeg": { + 'ffmpeg_path': "ffmpeg", + 'bytes_per_read': '1152000', + "font": None, + }, + "direct_stream": { + 'chunksize': 1024*1024 # usually you don't need to edit this + }, + "dev": { + 'reporting_model': 'HDHR4-2DT', + 'reporting_firmware_name': 'hdhomerun4_dvbt', + 'reporting_firmware_ver': '20150826', + 'reporting_tuner_type': "Antenna", + } + } + + def __init__(self, script_dir, args): + self.get_config_path(script_dir, args) + self.import_config() + self.config_adjustments(script_dir) + + def get_config_path(self, script_dir, args): + if args.cfg: + self.config_file = pathlib.Path(str(args.cfg)) + if not self.config_file or not os.path.exists(self.config_file): + print("Config file missing, Exiting...") + clean_exit() + print("Loading Configuration File: " + str(self.config_file)) + + def import_config(self): + self.config_handler.read(self.config_file) + for each_section in self.config_handler.sections(): + if each_section not in list(self.config.keys()): + self.config[each_section] = {} + for (each_key, each_val) in self.config_handler.items(each_section): + self.config[each_section.lower()][each_key.lower()] = each_val + + def write(self, section, key, value): + self.config[section][key] = value + self.config_handler.set(section, key, value) + + with open(self.config_file, 'w') as config_file: + self.config_handler.write(config_file) + + def config_adjustments(self, script_dir): + + self.config["main"]["script_dir"] = script_dir + + data_dir = pathlib.Path(script_dir).joinpath('data') + self.config["main"]["data_dir"] = data_dir + + self.config["fakehdhr"]["font"] = pathlib.Path(data_dir).joinpath('garamond.ttf') + + if not self.config["main"]["cache_dir"]: + self.config["main"]["cache_dir"] = pathlib.Path(data_dir).joinpath('cache') + else: + self.config["main"]["cache_dir"] = pathlib.Path(self.config["main"]["cache_dir"]) + if not self.config["main"]["cache_dir"].is_dir(): + print("Invalid Cache Directory. Exiting...") + clean_exit() + cache_dir = self.config["main"]["cache_dir"] + + if not self.config["nextpvr"]["pin"]: + print("NextPVR Login Credentials Missing. Exiting...") + clean_exit() + + empty_cache = pathlib.Path(cache_dir).joinpath('empty_cache') + self.config["main"]["empty_cache"] = empty_cache + if not empty_cache.is_dir(): + empty_cache.mkdir() + self.config["main"]["empty_cache_file"] = pathlib.Path(empty_cache).joinpath('epg.json') + + nextpvr_cache = pathlib.Path(cache_dir).joinpath('nextpvr') + self.config["main"]["nextpvr_cache"] = nextpvr_cache + if not nextpvr_cache.is_dir(): + nextpvr_cache.mkdir() + self.config["nextpvr"]["sidfile"] = pathlib.Path(nextpvr_cache).joinpath('sid.txt') + self.config["nextpvr"]["epg_cache"] = pathlib.Path(nextpvr_cache).joinpath('epg.json') + + zap_cache = pathlib.Path(cache_dir).joinpath('zap2it') + self.config["main"]["zap_cache"] = zap_cache + if not zap_cache.is_dir(): + zap_cache.mkdir() + self.config["zap2xml"]["epg_cache"] = pathlib.Path(zap_cache).joinpath('epg.json') + zap_web_cache = pathlib.Path(zap_cache).joinpath('zap_web_cache') + self.config["main"]["zap_web_cache"] = zap_web_cache + if not zap_web_cache.is_dir(): + zap_web_cache.mkdir() + + www_dir = pathlib.Path(data_dir).joinpath('www') + self.config["main"]["www_dir"] = www_dir + self.config["main"]["favicon"] = pathlib.Path(www_dir).joinpath('favicon.ico') + + www_image_dir = pathlib.Path(www_dir).joinpath('images') + self.config["main"]["www_image_dir"] = www_image_dir + self.config["main"]["image_def_channel"] = pathlib.Path(www_image_dir).joinpath("default-channel-thumb.png") + self.config["main"]["image_def_content"] = pathlib.Path(www_image_dir).joinpath("default-content-thumb.png") + + # generate UUID here for when we are not using docker + if self.config["main"]["uuid"] is None: + print("No UUID found. Generating one now...") + # from https://pynative.com/python-generate-random-string/ + # create a string that wouldn't be a real device uuid for + self.config["main"]["uuid"] = ''.join(random.choice("hijklmnopqrstuvwxyz") for i in range(8)) + self.write('main', 'uuid', self.config["main"]["uuid"]) + print("UUID set to: " + self.config["main"]["uuid"] + "...") + + print("Server is set to run on " + + str(self.config["fakehdhr"]["address"]) + ":" + + str(self.config["fakehdhr"]["port"])) diff --git a/main.py b/main.py new file mode 100644 index 0000000..d09b739 --- /dev/null +++ b/main.py @@ -0,0 +1,65 @@ +import os +import sys +import pathlib +import argparse +from multiprocessing import Process + +import fhdhrconfig +import proxyservice +import fakehdhr +import epghandler +import ssdpserver + +if sys.version_info.major == 2 or sys.version_info < (3, 3): + print('Error: FakeHDHR requires python 3.3+.') + sys.exit(1) + + +def get_args(): + parser = argparse.ArgumentParser(description='FakeHDHR.', epilog='') + parser.add_argument('--config_file', dest='cfg', type=str, default=None, help='') + return parser.parse_args() + + +def clean_exit(): + sys.stderr.flush() + sys.stdout.flush() + os._exit(0) + + +if __name__ == '__main__': + + # Gather args + args = get_args() + + # set to directory of script + script_dir = pathlib.Path(os.path.dirname(os.path.abspath(__file__))) + + # Open Configuration File + print("Opening and Verifying Configuration File.") + config = fhdhrconfig.HDHRConfig(script_dir, args) + + # Open proxyservice + serviceproxy = proxyservice.proxyserviceFetcher(config) + + # Open EPG Handler + epghandling = epghandler.EPGhandler(config, serviceproxy) + + try: + + print("Starting EPG thread...") + epgServer = Process(target=epghandler.epgServerProcess, args=(config, epghandling)) + epgServer.start() + + print("Starting fHDHR Interface") + fhdhrServer = Process(target=fakehdhr.interface_start, args=(config, serviceproxy, epghandling)) + fhdhrServer.start() + + print("Starting SSDP server...") + ssdpServer = Process(target=ssdpserver.ssdpServerProcess, args=(config,)) + ssdpServer.daemon = True + ssdpServer.start() + + except KeyboardInterrupt: + print('^C received, shutting down the server') + clean_exit() diff --git a/proxyservice/__init__.py b/proxyservice/__init__.py new file mode 100644 index 0000000..af52498 --- /dev/null +++ b/proxyservice/__init__.py @@ -0,0 +1,292 @@ +import os +import xmltodict +import json +import hashlib +import datetime +import urllib.error +import urllib.parse +import urllib.request + + +class NextPVR_Auth(): + config = { + 'npvrURL': '', + 'npvrSID': '', + 'npvrPIN': '', + } + sidfile = None + + def __init__(self, config): + self.sidfile = config.config["nextpvr"]["sidfile"] + self.config["npvrPIN"] = config.config["nextpvr"]["pin"] + self.config["npvrURL"] = ('%s%s:%s' % + ("https://" if config.config["nextpvr"]["ssl"] else "http://", + config.config["nextpvr"]["address"], + str(config.config["nextpvr"]["port"]), + )) + + def _check_sid(self): + if 'sid' not in self.config: + if os.path.isfile(self.sidfile): + with open(self.sidfile, 'r') as text_file: + self.config['sid'] = text_file.read() + print('Read SID from file.') + else: + self._get_sid() + + return True + + def _get_sid(self): + sid = '' + salt = '' + clientKey = '' + + initiate_url = "%s/service?method=session.initiate&ver=1.0&device=fhdhr" % self.config['npvrURL'] + + initiate_req = urllib.request.urlopen(initiate_url) + initiate_dict = xmltodict.parse(initiate_req) + + sid = initiate_dict['rsp']['sid'] + salt = initiate_dict['rsp']['salt'] + md5PIN = hashlib.md5(self.config['npvrPIN'].encode('utf-8')).hexdigest() + string = ':%s:%s' % (md5PIN, salt) + clientKey = hashlib.md5(string.encode('utf-8')).hexdigest() + + login_url = '%s/service?method=session.login&sid=%s&md5=%s' % (self.config['npvrURL'], sid, clientKey) + login_req = urllib.request.urlopen(login_url) + login_dict = xmltodict.parse(login_req) + + if login_dict['rsp']['allow_watch'] == "true": + self.config['sid'] = sid + with open(self.sidfile, 'w') as text_file: + text_file.write(self.config['sid']) + print('Wrote SID to file.') + else: + print("NextPVR Login Failed") + self.config['sid'] = '' + + +def xmltimestamp_nextpvr(epochtime): + xmltime = datetime.datetime.fromtimestamp(int(epochtime)/1000) + xmltime = str(xmltime.strftime('%Y%m%d%H%M%S')) + " +0000" + return xmltime + + +def duration_nextpvr_minutes(starttime, endtime): + return ((int(endtime) - int(starttime))/1000/60) + + +class proxyserviceFetcher(): + + def __init__(self, config): + self.config = config.config + + self.epg_cache = None + self.epg_cache_file = config.config["nextpvr"]["epg_cache"] + + self.servicename = "NextPVRProxy" + + self.urls = {} + self.url_assembler() + + self.auth = NextPVR_Auth(config) + + self.epg_cache = self.epg_cache_open() + + def epg_cache_open(self): + epg_cache = None + if os.path.isfile(self.epg_cache_file): + with open(self.epg_cache_file, 'r') as epgfile: + epg_cache = json.load(epgfile) + return epg_cache + + def url_assembler(self): + pass + + def get_channels(self): + self.auth._check_sid() + + url = ('%s%s:%s/service?method=channel.list&sid=%s' % + ("https://" if self.config["nextpvr"]["ssl"] else "http://", + self.config["nextpvr"]["address"], + str(self.config["nextpvr"]["port"]), + self.auth.config['sid'] + )) + + r = urllib.request.urlopen(url) + data_dict = xmltodict.parse(r) + + if 'channels' not in list(data_dict['rsp'].keys()): + print("could not retrieve channel list") + return [] + + channel_o_list = data_dict['rsp']['channels']['channel'] + + channel_list = [] + for c in channel_o_list: + dString = json.dumps(c) + channel_dict = eval(dString) + channel_list.append(channel_dict) + return channel_list + + def get_station_list(self, base_url): + station_list = [] + + for c in self.get_channels(): + if self.config["fakehdhr"]["stream_type"] == "ffmpeg": + watchtype = "ffmpeg" + else: + watchtype = "direct" + url = ('%s%s/watch?method=%s&channel=%s' % + ("http://", + base_url, + watchtype, + c['formatted-number'] + )) + station_list.append( + { + 'GuideNumber': str(c['formatted-number']), + 'GuideName': c['name'], + 'URL': url + }) + return station_list + + def get_station_total(self): + total_channels = 0 + for c in self.get_channels(): + total_channels += 1 + return total_channels + + def get_channel_streams(self): + streamdict = {} + for c in self.get_channels(): + url = ('%s%s:%s/live?channel=%s&client=%s' % + ("https://" if self.config["nextpvr"]["ssl"] else "http://", + self.config["nextpvr"]["address"], + str(self.config["nextpvr"]["port"]), + str(c["formatted-number"]), + str(c["formatted-number"]), + )) + streamdict[str(c["formatted-number"])] = url + return streamdict + + def get_channel_thumbnail(self, channel_id): + channel_thumb_url = ("%s%s:%s/service?method=channel.icon&channel_id=%s" % + ("https://" if self.config["nextpvr"]["ssl"] else "http://", + self.config["nextpvr"]["address"], + str(self.config["nextpvr"]["port"]), + str(channel_id) + )) + return channel_thumb_url + + def get_content_thumbnail(self, content_id): + self.auth._check_sid() + item_thumb_url = ("%s%s:%s/service?method=channel.show.artwork&sid=%s&event_id=%s" % + ("https://" if self.config["nextpvr"]["ssl"] else "http://", + self.config["nextpvr"]["address"], + str(self.config["nextpvr"]["port"]), + self.auth.config['sid'], + str(content_id) + )) + return item_thumb_url + + def update_epg(self): + print('Updating NextPVR EPG cache file.') + self.auth._check_sid() + + programguide = {} + + for c in self.get_channels(): + if str(c["formatted-number"]) not in list(programguide.keys()): + programguide[str(c["formatted-number"])] = {} + + channel_thumb_path = ("/images?source=proxy&type=channel&id=%s" % (str(c['id']))) + programguide[str(c["formatted-number"])]["thumbnail"] = channel_thumb_path + + if "name" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["name"] = c["name"] + + if "callsign" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["callsign"] = c["name"] + + if "id" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["id"] = c["id"] + + if "number" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["number"] = c["formatted-number"] + + if "listing" not in list(programguide[str(c["formatted-number"])].keys()): + programguide[str(c["formatted-number"])]["listing"] = [] + + epg_url = ('%s%s:%s/service?method=channel.listings&channel_id=%s' % + ("https://" if self.config["nextpvr"]["ssl"] else "http://", + self.config["nextpvr"]["address"], + str(self.config["nextpvr"]["port"]), + str(c["id"]), + )) + epg_req = urllib.request.urlopen(epg_url) + epg_dict = xmltodict.parse(epg_req) + + for program_listing in epg_dict["rsp"]["listings"]: + for program_item in epg_dict["rsp"]["listings"][program_listing]: + if not isinstance(program_item, str): + dirty_prog_dict = {} + for programkey in list(program_item.keys()): + dirty_prog_dict[programkey] = program_item[programkey] + + clean_prog_dict = {} + + clean_prog_dict["time_start"] = xmltimestamp_nextpvr(dirty_prog_dict["start"]) + clean_prog_dict["time_end"] = xmltimestamp_nextpvr(dirty_prog_dict["end"]) + clean_prog_dict["duration_minutes"] = duration_nextpvr_minutes(dirty_prog_dict["start"], dirty_prog_dict["end"]) + + item_thumb_path = ("/images?source=proxy&type=content&id=%s" % (str(dirty_prog_dict['id']))) + clean_prog_dict["thumbnail"] = item_thumb_path + + if 'name' not in list(dirty_prog_dict.keys()): + dirty_prog_dict["name"] = "Unavailable" + elif not dirty_prog_dict["name"]: + dirty_prog_dict["name"] = "Unavailable" + clean_prog_dict["title"] = dirty_prog_dict["name"] + + if 'genre' not in list(dirty_prog_dict.keys()): + clean_prog_dict["genres"] = [] + else: + clean_prog_dict["genres"] = dirty_prog_dict['genre'].split(",") + + if 'subtitle' not in list(dirty_prog_dict.keys()): + dirty_prog_dict["subtitle"] = "Unavailable" + clean_prog_dict["sub-title"] = dirty_prog_dict["subtitle"] + + if dirty_prog_dict['subtitle'].startswith("Movie:"): + clean_prog_dict['releaseyear'] = dirty_prog_dict['subtitle'].split("Movie:")[-1] + else: + clean_prog_dict['releaseyear'] = None + + if 'description' not in list(dirty_prog_dict.keys()): + dirty_prog_dict["description"] = "Unavailable" + elif dirty_prog_dict['description']: + dirty_prog_dict["description"] = "Unavailable" + clean_prog_dict["description"] = dirty_prog_dict["description"] + + if 'rating' not in list(dirty_prog_dict.keys()): + dirty_prog_dict['rating'] = "N/A" + clean_prog_dict['rating'] = dirty_prog_dict['rating'] + + if 'season' in list(dirty_prog_dict.keys()) and 'episode' in list(dirty_prog_dict.keys()): + clean_prog_dict["seasonnumber"] = dirty_prog_dict['season'] + clean_prog_dict["episodenumber"] = dirty_prog_dict['episode'] + clean_prog_dict["episodetitle"] = clean_prog_dict["sub-title"] + else: + if "movie" not in clean_prog_dict["genres"]: + clean_prog_dict["episodetitle"] = clean_prog_dict["sub-title"] + + # TODO isNEW + + programguide[str(c["formatted-number"])]["listing"].append(clean_prog_dict) + + self.epg_cache = programguide + with open(self.epg_cache_file, 'w') as epgfile: + epgfile.write(json.dumps(programguide, indent=4)) + print('Wrote updated NextPVR EPG cache file.') + return programguide diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6fcb3c2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests +gevent +flask +image diff --git a/ssdpserver/__init__.py b/ssdpserver/__init__.py new file mode 100644 index 0000000..281fd88 --- /dev/null +++ b/ssdpserver/__init__.py @@ -0,0 +1,239 @@ +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# Copyright 2005, Tim Potter +# Copyright 2006 John-Mark Gurney +# Copyright (C) 2006 Fluendo, S.A. (www.fluendo.com). +# Copyright 2006,2007,2008,2009 Frank Scholz +# Copyright 2016 Erwan Martin +# +# Implementation of a SSDP server. +# + +import random +import time +import socket +import logging +from email.utils import formatdate +from errno import ENOPROTOOPT + +SSDP_ADDR = '239.255.255.250' + + +logger = logging.getLogger() + + +# mostly from https://github.com/ZeWaren/python-upnp-ssdp-example +def ssdpServerProcess(config): + ssdp = SSDPServer() + ssdp.ssdp_port = 1900 + ssdp.register('local', + 'uuid:' + config.config["main"]["uuid"] + '::upnp:rootdevice', + 'upnp:rootdevice', + 'http://' + config.config["fakehdhr"]["discovery_address"] + ':' + + config.config["fakehdhr"]["port"] + '/device.xml') + try: + ssdp.run() + except KeyboardInterrupt: + pass + + +class SSDPServer: + """A class implementing a SSDP server. The notify_received and + searchReceived methods are called when the appropriate type of + datagram is received by the server.""" + known = {} + + def __init__(self): + self.sock = None + + def run(self): + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if hasattr(socket, "SO_REUSEPORT"): + try: + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except socket.error as le: + # RHEL6 defines SO_REUSEPORT but it doesn't work + if le.errno == ENOPROTOOPT: + pass + else: + raise + + addr = socket.inet_aton(SSDP_ADDR) + interface = socket.inet_aton('0.0.0.0') + cmd = socket.IP_ADD_MEMBERSHIP + self.sock.setsockopt(socket.IPPROTO_IP, cmd, addr + interface) + self.sock.bind(('0.0.0.0', self.ssdp_port)) + self.sock.settimeout(1) + + while True: + try: + data, addr = self.sock.recvfrom(1024) + self.datagram_received(data, addr) + except socket.timeout: + continue + self.shutdown() + + def shutdown(self): + for st in self.known: + if self.known[st]['MANIFESTATION'] == 'local': + self.do_byebye(st) + + def datagram_received(self, data, host_port): + """Handle a received multicast datagram.""" + + (host, port) = host_port + + try: + header, payload = data.decode().split('\r\n\r\n')[:2] + except ValueError as err: + logger.error(err) + return + + lines = header.split('\r\n') + cmd = lines[0].split(' ') + lines = [x.replace(': ', ':', 1) for x in lines[1:]] + lines = [x for x in lines if len(x) > 0] + + headers = [x.split(':', 1) for x in lines] + headers = dict([(x[0].lower(), x[1]) for x in headers]) + + logger.info('SSDP command %s %s - from %s:%d' % (cmd[0], cmd[1], host, port)) + logger.debug('with headers: {}.'.format(headers)) + if cmd[0] == 'M-SEARCH' and cmd[1] == '*': + # SSDP discovery + self.discovery_request(headers, (host, port)) + elif cmd[0] == 'NOTIFY' and cmd[1] == '*': + # SSDP presence + logger.debug('NOTIFY *') + else: + logger.warning('Unknown SSDP command %s %s' % (cmd[0], cmd[1])) + + def register(self, manifestation, usn, st, location, cache_control='max-age=1800', silent=False, + host=None): + """Register a service or device that this SSDP server will + respond to.""" + + logging.info('Registering %s (%s)' % (st, location)) + + self.known[usn] = {} + self.known[usn]['USN'] = usn + self.known[usn]['LOCATION'] = location + self.known[usn]['ST'] = st + self.known[usn]['EXT'] = '' + self.known[usn]['SERVER'] = "fHDHR Server" + self.known[usn]['CACHE-CONTROL'] = cache_control + + self.known[usn]['MANIFESTATION'] = manifestation + self.known[usn]['SILENT'] = silent + self.known[usn]['HOST'] = host + self.known[usn]['last-seen'] = time.time() + + if manifestation == 'local' and self.sock: + self.do_notify(usn) + + def unregister(self, usn): + logger.info("Un-registering %s" % usn) + del self.known[usn] + + def is_known(self, usn): + return usn in self.known + + def send_it(self, response, destination, delay, usn): + logger.debug('send discovery response delayed by %ds for %s to %r' % (delay, usn, destination)) + try: + self.sock.sendto(response.encode(), destination) + except (AttributeError, socket.error) as msg: + logger.warning("failure sending out byebye notification: %r" % msg) + + def discovery_request(self, headers, host_port): + """Process a discovery request. The response must be sent to + the address specified by (host, port).""" + + (host, port) = host_port + + logger.info('Discovery request from (%s,%d) for %s' % (host, port, headers['st'])) + logger.info('Discovery request for %s' % headers['st']) + + # Do we know about this service? + for i in list(self.known.values()): + if i['MANIFESTATION'] == 'remote': + continue + if headers['st'] == 'ssdp:all' and i['SILENT']: + continue + if i['ST'] == headers['st'] or headers['st'] == 'ssdp:all': + response = ['HTTP/1.1 200 OK'] + + usn = None + for k, v in list(i.items()): + if k == 'USN': + usn = v + if k not in ('MANIFESTATION', 'SILENT', 'HOST'): + response.append('%s: %s' % (k, v)) + + if usn: + response.append('DATE: %s' % formatdate(timeval=None, localtime=False, usegmt=True)) + + response.extend(('', '')) + delay = random.randint(0, int(headers['mx'])) + + self.send_it('\r\n'.join(response), (host, port), delay, usn) + + def do_notify(self, usn): + """Do notification""" + + if self.known[usn]['SILENT']: + return + logger.info('Sending alive notification for %s' % usn) + + resp = [ + 'NOTIFY * HTTP/1.1', + 'HOST: %s:%d' % (SSDP_ADDR, self.ssdp_port), + 'NTS: ssdp:alive', + ] + stcpy = dict(list(self.known[usn].items())) + stcpy['NT'] = stcpy['ST'] + del stcpy['ST'] + del stcpy['MANIFESTATION'] + del stcpy['SILENT'] + del stcpy['HOST'] + del stcpy['last-seen'] + + resp.extend([': '.join(x) for x in list(stcpy.items())]) + resp.extend(('', '')) + logger.debug('do_notify content', resp) + try: + self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, self.ssdp_port)) + self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, self.ssdp_port)) + except (AttributeError, socket.error) as msg: + logger.warning("failure sending out alive notification: %r" % msg) + + def do_byebye(self, usn): + """Do byebye""" + + logger.info('Sending byebye notification for %s' % usn) + + resp = [ + 'NOTIFY * HTTP/1.1', + 'HOST: %s:%d' % (SSDP_ADDR, self.ssdp_port), + 'NTS: ssdp:byebye', + ] + try: + stcpy = dict(list(self.known[usn].items())) + stcpy['NT'] = stcpy['ST'] + del stcpy['ST'] + del stcpy['MANIFESTATION'] + del stcpy['SILENT'] + del stcpy['HOST'] + del stcpy['last-seen'] + resp.extend([': '.join(x) for x in list(stcpy.items())]) + resp.extend(('', '')) + logger.debug('do_byebye content', resp) + if self.sock: + try: + self.sock.sendto('\r\n'.join(resp), (SSDP_ADDR, self.ssdp_port)) + except (AttributeError, socket.error) as msg: + logger.error("failure sending out byebye notification: %r" % msg) + except KeyError as msg: + logger.error("error building byebye notification: %r" % msg)