From 9b4584e7df5ac3ce7103c1f696caf9041738f394 Mon Sep 17 00:00:00 2001 From: croneter Date: Mon, 25 Mar 2019 17:15:18 +0100 Subject: [PATCH] Switch to stream playback, part I --- resources/lib/entrypoint.py | 3 +- resources/lib/playstrm.py | 255 ++++++++++++++++++++++++ resources/lib/plex_db/__init__.py | 15 ++ resources/lib/service_entry.py | 9 + resources/lib/variables.py | 3 + resources/lib/webservice.py | 319 ++++++++++++++++++++++++++++++ resources/lib/widgets.py | 12 +- 7 files changed, 612 insertions(+), 4 deletions(-) create mode 100644 resources/lib/playstrm.py create mode 100644 resources/lib/webservice.py diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 0c41266b..5f14b7e2 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -217,7 +217,8 @@ def show_listing(xml, plex_type=None, section_id=None, synched=True, key=None, # Need to chain keys for navigation widgets.KEY = key # Process all items to show - widgets.attach_kodi_ids(xml) + if synched: + widgets.attach_kodi_ids(xml) all_items = widgets.process_method_on_list(widgets.generate_item, xml) all_items = widgets.process_method_on_list(widgets.prepare_listitem, all_items) diff --git a/resources/lib/playstrm.py b/resources/lib/playstrm.py new file mode 100644 index 00000000..385cae6a --- /dev/null +++ b/resources/lib/playstrm.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +import urllib + +import xbmc +import xbmcgui + +from .plex_api import API +from . import plex_function as PF, utils, json_rpc, variables as v, \ + widgets + + +LOG = getLogger('PLEX.playstrm') + + +class PlayStrmException(Exception): + """ + Any Exception associated with playstrm + """ + pass + + +class PlayStrm(object): + ''' + Workflow: Strm that calls our webservice in database. When played, the + webserivce returns a dummy file to play. Meanwhile, PlayStrm adds the real + listitems for items to play to the playlist. + ''' + def __init__(self, params, server_id=None): + LOG.debug('Starting PlayStrm with server_id %s, params: %s', + server_id, params) + self.xml = None + self.api = None + self.start_index = None + self.index = None + self.server_id = server_id + self.plex_id = utils.cast(int, params['plex_id']) + self.plex_type = params.get('plex_type') + if params.get('synched') and params['synched'].lower() == 'false': + self.synched = False + else: + self.synched = True + self._get_xml() + self.name = self.api.title() + self.kodi_id = utils.cast(int, params.get('kodi_id')) + self.kodi_type = params.get('kodi_type') + if ((self.kodi_id is None or self.kodi_type is None) and + self.xml[0].get('pkc_db_item')): + self.kodi_id = self.xml[0].get('pkc_db_item')['kodi_id'] + self.kodi_type = self.xml[0].get('pkc_db_item')['kodi_type'] + self.transcode = params.get('transcode') + if self.transcode is None: + self.transcode = utils.settings('playFromTranscode.bool') if utils.settings('playFromStream.bool') else None + if utils.window('plex.playlist.audio.bool'): + LOG.info('Audio playlist detected') + self.kodi_playlist = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) + else: + self.kodi_playlist = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + + def __repr__(self): + return ("{{" + "'name': '{self.name}', " + "'plex_id': {self.plex_id}, " + "'plex_type': '{self.plex_type}', " + "'kodi_id': {self.kodi_id}, " + "'kodi_type': '{self.kodi_type}', " + "'server_id': '{self.server_id}', " + "'transcode': {self.transcode}, " + "'start_index': {self.start_index}, " + "'index': {self.index}" + "}}").format(self=self).encode('utf-8') + __str__ = __repr__ + + def add_to_playlist(self, kodi_id, kodi_type, index=None, playlistid=None): + playlistid = playlistid or self.kodi_playlist.getPlayListId() + LOG.debug('Adding kodi_id %s, kodi_type %s to playlist %s at index %s', + kodi_id, kodi_type, playlistid, index) + if index is None: + json_rpc.playlist_add(playlistid, {'%sid' % kodi_type: kodi_id}) + else: + json_rpc.playlist_insert({'playlistid': playlistid, + 'position': index, + 'item': {'%sid' % kodi_type: kodi_id}}) + + def remove_from_playlist(self, index): + LOG.debug('Removing playlist item number %s from %s', index, self) + json_rpc.playlist_remove(self.kodi_playlist.getPlayListId(), + index) + + def _get_xml(self): + self.xml = PF.GetPlexMetadata(self.plex_id) + if self.xml in (None, 401): + raise PlayStrmException('No xml received from the PMS') + if self.synched: + # Adds a new key 'pkc_db_item' to self.xml[0].attrib + widgets.attach_kodi_ids(self.xml) + else: + self.xml[0].set('pkc_db_item', None) + self.api = API(self.xml[0]) + + def start_playback(self, index=0): + LOG.debug('Starting playback at %s', index) + xbmc.Player().play(self.kodi_playlist, startpos=index, windowed=False) + + def play(self, start_position=None, delayed=True): + ''' + Create and add listitems to the Kodi playlist. + ''' + if start_position is not None: + self.start_index = start_position + else: + self.start_index = max(self.kodi_playlist.getposition(), 0) + self.index = self.start_index + listitem = xbmcgui.ListItem() + self._set_playlist(listitem) + LOG.info('Initiating play for %s', self) + if not delayed: + self.start_playback(self.start_index) + return self.start_index + + def play_folder(self, position=None): + ''' + When an entire queue is requested, If requested from Kodi, kodi_type is + provided, add as Kodi would, otherwise queue playlist items using strm + links to setup playback later. + ''' + self.start_index = position or max(self.kodi_playlist.size(), 0) + self.index = self.start_index + 1 + LOG.info('Play folder plex_id %s, index: %s', self.plex_id, self.index) + if self.kodi_id and self.kodi_type: + self.add_to_playlist(self.kodi_id, self.kodi_type, self.index) + else: + listitem = widgets.get_listitem(self.xml[0]) + url = 'http://127.0.0.1:%s/plex/play/file.strm' % v.WEBSERVICE_PORT + args = { + 'mode': 'play', + 'plex_id': self.plex_id, + 'plex_type': self.api.plex_type() + } + if self.kodi_id: + args['kodi_id'] = self.kodi_id + if self.kodi_type: + args['kodi_type'] = self.kodi_type + if self.server_id: + args['server_id'] = self.server_id + if self.transcode: + args['transcode'] = True + url = '%s?%s' % (url, urllib.urlencode(args)) + listitem.setPath(url) + self.kodi_playlist.add(url=url, + listitem=listitem, + index=self.index) + return self.index + + def _set_playlist(self, listitem): + ''' + Verify seektime, set intros, set main item and set additional parts. + Detect the seektime for video type content. Verify the default video + action set in Kodi for accurate resume behavior. + ''' + seektime = self._resume() + if (not seektime and self.plex_type == v.PLEX_TYPE_MOVIE and + utils.settings('enableCinema') == 'true'): + self._set_intros() + + play = playutils.PlayUtilsStrm(self.xml, self.transcode, self.server_id, self.info['Server']) + source = play.select_source(play.get_sources()) + + if not source: + raise PlayStrmException('Playback selection cancelled') + + play.set_external_subs(source, listitem) + self.set_listitem(self.xml, listitem, self.kodi_id, seektime) + listitem.setPath(self.xml['PlaybackInfo']['Path']) + playutils.set_properties(self.xml, self.xml['PlaybackInfo']['Method'], self.server_id) + + self.kodi_playlist.add(url=self.xml['PlaybackInfo']['Path'], listitem=listitem, index=self.index) + self.index += 1 + + if self.xml.get('PartCount'): + self._set_additional_parts() + + def _resume(self): + ''' + Resume item if available. Returns bool or raise an PlayStrmException if + resume was cancelled by user. + ''' + seektime = utils.window('plex.resume') + utils.window('plex.resume', clear=True) + seektime = seektime == 'true' if seektime else None + auto_play = utils.window('plex.autoplay.bool') + if auto_play: + seektime = False + LOG.info('Skip resume for autoplay') + elif seektime is None: + resume = self.api.resume_point() + if resume: + seektime = resume_dialog(resume) + LOG.info('Resume: %s', seektime) + if seektime is None: + raise PlayStrmException('User backed out of resume dialog.') + # Todo: Probably need to have a look here + utils.window('plex.autoplay.bool', value='true') + return seektime + + def _set_intros(self): + ''' + if we have any play them when the movie/show is not being resumed. + ''' + if self.info['Intros']['Items']: + enabled = True + + if utils.settings('askCinema') == 'true': + + resp = dialog('yesno', heading='{emby}', line1=_(33016)) + if not resp: + + enabled = False + LOG.info('Skip trailers.') + + if enabled: + for intro in self.info['Intros']['Items']: + + listitem = xbmcgui.ListItem() + LOG.info('[ intro/%s/%s ] %s', intro['plex_id'], self.index, intro['Name']) + + play = playutils.PlayUtilsStrm(intro, False, self.server_id, self.info['Server']) + source = play.select_source(play.get_sources()) + self.set_listitem(intro, listitem, intro=True) + listitem.setPath(intro['PlaybackInfo']['Path']) + playutils.set_properties(intro, intro['PlaybackInfo']['Method'], self.server_id) + + self.kodi_playlist.add(url=intro['PlaybackInfo']['Path'], listitem=listitem, index=self.index) + self.index += 1 + + utils.window('plex.skip.%s' % intro['plex_id'], value='true') + + def _set_additional_parts(self): + ''' Create listitems and add them to the stack of playlist. + ''' + for part in self.info['AdditionalParts']['Items']: + + listitem = xbmcgui.ListItem() + LOG.info('[ part/%s/%s ] %s', part['plex_id'], self.index, part['Name']) + + play = playutils.PlayUtilsStrm(part, self.transcode, self.server_id, self.info['Server']) + source = play.select_source(play.get_sources()) + play.set_external_subs(source, listitem) + self.set_listitem(part, listitem) + listitem.setPath(part['PlaybackInfo']['Path']) + playutils.set_properties(part, part['PlaybackInfo']['Method'], self.server_id) + + self.kodi_playlist.add(url=part['PlaybackInfo']['Path'], listitem=listitem, index=self.index) + self.index += 1 diff --git a/resources/lib/plex_db/__init__.py b/resources/lib/plex_db/__init__.py index 53196e64..ee847db6 100644 --- a/resources/lib/plex_db/__init__.py +++ b/resources/lib/plex_db/__init__.py @@ -12,3 +12,18 @@ from .sections import Sections class PlexDB(PlexDBBase, TVShows, Movies, Music, Playlists, Sections): pass + + +def kodi_from_plex(plex_id, plex_type=None): + """ + Returns the tuple (kodi_id, kodi_type) for plex_id. Faster, if plex_type + is provided + + Returns (None, None) if unsuccessful + """ + with PlexDB(lock=False) as plexdb: + db_item = plexdb.item_by_id(plex_id, plex_type) + if db_item: + return (db_item['kodi_id'], db_item['kodi_type']) + else: + return None, None diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index b00e879b..eda2dfed 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -10,6 +10,7 @@ from . import initialsetup from . import kodimonitor from . import sync, library_sync from . import websocket_client +from . import webservice from . import plex_companion from . import plex_functions as PF, playqueue as PQ from . import playback_starter @@ -433,6 +434,7 @@ class Service(object): self.setup.setup() # Initialize important threads + self.webservice = webservice.WebService() self.ws = websocket_client.PMS_Websocket() self.alexa = websocket_client.Alexa_Websocket() self.sync = sync.Sync() @@ -494,6 +496,12 @@ class Service(object): xbmc.sleep(100) continue + if self.webservice is not None and not self.webservice.is_alive(): + LOG.info('Restarting webservice') + self.webservice.abort() + self.webservice = webservice.WebService() + self.webservice.start() + # Before proceeding, need to make sure: # 1. Server is online # 2. User is set @@ -523,6 +531,7 @@ class Service(object): continue elif not self.startup_completed: self.startup_completed = True + self.webservice.start() self.ws.start() self.sync.start() self.plexcompanion.start() diff --git a/resources/lib/variables.py b/resources/lib/variables.py index 46b808fb..70ec6482 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -92,6 +92,9 @@ DEVICENAME = DEVICENAME.replace(' ', "") COMPANION_PORT = int(_ADDON.getSetting('companionPort')) +# Port for the PKC webservice +WEBSERVICE_PORT = 57578 + # Unique ID for this Plex client; also see clientinfo.py PKC_MACHINE_IDENTIFIER = None diff --git a/resources/lib/webservice.py b/resources/lib/webservice.py new file mode 100644 index 00000000..7f35427c --- /dev/null +++ b/resources/lib/webservice.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +''' +PKC-dedicated webserver. Listens to Kodi starting playback; will then hand-over +playback to plugin://video.plexkodiconnect +''' +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +import BaseHTTPServer +import httplib +import urlparse +import socket +import Queue + +import xbmc +import xbmcvfs + +from . import backgroundthread, utils, variables as v, app +from .playstrm import PlayStrm + + +LOG = getLogger('PLEX.webservice') + + +class WebService(backgroundthread.KillableThread): + + ''' Run a webservice to trigger playback. + ''' + def is_alive(self): + ''' Called to see if the webservice is still responding. + ''' + alive = True + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect(('127.0.0.1', v.WEBSERVICE_PORT)) + s.sendall('') + except Exception as error: + LOG.error(error) + if 'Errno 61' in str(error): + alive = False + s.close() + return alive + + def abort(self): + ''' Called when the thread needs to stop + ''' + try: + conn = httplib.HTTPConnection('127.0.0.1:%d' % v.WEBSERVICE_PORT) + conn.request('QUIT', '/') + conn.getresponse() + except Exception: + pass + + def run(self): + ''' Called to start the webservice. + ''' + LOG.info('----===## Starting Webserver on port %s ##===----', + v.WEBSERVICE_PORT) + app.APP.register_thread(self) + try: + server = HttpServer(('127.0.0.1', v.WEBSERVICE_PORT), + RequestHandler) + server.serve_forever() + except Exception as error: + if '10053' not in error: # ignore host diconnected errors + utils.ERROR() + finally: + app.APP.deregister_thread(self) + LOG.info('##===---- Webserver stopped ----===##') + + +class HttpServer(BaseHTTPServer.HTTPServer): + ''' Http server that reacts to self.stop flag. + ''' + def __init__(self, *args, **kwargs): + self.stop = False + self.pending = [] + self.threads = [] + self.queue = Queue.Queue() + super(HttpServer, self).__init__(*args, **kwargs) + + def serve_forever(self): + + ''' Handle one request at a time until stopped. + ''' + while not self.stop: + self.handle_request() + + +class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): + ''' Http request handler. Do not use LOG here, + it will hang requests in Kodi > show information dialog. + ''' + timeout = 0.5 + + def log_message(self, format, *args): + ''' Mute the webservice requests. + ''' + pass + + def handle(self): + ''' To quiet socket errors with 404. + ''' + try: + BaseHTTPServer.BaseHTTPRequestHandler.handle(self) + except Exception: + pass + + def do_QUIT(self): + ''' send 200 OK response, and set server.stop to True + ''' + self.send_response(200) + self.end_headers() + self.server.stop = True + + def get_params(self): + ''' Get the params as a dict + ''' + try: + path = self.path[1:].decode('utf-8') + except IndexError: + params = {} + if '?' in path: + path = path.split('?', 1)[1] + params = dict(urlparse.parse_qsl(path)) + + if params.get('transcode'): + params['transcode'] = params['transcode'].lower() == 'true' + if params.get('server') and params['server'].lower() == 'none': + params['server'] = None + + return params + + def do_HEAD(self): + ''' Called on HEAD requests + ''' + self.handle_request(True) + + def do_GET(self): + ''' Called on GET requests + ''' + self.handle_request() + + def handle_request(self, headers_only=False): + '''Send headers and reponse + ''' + try: + if b'extrafanart' in self.path or b'extrathumbs' in self.path: + raise Exception('unsupported artwork request') + + if headers_only: + self.send_response(200) + self.send_header(b'Content-type', b'text/html') + self.end_headers() + + elif b'file.strm' not in self.path: + self.images() + elif b'file.strm' in self.path: + self.strm() + else: + xbmc.log(str(self.path), xbmc.LOGWARNING) + + except Exception as error: + self.send_error(500, + b'PLEX.webservice: Exception occurred: %s' % error) + xbmc.log('<[ webservice/%s/%s ]' % (str(id(self)), int(not headers_only)), xbmc.LOGWARNING) + + def strm(self): + ''' Return a dummy video and and queue real items. + ''' + self.send_response(200) + self.send_header(b'Content-type', b'text/html') + self.end_headers() + + params = self.get_params() + + if b'kodi/movies' in self.path: + params['kodi_type'] = v.KODI_TYPE_MOVIE + elif b'kodi/tvshows' in self.path: + params['kodi_type'] = v.KODI_TYPE_EPISODE + # elif 'kodi/musicvideos' in self.path: + # params['MediaType'] = 'musicvideo' + + if utils.settings('pluginSingle.bool'): + path = 'plugin://plugin.video.plexkodiconnect?mode=playsingle&plex_id=%s' % params['plex_id'] + if params.get('server'): + path += '&server=%s' % params['server'] + if params.get('transcode'): + path += '&transcode=true' + if params.get('kodi_id'): + path += '&kodi_id=%s' % params['kodi_id'] + if params.get('Name'): + path += '&filename=%s' % params['Name'] + self.wfile.write(bytes(path)) + return + + path = 'plugin://plugin.video.plexkodiconnect?mode=playstrm&plex_id=%s' % params['plex_id'] + self.wfile.write(bytes(path)) + if params['plex_id'] not in self.server.pending: + xbmc.log('PLEX.webserver: %s: path: %s params: %s' + % (str(id(self)), str(self.path), str(params)), + xbmc.LOGWARNING) + + self.server.pending.append(params['plex_id']) + self.server.queue.put(params) + if not len(self.server.threads): + queue = QueuePlay(self.server) + queue.start() + self.server.threads.append(queue) + + def images(self): + ''' Return a dummy image for unwanted images requests over the webservice. + Required to prevent freezing of widget playback if the file url has no + local textures cached yet. + ''' + image = xbmc.translatePath( + 'special://home/addons/plugin.video.plexkodiconnect/icon.png').decode('utf-8') + self.send_response(200) + self.send_header(b'Content-type', b'image/png') + modified = xbmcvfs.Stat(image).st_mtime() + self.send_header(b'Last-Modified', b'%s' % modified) + image = xbmcvfs.File(image) + size = image.size() + self.send_header(b'Content-Length', str(size)) + self.end_headers() + self.wfile.write(image.readBytes()) + image.close() + + +class QueuePlay(backgroundthread.KillableThread): + ''' Workflow for new playback: + + Queue up strm playback that was called in the webservice. Called + playstrm in default.py which will wait for our signal here. Downloads + plex information. Add content to the playlist after the strm file that + initiated playback from db. Start playback by telling playstrm waiting. + It will fail playback of the current strm and move to the next entry for + us. If play folder, playback starts here. + + Required delay for widgets, custom skin containers and non library + windows. Otherwise Kodi will freeze if no artwork textures are cached + yet in Textures13.db Will be skipped if the player already has media and + is playing. + + Why do all this instead of using plugin? Strms behaves better than + plugin in database. Allows to load chapter images with direct play. + Allows to have proper artwork for intros. Faster than resolving using + plugin, especially on low powered devices. Cons: Can't use external + players with this method. + ''' + + def __init__(self, server): + self.server = server + super(QueuePlay, self).__init__() + + def run(self): + LOG.info('##===---- Starting QueuePlay ----===##') + play_folder = False + play = None + start_position = None + position = None + + # Let Kodi catch up + xbmc.sleep(200) + + while True: + + try: + try: + params = self.server.queue.get(timeout=0.01) + except Queue.Empty: + count = 20 + while not utils.window('plex.playlist.ready.bool'): + xbmc.sleep(50) + if not count: + LOG.info('Playback aborted') + raise Exception('PlaybackAborted') + count -= 1 + LOG.info('Starting playback at position: %s', start_position) + if play_folder: + LOG.info('Start playing folder') + xbmc.executebuiltin('Dialog.Close(busydialognocancel)') + play.start_playback() + else: + utils.window('plex.playlist.play.bool', True) + xbmc.sleep(1000) + play.remove_from_playlist(start_position) + break + play = PlayStrm(params, params.get('ServerId')) + + if start_position is None: + start_position = max(play.info['KodiPlaylist'].getposition(), 0) + position = start_position + 1 + if play_folder: + position = play.play_folder(position) + else: + if self.server.pending.count(params['plex_id']) != len(self.server.pending): + play_folder = True + utils.window('plex.playlist.start', str(start_position)) + position = play.play(position) + if play_folder: + xbmc.executebuiltin('Activateutils.window(busydialognocancel)') + except Exception: + utils.ERROR() + play.info['KodiPlaylist'].clear() + xbmc.Player().stop() + self.server.queue.queue.clear() + if play_folder: + xbmc.executebuiltin('Dialog.Close(busydialognocancel)') + else: + utils.window('plex.playlist.aborted.bool', True) + break + self.server.queue.task_done() + + utils.window('plex.playlist.ready', clear=True) + utils.window('plex.playlist.start', clear=True) + utils.window('plex.playlist.audio', clear=True) + self.server.threads.remove(self) + self.server.pending = [] + LOG.info('##===---- QueuePlay Stopped ----===##') diff --git a/resources/lib/widgets.py b/resources/lib/widgets.py index 757b66fa..de3e9168 100644 --- a/resources/lib/widgets.py +++ b/resources/lib/widgets.py @@ -29,11 +29,19 @@ PLEX_TYPE = None SECTION_ID = None APPEND_SHOW_TITLE = None APPEND_SXXEXX = None -SYNCHED = True # Need to chain the PMS keys KEY = None +def get_listitem(xml_element): + """ + Returns a valid xbmcgui.ListItem() for xml_element + """ + item = generate_item(xml_element) + prepare_listitem(item) + return create_listitem(item) + + def process_method_on_list(method_to_run, items): """ helper method that processes a method on each listitem with pooling if the @@ -246,8 +254,6 @@ def attach_kodi_ids(xml): """ Attaches the kodi db_item to the xml's children, attribute 'pkc_db_item' """ - if not SYNCHED: - return with PlexDB(lock=False) as plexdb: for child in xml: api = API(child)