From c727092ccdc9e731afaa16e77661b7720e4f5805 Mon Sep 17 00:00:00 2001 From: Joan Date: Tue, 19 Mar 2024 17:41:03 +0100 Subject: [PATCH] Readding tvhproxy --- tvhProxy/AUTHORS | 12 ++ tvhProxy/Dockerfile | 14 ++ tvhProxy/LICENSE | 33 ++++ tvhProxy/README.md | 30 ++++ tvhProxy/requirements.txt | 4 + tvhProxy/ssdp.py | 226 +++++++++++++++++++++++ tvhProxy/templates/device.xml | 16 ++ tvhProxy/tvhProxy.env | 8 + tvhProxy/tvhProxy.py | 327 ++++++++++++++++++++++++++++++++++ tvhProxy/tvhProxy.service | 12 ++ tvhProxy/tvhProxy.sh | 11 ++ 11 files changed, 693 insertions(+) create mode 100644 tvhProxy/AUTHORS create mode 100644 tvhProxy/Dockerfile create mode 100644 tvhProxy/LICENSE create mode 100644 tvhProxy/README.md create mode 100644 tvhProxy/requirements.txt create mode 100644 tvhProxy/ssdp.py create mode 100644 tvhProxy/templates/device.xml create mode 100644 tvhProxy/tvhProxy.env create mode 100644 tvhProxy/tvhProxy.py create mode 100644 tvhProxy/tvhProxy.service create mode 100755 tvhProxy/tvhProxy.sh diff --git a/tvhProxy/AUTHORS b/tvhProxy/AUTHORS new file mode 100644 index 0000000..c28a210 --- /dev/null +++ b/tvhProxy/AUTHORS @@ -0,0 +1,12 @@ +tvhProxy is written and maintained by Joel Kaaberg and +various contributors: + +Development Lead +```````````````` + +- Joel Kaaberg + +Patches and Suggestions +``````````````````````` + +- Nikhil Choudhary \ No newline at end of file diff --git a/tvhProxy/Dockerfile b/tvhProxy/Dockerfile new file mode 100644 index 0000000..7664ae2 --- /dev/null +++ b/tvhProxy/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3-slim + +# Sample from https://hub.docker.com/_/python + +WORKDIR /usr/src/app + +COPY requirements.txt ./ +RUN pip3 install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5004 + +CMD [ "python3", "./tvhProxy.py" ] diff --git a/tvhProxy/LICENSE b/tvhProxy/LICENSE new file mode 100644 index 0000000..4d865f8 --- /dev/null +++ b/tvhProxy/LICENSE @@ -0,0 +1,33 @@ +Copyright (c) 2016 by Joel Kaaberg and contributors. See AUTHORS +for more details. + +Some rights reserved. + +Redistribution and use in source and binary forms of the software as well +as documentation, with or without modification, are permitted provided +that the following conditions are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +* The names of the contributors may not be used to endorse or + promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT +NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. \ No newline at end of file diff --git a/tvhProxy/README.md b/tvhProxy/README.md new file mode 100644 index 0000000..d037c6e --- /dev/null +++ b/tvhProxy/README.md @@ -0,0 +1,30 @@ +tvhProxy +======== + +A small flask app to proxy requests between Plex Media Server and Tvheadend. This repo adds a few critical improvements and fixes to the archived upstream version at [jkaberg/tvhProxy](https://github.com/jkaberg/tvhProxy): + +- [SSDP](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol) Discovery. Fixes the issue of Plex randomly dropping the device. +- [XMLTV EPG](https://support.plex.tv/articles/using-an-xmltv-guide/) EPG export, including adding dummy programme entries for channels without EPG so you can still use these channels in Plex (see below for Plex configuration URL) +- Configuration of variables via [dotenv](https://pypi.org/project/python-dotenv/) file + +#### tvhProxy configuration +1. Check tvhProxy.py for configuration options and set them up them as ```KEY=VALUE``` pairs in a ```.env``` file. +2. Create a virtual enviroment: ```$ python3 -m venv .venv``` +3. Activate the virtual enviroment: ```$ . .venv/bin/activate``` +4. Install the requirements: ```$ pip install -r requirements.txt``` +5. Finally run the app with: ```$ python tvhProxy.py``` + +#### systemd service configuration +A startup script for Ubuntu can be found in tvhProxy.service (change paths and user in tvhProxy.service to your setup), install with: + + $ sudo cp tvhProxy.service /etc/systemd/system/tvhProxy.service + $ sudo systemctl daemon-reload + $ sudo systemctl enable tvhProxy.service + $ sudo systemctl start tvhProxy.service + +#### Plex configuration +Enter the IP of the host running tvhProxy including port 5004, eg.: ```192.168.1.50:5004```, use ```http://192.168.1.50:5004/epg.xml``` for the EPG (see [Using XMLTV for guide data](https://support.plex.tv/articles/using-an-xmltv-guide/) for full instructions). + +Should the proxy keep disappearing or you can't add it, it's worth trying to delete the existing configuration from the database: +- See https://support.plex.tv/articles/201100678-repair-a-corrupt-database/ on how to access the plex config database and +- run ```delete from media_provider_resources;``` to remove any leftover config. diff --git a/tvhProxy/requirements.txt b/tvhProxy/requirements.txt new file mode 100644 index 0000000..22420a8 --- /dev/null +++ b/tvhProxy/requirements.txt @@ -0,0 +1,4 @@ +flask +requests +gevent +python-dotenv \ No newline at end of file diff --git a/tvhProxy/ssdp.py b/tvhProxy/ssdp.py new file mode 100644 index 0000000..3c9d130 --- /dev/null +++ b/tvhProxy/ssdp.py @@ -0,0 +1,226 @@ +# 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_PORT = 1900 +SSDP_ADDR = '239.255.255.250' +SERVER_ID = 'ZeWaren example SSDP Server' + + +logger = logging.getLogger() + + +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', 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 = map(lambda x: x.replace(': ', ':', 1), lines[1:]) + lines = filter(lambda x: len(x) > 0, lines) + + headers = [x.split(':', 1) for x in lines] + headers = dict(map(lambda x: (x[0].lower(), x[1]), 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, server=SERVER_ID, 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'] = 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 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 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, SSDP_PORT), + 'NTS: ssdp:alive', + ] + stcpy = dict(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(map(lambda x: ': '.join(x), stcpy.items())) + resp.extend(('', '')) + logger.debug('do_notify content: %s', resp) + try: + self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, SSDP_PORT)) + self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, 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, SSDP_PORT), + 'NTS: ssdp:byebye', + ] + try: + stcpy = dict(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(map(lambda x: ': '.join(x), stcpy.items())) + resp.extend(('', '')) + logger.debug('do_byebye content: %s', resp) + if self.sock: + try: + self.sock.sendto('\r\n'.join(resp), (SSDP_ADDR, 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) diff --git a/tvhProxy/templates/device.xml b/tvhProxy/templates/device.xml new file mode 100644 index 0000000..955fe24 --- /dev/null +++ b/tvhProxy/templates/device.xml @@ -0,0 +1,16 @@ + + + 1 + 0 + + {{ data.BaseURL }} + + urn:schemas-upnp-org:device:MediaServer:1 + {{ data.FriendlyName }} + {{ data.Manufacturer }} + {{ data.ModelNumber }} + {{ data.ModelNumber }} + + uuid:{{ data.DeviceID }} + + \ No newline at end of file diff --git a/tvhProxy/tvhProxy.env b/tvhProxy/tvhProxy.env new file mode 100644 index 0000000..a53cdf2 --- /dev/null +++ b/tvhProxy/tvhProxy.env @@ -0,0 +1,8 @@ +TVH_BINDADDR="0.0.0.0" +TVH_BINDPORT=5004 +TVH_URL="http://christian:enter@parklife:9981" +TVH_PROXY_URL="http://parklife:5004" +TVH_TUNER_COUNT=6 +TVH_WEIGHT=300 +TVH_CHUNK_SIZE=1048576 +TVH_PROFILE="avlib" diff --git a/tvhProxy/tvhProxy.py b/tvhProxy/tvhProxy.py new file mode 100644 index 0000000..6d7f2b1 --- /dev/null +++ b/tvhProxy/tvhProxy.py @@ -0,0 +1,327 @@ +from gevent import monkey +monkey.patch_all() +import json +from dotenv import load_dotenv +from ssdp import SSDPServer +from flask import Flask, Response, request, jsonify, abort, render_template +from gevent.pywsgi import WSGIServer +import xml.etree.ElementTree as ElementTree +from datetime import timedelta, datetime, time +import logging +import socket +import threading +from requests.auth import HTTPDigestAuth +import requests +import os +import sched + + +logging.basicConfig(level=logging.INFO) +load_dotenv(verbose=True) + +app = Flask(__name__) +scheduler = sched.scheduler() +logger = logging.getLogger() + +host_name = socket.gethostname() +host_ip = socket.gethostbyname(host_name) + +# URL format: ://:@:, example: https://test:1234@localhost:9981 +config = { + 'deviceID': os.environ.get('DEVICE_ID') or '12345678', + 'bindAddr': os.environ.get('TVH_BINDADDR') or '', + # only used if set (in case of forward-proxy) + 'tvhURL': os.environ.get('TVH_URL') or 'http://localhost:9981', + 'tvhProxyURL': os.environ.get('TVH_PROXY_URL'), + 'tvhProxyHost': os.environ.get('TVH_PROXY_HOST') or host_ip, + 'tvhProxyPort': os.environ.get('TVH_PROXY_PORT') or 5004, + 'tvhUser': os.environ.get('TVH_USER') or '', + 'tvhPassword': os.environ.get('TVH_PASSWORD') or '', + # number of tuners in tvh + 'tunerCount': os.environ.get('TVH_TUNER_COUNT') or 6, + 'tvhWeight': os.environ.get('TVH_WEIGHT') or 300, # subscription priority + # usually you don't need to edit this + 'chunkSize': os.environ.get('TVH_CHUNK_SIZE') or 1024*1024, + # specifiy a stream profile that you want to use for adhoc transcoding in tvh, e.g. mp4 + 'streamProfile': os.environ.get('TVH_PROFILE') or 'pass' +} + +discoverData = { + 'FriendlyName': 'tvhProxy', + 'Manufacturer': 'Silicondust', + 'ModelNumber': 'HDTC-2US', + 'FirmwareName': 'hdhomeruntc_atsc', + 'TunerCount': int(config['tunerCount']), + 'FirmwareVersion': '20150826', + 'DeviceID': config['deviceID'], + 'DeviceAuth': 'test1234', + 'BaseURL': '%s' % (config['tvhProxyURL'] or "http://" + config['tvhProxyHost'] + ":" + str(config['tvhProxyPort'])), + 'LineupURL': '%s/lineup.json' % (config['tvhProxyURL'] or "http://" + config['tvhProxyHost'] + ":" + str(config['tvhProxyPort'])) +} + + +@app.route('/discover.json') +def discover(): + return jsonify(discoverData) + + +@app.route('/lineup_status.json') +def status(): + return jsonify({ + 'ScanInProgress': 0, + 'ScanPossible': 0, + 'Source': "Cable", + 'SourceList': ['Cable'] + }) + + +@app.route('/lineup.json') +def lineup(): + lineup = [] + + for c in _get_channels(): + if c['enabled']: + url = '%s/stream/channel/%s?profile=%s&weight=%s' % ( + config['tvhURL'], c['uuid'], config['streamProfile'], int(config['tvhWeight'])) + + lineup.append({'GuideNumber': str(c['number']), + 'GuideName': c['name'], + 'URL': url + }) + + return jsonify(lineup) + + +@app.route('/lineup.post', methods=['GET', 'POST']) +def lineup_post(): + return '' + + +@app.route('/') +@app.route('/device.xml') +def device(): + return render_template('device.xml', data=discoverData), {'Content-Type': 'application/xml'} + + +@app.route('/epg.xml') +def epg(): + return _get_xmltv(), {'Content-Type': 'application/xml'} + + +def _get_channels(): + url = '%s/api/channel/grid' % config['tvhURL'] + params = { + 'limit': 999999, + 'start': 0 + } + try: + r = requests.get(url, params=params, auth=HTTPDigestAuth( + config['tvhUser'], config['tvhPassword'])) + return r.json(strict=False)['entries'] + + except Exception as e: + logger.error('An error occured: %s' + repr(e)) + + +def _get_genres(): + def _findMainCategory(majorCategories, minorCategory): + prevKey, currentKey = None, None + for currentKey in sorted(majorCategories.keys()): + if(currentKey > minorCategory): + return majorCategories[prevKey] + prevKey = currentKey + return majorCategories[prevKey] + url = '%s/api/epg/content_type/list' % config['tvhURL'] + params = {'full': 1} + try: + r = requests.get(url, auth=HTTPDigestAuth( + config['tvhUser'], config['tvhPassword'])) + entries = r.json(strict=False)['entries'] + r = requests.get(url, params=params, auth=HTTPDigestAuth( + config['tvhUser'], config['tvhPassword'])) + entries_full = r.json(strict=False)['entries'] + majorCategories = {} + genres = {} + for entry in entries: + majorCategories[entry['key']] = entry['val'] + for entry in entries_full: + if not entry['key'] in majorCategories: + mainCategory = _findMainCategory(majorCategories, entry['key']) + if(mainCategory != entry['val']): + genres[entry['key']] = [mainCategory, entry['val']] + else: + genres[entry['key']] = [entry['val']] + else: + genres[entry['key']] = [entry['val']] + return genres + except Exception as e: + logger.error('An error occured: %s' + repr(e)) + + +def _get_xmltv(): + try: + url = '%s/xmltv/channels' % config['tvhURL'] + r = requests.get(url, auth=HTTPDigestAuth( + config['tvhUser'], config['tvhPassword'])) + logger.info('downloading xmltv from %s', r.url) + tree = ElementTree.ElementTree( + ElementTree.fromstring(requests.get(url, auth=HTTPDigestAuth(config['tvhUser'], config['tvhPassword'])).content)) + root = tree.getroot() + url = '%s/api/epg/events/grid' % config['tvhURL'] + params = { + 'limit': 999999, + 'filter': json.dumps([ + { + "field": "start", + "type": "numeric", + "value": int(round(datetime.timestamp(datetime.now() + timedelta(hours=72)))), + "comparison": "lt" + } + ]) + } + r = requests.get(url, params=params, auth=HTTPDigestAuth( + config['tvhUser'], config['tvhPassword'])) + logger.info('downloading epg grid from %s', r.url) + epg_events_grid = r.json(strict=False)['entries'] + epg_events = {} + event_keys = {} + for epg_event in epg_events_grid: + if epg_event['channelUuid'] not in epg_events: + epg_events[epg_event['channelUuid']] = {} + epg_events[epg_event['channelUuid'] + ][epg_event['start']] = epg_event + for key in epg_event.keys(): + event_keys[key] = True + channelNumberMapping = {} + channelsInEPG = {} + genres = _get_genres() + for child in root: + if child.tag == 'channel': + channelId = child.attrib['id'] + channelNo = child[1].text + if not channelNo: + logger.error("No channel number for: %s", channelId) + channelNo = "00" + if not child[0].text: + logger.error("No channel name for: %s", channelNo) + child[0].text = "No Name" + channelNumberMapping[channelId] = channelNo + if channelNo in channelsInEPG: + logger.error("duplicate channelNo: %s", channelNo) + + channelsInEPG[channelNo] = False + channelName = ElementTree.Element('display-name') + channelName.text = str(channelNo) + " " + child[0].text + child.insert(0, channelName) + for icon in child.iter('icon'): + # check if icon exists (tvh always returns an URL even if there is no channel icon) + iconUrl = icon.attrib['src'] + r = requests.head(iconUrl) + if r.status_code == requests.codes.ok: + icon.attrib['src'] = iconUrl + else: + logger.error("remove icon: %s", iconUrl) + child.remove(icon) + + child.attrib['id'] = channelNo + if child.tag == 'programme': + channelUuid = child.attrib['channel'] + channelNumber = channelNumberMapping[channelUuid] + channelsInEPG[channelNumber] = True + child.attrib['channel'] = channelNumber + start_datetime = datetime.strptime( + child.attrib['start'], "%Y%m%d%H%M%S %z").astimezone(tz=None).replace(tzinfo=None) + stop_datetime = datetime.strptime( + child.attrib['stop'], "%Y%m%d%H%M%S %z").astimezone(tz=None).replace(tzinfo=None) + if start_datetime >= datetime.now() + timedelta(hours=72): + # Plex doesn't like extremely large XML files, we'll remove the details from entries more than 72h in the future + # Fixed w/ plex server 1.19.2.2673 + # for desc in child.iter('desc'): + # child.remove(desc) + pass + elif stop_datetime > datetime.now() and start_datetime < datetime.now() + timedelta(hours=72): + # add extra details for programs in the next 72hs + start_timestamp = int( + round(datetime.timestamp(start_datetime))) + epg_event = epg_events[channelUuid][start_timestamp] + if ('image' in epg_event): + programmeImage = ElementTree.SubElement(child, 'icon') + imageUrl = str(epg_event['image']) + if(imageUrl.startswith('imagecache')): + imageUrl = config['tvhURL'] + \ + "/" + imageUrl + ".png" + programmeImage.attrib['src'] = imageUrl + if ('genre' in epg_event): + for genreId in epg_event['genre']: + for category in genres[genreId]: + programmeCategory = ElementTree.SubElement( + child, 'category') + programmeCategory.text = category + if ('episodeOnscreen' in epg_event): + episodeNum = ElementTree.SubElement( + child, 'episode-num') + episodeNum.attrib['system'] = 'onscreen' + episodeNum.text = epg_event['episodeOnscreen'] + if('hd' in epg_event): + video = ElementTree.SubElement(child, 'video') + quality = ElementTree.SubElement(video, 'quality') + quality.text = "HDTV" + if('new' in epg_event): + ElementTree.SubElement(child, 'new') + else: + ElementTree.SubElement(child, 'previously-shown') + if('copyright_year' in epg_event): + date = ElementTree.SubElement(child, 'date') + date.text = str(epg_event['copyright_year']) + del epg_events[channelUuid][start_timestamp] + for key in sorted(channelsInEPG): + if channelsInEPG[key]: + logger.debug("Programmes found for channel %s", key) + else: + channelName = root.find( + 'channel[@id="'+key+'"]/display-name').text + logger.error("No programme for channel %s: %s", + key, channelName) + # create 2h programmes for 72 hours + yesterday_midnight = datetime.combine( + datetime.today(), time.min) - timedelta(days=1) + date_format = '%Y%m%d%H%M%S' + for x in range(0, 36): + dummyProgramme = ElementTree.SubElement(root, 'programme') + dummyProgramme.attrib['channel'] = str(key) + dummyProgramme.attrib['start'] = ( + yesterday_midnight + timedelta(hours=x*2)).strftime(date_format) + dummyProgramme.attrib['stop'] = ( + yesterday_midnight + timedelta(hours=(x*2)+2)).strftime(date_format) + dummyTitle = ElementTree.SubElement( + dummyProgramme, 'title') + dummyTitle.attrib['lang'] = 'eng' + dummyTitle.text = channelName + dummyDesc = ElementTree.SubElement(dummyProgramme, 'desc') + dummyDesc.attrib['lang'] = 'eng' + dummyDesc.text = "No programming information" + logger.info("returning epg") + return ElementTree.tostring(root) + except requests.exceptions.RequestException as e: # This is the correct syntax + logger.error('An error occured: ' + repr(e)) + raise e + + +def _start_ssdp(): + ssdp = SSDPServer() + thread_ssdp = threading.Thread(target=ssdp.run, args=()) + thread_ssdp.daemon = True # Daemonize thread + thread_ssdp.start() + ssdp.register('local', + 'uuid:{}::upnp:rootdevice'.format(discoverData['DeviceID']), + 'upnp:rootdevice', + 'http://{}:{}/device.xml'.format( + config['tvhProxyHost'], config['tvhProxyPort']), + 'SSDP Server for tvhProxy') + + +if __name__ == '__main__': + http = WSGIServer((config['bindAddr'], int(config['tvhProxyPort'])), + app.wsgi_app, log=logger, error_log=logger) + _start_ssdp() + http.serve_forever() diff --git a/tvhProxy/tvhProxy.service b/tvhProxy/tvhProxy.service new file mode 100644 index 0000000..269e00c --- /dev/null +++ b/tvhProxy/tvhProxy.service @@ -0,0 +1,12 @@ +[Unit] +Description=A simple proxy for Plex and Tvheadend +After=syslog.target network.target tvheadend.service + +[Service] +Environment= +WorkingDirectory=/srv/home/hts/tvhProxy/ +ExecStart=/home/hts/tvhProxy/tvhProxy.sh +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/tvhProxy/tvhProxy.sh b/tvhProxy/tvhProxy.sh new file mode 100755 index 0000000..dce49dd --- /dev/null +++ b/tvhProxy/tvhProxy.sh @@ -0,0 +1,11 @@ +#!/bin/bash +if [ -f .env ] ; then + source .env +fi + +# https://stackoverflow.com/questions/59895/how-to-get-the-source-directory-of-a-bash-script-from-within-the-script-itself +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd $DIR + +source .venv/bin/activate +python tvhProxy.py