Readded tvhProxy folder
This commit is contained in:
12
tvhProxy/AUTHORS
Normal file
12
tvhProxy/AUTHORS
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
tvhProxy is written and maintained by Joel Kaaberg and
|
||||||
|
various contributors:
|
||||||
|
|
||||||
|
Development Lead
|
||||||
|
````````````````
|
||||||
|
|
||||||
|
- Joel Kaaberg <joel.kaberg@gmail.com>
|
||||||
|
|
||||||
|
Patches and Suggestions
|
||||||
|
```````````````````````
|
||||||
|
|
||||||
|
- Nikhil Choudhary
|
||||||
14
tvhProxy/Dockerfile
Normal file
14
tvhProxy/Dockerfile
Normal file
@@ -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" ]
|
||||||
33
tvhProxy/LICENSE
Normal file
33
tvhProxy/LICENSE
Normal file
@@ -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.
|
||||||
30
tvhProxy/README.md
Normal file
30
tvhProxy/README.md
Normal file
@@ -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.
|
||||||
4
tvhProxy/requirements.txt
Normal file
4
tvhProxy/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
flask
|
||||||
|
requests
|
||||||
|
gevent
|
||||||
|
python-dotenv
|
||||||
226
tvhProxy/ssdp.py
Normal file
226
tvhProxy/ssdp.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Licensed under the MIT license
|
||||||
|
# http://opensource.org/licenses/mit-license.php
|
||||||
|
|
||||||
|
# Copyright 2005, Tim Potter <tpot@samba.org>
|
||||||
|
# Copyright 2006 John-Mark Gurney <gurney_j@resnet.uroegon.edu>
|
||||||
|
# Copyright (C) 2006 Fluendo, S.A. (www.fluendo.com).
|
||||||
|
# Copyright 2006,2007,2008,2009 Frank Scholz <coherence@beebits.net>
|
||||||
|
# Copyright 2016 Erwan Martin <public@fzwte.net>
|
||||||
|
#
|
||||||
|
# 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)
|
||||||
16
tvhProxy/templates/device.xml
Normal file
16
tvhProxy/templates/device.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor>
|
||||||
|
</specVersion>
|
||||||
|
<URLBase>{{ data.BaseURL }}</URLBase>
|
||||||
|
<device>
|
||||||
|
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||||
|
<friendlyName>{{ data.FriendlyName }}</friendlyName>
|
||||||
|
<manufacturer>{{ data.Manufacturer }}</manufacturer>
|
||||||
|
<modelName>{{ data.ModelNumber }}</modelName>
|
||||||
|
<modelNumber>{{ data.ModelNumber }}</modelNumber>
|
||||||
|
<serialNumber></serialNumber>
|
||||||
|
<UDN>uuid:{{ data.DeviceID }}</UDN>
|
||||||
|
</device>
|
||||||
|
</root>
|
||||||
8
tvhProxy/tvhProxy.env
Normal file
8
tvhProxy/tvhProxy.env
Normal file
@@ -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"
|
||||||
327
tvhProxy/tvhProxy.py
Normal file
327
tvhProxy/tvhProxy.py
Normal file
@@ -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: <protocol>://<username>:<password>@<hostname>:<port>, 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()
|
||||||
12
tvhProxy/tvhProxy.service
Normal file
12
tvhProxy/tvhProxy.service
Normal file
@@ -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
|
||||||
11
tvhProxy/tvhProxy.sh
Executable file
11
tvhProxy/tvhProxy.sh
Executable file
@@ -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
|
||||||
Reference in New Issue
Block a user