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)