310 lines
15 KiB
Python
310 lines
15 KiB
Python
# Copyright (C) 2024 Lunatixz
|
|
#
|
|
#
|
|
# This file is part of PseudoTV Live.
|
|
#
|
|
# PseudoTV Live is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# PseudoTV Live is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with PseudoTV Live. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
# -*- coding: utf-8 -*-
|
|
import gzip, mimetypes, socket, time
|
|
|
|
from zeroconf import *
|
|
from globals import *
|
|
from functools import partial
|
|
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
|
from six.moves.socketserver import ThreadingMixIn
|
|
|
|
#todo proper REST API to handle server/client communication incl. sync/update triggers.
|
|
#todo incorporate experimental webserver UI to master branch.
|
|
|
|
ZEROCONF_SERVICE = "_xbmc-jsonrpc-h._tcp.local."
|
|
|
|
class Discovery:
|
|
class MyListener(object):
|
|
def __init__(self, multiroom=None):
|
|
self.zServers = dict()
|
|
self.zeroconf = Zeroconf()
|
|
self.multiroom = multiroom
|
|
|
|
def log(self, msg, level=xbmc.LOGDEBUG):
|
|
return log('%s: %s'%(self.__class__.__name__,msg),level)
|
|
|
|
def removeService(self, zeroconf, type, name):
|
|
self.log("getService, type = %s, name = %s"%(type,name))
|
|
|
|
def addService(self, zeroconf, type, name):
|
|
info = self.zeroconf.getServiceInfo(type, name)
|
|
if info:
|
|
IP = socket.inet_ntoa(info.getAddress())
|
|
if IP != SETTINGS.getIP():
|
|
server = info.getServer()
|
|
self.zServers[server] = {'type':type,'name':name,'server':server,'host':'%s:%d'%(IP,info.getPort()),'bonjour':'http://%s:%s/%s'%(IP,SETTINGS.getSettingInt('TCP_PORT'),BONJOURFLE)}
|
|
self.log("addService, found zeroconf %s @ %s using using bonjour %s"%(server,self.zServers[server]['host'],self.zServers[server]['bonjour']))
|
|
self.multiroom.addServer(requestURL(self.zServers[server]['bonjour'],json_data=True))
|
|
|
|
|
|
def __init__(self, service=None, multiroom=None):
|
|
self.service = service
|
|
self.multiroom = multiroom
|
|
self._start()
|
|
|
|
|
|
def log(self, msg, level=xbmc.LOGDEBUG):
|
|
return log('%s: %s'%(self.__class__.__name__,msg),level)
|
|
|
|
|
|
def _start(self):
|
|
if not PROPERTIES.isRunning('Discovery'):
|
|
with PROPERTIES.chkRunning('Discovery'):
|
|
zconf = Zeroconf()
|
|
zcons = self.multiroom._getStatus()
|
|
self.log("_start, Multicast DNS Service Discovery (%s)"%(ZEROCONF_SERVICE))
|
|
SETTINGS.setSetting('ZeroConf_Status','[COLOR=yellow][B]%s[/B][/COLOR]'%(LANGUAGE(32252)))
|
|
ServiceBrowser(zconf, ZEROCONF_SERVICE, self.MyListener(multiroom=self.multiroom))
|
|
self.service.monitor.waitForAbort(DISCOVER_INTERVAL)
|
|
SETTINGS.setSetting('ZeroConf_Status',LANGUAGE(32211)%({True:'green',False:'red'}[zcons],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[zcons]))
|
|
zconf.close()
|
|
|
|
|
|
class RequestHandler(BaseHTTPRequestHandler):
|
|
|
|
def __init__(self, request, client_address, server, monitor):
|
|
self.monitor = monitor
|
|
self.cache = SETTINGS.cache
|
|
try: BaseHTTPRequestHandler.__init__(self, request, client_address, server)
|
|
except: pass
|
|
|
|
|
|
def log(self, msg, level=xbmc.LOGDEBUG):
|
|
return log('%s: %s'%(self.__class__.__name__,msg),level)
|
|
|
|
|
|
def _set_headers(self, content='*/*', size=None, gzip=False):
|
|
self.send_response(200, "OK")
|
|
self.send_header("Content-type",content)
|
|
if size: self.send_header("Content-Length", len(size))
|
|
if gzip: self.send_header("Content-Encoding", "gzip")
|
|
self.end_headers()
|
|
|
|
|
|
def _gzip_encode(self, content):
|
|
out = BytesIO()
|
|
f = gzip.GzipFile(fileobj=out, mode='w', compresslevel=5)
|
|
f.write(content)
|
|
f.close()
|
|
return out.getvalue()
|
|
|
|
|
|
def do_HEAD(self):
|
|
return self._set_headers()
|
|
|
|
|
|
def do_POST(self):
|
|
def _verifyUUID(uuid):
|
|
if uuid == SETTINGS.getMYUUID(): return True
|
|
else:
|
|
from multiroom import Multiroom
|
|
for server in list(Multiroom().getDiscovery().values()):
|
|
if server.get('uuid') == uuid: return True
|
|
|
|
self.log('do_POST, incoming path = %s'%(self.path))
|
|
if not PROPERTIES.isRunning('do_POST'):
|
|
with PROPERTIES.chkRunning('do_POST'), PROPERTIES.interruptActivity():
|
|
if self.path.lower().endswith('.json'):
|
|
try: incoming = loadJSON(self.rfile.read(int(self.headers['content-length'])).decode())
|
|
except: incoming = {}
|
|
if _verifyUUID(incoming.get('uuid')):
|
|
self.log('do_POST incoming uuid [%s] verified!'%(incoming.get('uuid')))
|
|
#channels - channel manager save
|
|
if self.path.lower() == '/%s'%(CHANNELFLE.lower()) and incoming.get('payload'):
|
|
from channels import Channels
|
|
if Channels().setChannels(list(Channels()._verify(incoming.get('payload')))):
|
|
DIALOG.notificationDialog(LANGUAGE(30085)%(LANGUAGE(30108),incoming.get('name',ADDON_NAME)))
|
|
return self.send_response(200, "OK")
|
|
#filelist w/resume - paused channel rule
|
|
elif self.path.lower().startswith('/filelist') and incoming.get('payload'):
|
|
if setJSON(os.path.join(TEMP_LOC,self.path.replace('/filelist/','')),incoming.get('payload')):
|
|
DIALOG.notificationDialog(LANGUAGE(30085)%(LANGUAGE(30060),incoming.get('name',ADDON_NAME)))
|
|
return self.send_response(200, "OK")
|
|
else: self.send_error(401, "Path Not found")
|
|
else: return self.send_error(401, "UUID Not verified!")
|
|
else: return self.do_GET()
|
|
|
|
|
|
def do_GET(self):
|
|
def _sendChunk(path, content, chunk):
|
|
self.log('do_GET, outgoing path = %s, content = %s'%(path, content))
|
|
self._set_headers(content,chunk)
|
|
self.log('do_GET, sending chunk, size = %s'%(len(chunk)))
|
|
self.wfile.write(chunk)
|
|
self.wfile.close()
|
|
|
|
def _sendChunks(path, content):
|
|
self._set_headers(content)
|
|
self.log('do_GET, outgoing path = %s, content = %s'%(path, content))
|
|
while not self.monitor.abortRequested():
|
|
chunk = fle.read(64 * 1024).encode(encoding=DEFAULT_ENCODING)
|
|
if not chunk or self.monitor.waitForAbort(0.0001): break
|
|
self.send_header('content-length', len(chunk))
|
|
self.log('do_GET, sending = %s, chunk = %s'%(path, chunk))
|
|
self.wfile.write(chunk)
|
|
self.wfile.close()
|
|
|
|
def _sendFile(path, content):
|
|
self.log('do_GET, outgoing path = %s, content = %s'%(path, content))
|
|
with xbmcvfs.File(path, "r") as fle:
|
|
chunk = fle.read().encode(encoding=DEFAULT_ENCODING)
|
|
self._set_headers(content,chunk)
|
|
self.log('do_GET, sending = %s, size = %s'%(path,len(chunk)))
|
|
self.wfile.write(chunk)
|
|
self.wfile.close()
|
|
|
|
def _sendZip(path, content):
|
|
self.log('do_GET, outgoing path = %s, content = %s'%(path, content))
|
|
with xbmcvfs.File(path, "r") as fle:
|
|
if 'gzip' in self.headers.get('accept-encoding'):
|
|
data = self._gzip_encode(fle.read().encode(encoding=DEFAULT_ENCODING))
|
|
self._set_headers(content,data,True)
|
|
self.log('do_GET, sending = %s, gzip compressing'%(path))
|
|
self.wfile.write(data)
|
|
self.wfile.close()
|
|
else: self._sendChunks(path, content)
|
|
|
|
def _sendImage(path, content):
|
|
self.log('do_GET, outgoing path = %s, content = %s'%(path, content))
|
|
with xbmcvfs.File(path, "r") as fle:
|
|
chunk = fle.readBytes()
|
|
self._set_headers(content,chunk)
|
|
self.log('do_GET, sending = %s, size = %s'%(path,len(chunk)))
|
|
self.wfile.write(chunk)
|
|
self.wfile.close()
|
|
|
|
self.log('do_GET, incoming path = %s'%(self.path))
|
|
if not PROPERTIES.isRunning('do_GET'):
|
|
with PROPERTIES.chkRunning('do_GET'), PROPERTIES.interruptActivity():
|
|
#Bonjour json/html
|
|
if self.path.lower() == '/%s'%(BONJOURFLE.lower()):
|
|
chunk = dumpJSON(SETTINGS.getBonjour(inclChannels=True),idnt=4).encode(encoding=DEFAULT_ENCODING)
|
|
_sendChunk(self.path.lower(), "application/json", chunk)
|
|
#Remotes Json/jtml
|
|
elif self.path.lower().startswith('/remote'):
|
|
if self.path.lower().endswith('.json'):
|
|
_sendChunk(self.path.lower(), "application/json", dumpJSON(SETTINGS.getPayload(),idnt=4).encode(encoding=DEFAULT_ENCODING))
|
|
elif self.path.lower().endswith('.html'):
|
|
_sendChunk(self.path.lower(), "text/html", SETTINGS.getPayloadUI().encode(encoding=DEFAULT_ENCODING))
|
|
else: self.send_error(404, "Path Not found")
|
|
#filelist - Paused Channels
|
|
elif self.path.lower().startswith('/filelist') and self.path.lower().endswith('.json'):
|
|
_sendChunk(self.path.lower(), "application/json", dumpJSON(getJSON((os.path.join(TEMP_LOC,self.path.replace('/filelist/',''))))).encode(encoding=DEFAULT_ENCODING))
|
|
#M3U - MPEG
|
|
elif self.path.lower() == '/%s'%(M3UFLE.lower()):
|
|
_sendFile(M3UFLEPATH, "application/vnd.apple.mpegurl")
|
|
#Genres - XML
|
|
elif self.path.lower() == '/%s'%(GENREFLE.lower()):
|
|
_sendFile(GENREFLEPATH, "text/plain")
|
|
#XMLTV - XML (Large)
|
|
elif self.path.lower() == '/%s'%(XMLTVFLE.lower()):
|
|
_sendZip(XMLTVFLEPATH, "text/xml")
|
|
#Images - image server
|
|
elif self.path.lower().startswith("/images/"):
|
|
_sendImage(os.path.join(LOGO_LOC,unquoteString(self.path.replace('/images/',''))), mimetypes.guess_type(self.path[1:])[0])
|
|
else: self.send_error(404, "Path Not found")
|
|
|
|
class HTTP:
|
|
isRunning = False
|
|
|
|
def __init__(self, service=None):
|
|
self.log('__init__')
|
|
self.service = service
|
|
timerit(self._start)(0.1)
|
|
|
|
|
|
def log(self, msg, level=xbmc.LOGDEBUG):
|
|
return log('%s: %s'%(self.__class__.__name__,msg),level)
|
|
|
|
|
|
def chkPort(self, port=0, redirect=False):
|
|
try:
|
|
state = False
|
|
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
|
s.bind(("127.0.0.1", port))
|
|
state = True
|
|
except Exception as e:
|
|
self.log("chkPort, port = %s, failed! = %s"%(port,e))
|
|
if redirect:
|
|
try:
|
|
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
port = s.getsockname()[1]
|
|
state = True
|
|
except Exception as e: self.log("chkPort, port = %s, failed! = %s"%(port,e))
|
|
else: port = None
|
|
self.log("chkPort, port = %s, available = %s"%(port,state))
|
|
return port
|
|
|
|
|
|
def _start(self, wait=900):
|
|
while not self.service.monitor.abortRequested():
|
|
if not self.isRunning:
|
|
try:
|
|
IP = SETTINGS.getIP()
|
|
TCP = SETTINGS.getSettingInt('TCP_PORT')
|
|
PORT= self.chkPort(TCP,redirect=True)
|
|
if PORT is None: raise Exception('Port: %s In-Use!'%(PORT))
|
|
elif PORT != TCP: SETTINGS.setSettingInt('TCP_PORT',PORT)
|
|
LOCAL_HOST = PROPERTIES.setRemoteHost('%s:%s'%(IP,PORT))
|
|
self.log("_start, starting server @ %s"%(LOCAL_HOST),xbmc.LOGINFO)
|
|
|
|
SETTINGS.setSetting('Remote_NAME' ,SETTINGS.getFriendlyName())
|
|
SETTINGS.setSetting('Remote_M3U' ,'http://%s/%s'%(LOCAL_HOST,M3UFLE))
|
|
SETTINGS.setSetting('Remote_XMLTV','http://%s/%s'%(LOCAL_HOST,XMLTVFLE))
|
|
SETTINGS.setSetting('Remote_GENRE','http://%s/%s'%(LOCAL_HOST,GENREFLE))
|
|
|
|
self.isRunning = True
|
|
self._server = ThreadedHTTPServer((IP, PORT), partial(RequestHandler,monitor=self.service.monitor))
|
|
self._server.allow_reuse_address = True
|
|
self._httpd_thread = Thread(target=self._server.serve_forever)
|
|
self._httpd_thread.daemon=True
|
|
self._httpd_thread.start()
|
|
except Exception as e: self.log("_start, Failed! %s"%(e), xbmc.LOGERROR)
|
|
self._update()
|
|
if self.service.monitor.waitForAbort(wait): break
|
|
self._stop()
|
|
|
|
|
|
def _stop(self):
|
|
try:
|
|
if self.isRunning:
|
|
self.log('_stop, shutting server down',xbmc.LOGINFO)
|
|
self._server.shutdown()
|
|
self._server.server_close()
|
|
self._server.socket.close()
|
|
if self._httpd_thread.is_alive():
|
|
try: self._httpd_thread.join(5)
|
|
except: pass
|
|
except Exception as e: self.log("_stop, Failed! %s"%(e), xbmc.LOGERROR)
|
|
self.isRunning = False
|
|
self._update()
|
|
|
|
|
|
def _update(self):
|
|
DIALOG.notificationDialog('%s: %s'%(SETTINGS.getSetting('Remote_NAME'),LANGUAGE(32211)%({True:'green',False:'red'}[self.isRunning],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[self.isRunning])))
|
|
SETTINGS.setSetting('Remote_Status',LANGUAGE(32211)%({True:'green',False:'red'}[self.isRunning],{True:LANGUAGE(32158),False:LANGUAGE(32253)}[self.isRunning]))
|
|
|
|
|
|
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
|
daemon_threads = True
|
|
|