Switch to stream playback, part I

This commit is contained in:
croneter 2019-03-25 17:15:18 +01:00
parent edb9d6e2b0
commit 9b4584e7df
7 changed files with 612 additions and 4 deletions

View file

@ -217,7 +217,8 @@ def show_listing(xml, plex_type=None, section_id=None, synched=True, key=None,
# Need to chain keys for navigation # Need to chain keys for navigation
widgets.KEY = key widgets.KEY = key
# Process all items to show # 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.generate_item, xml)
all_items = widgets.process_method_on_list(widgets.prepare_listitem, all_items = widgets.process_method_on_list(widgets.prepare_listitem,
all_items) all_items)

255
resources/lib/playstrm.py Normal file
View file

@ -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

View file

@ -12,3 +12,18 @@ from .sections import Sections
class PlexDB(PlexDBBase, TVShows, Movies, Music, Playlists, Sections): class PlexDB(PlexDBBase, TVShows, Movies, Music, Playlists, Sections):
pass 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

View file

@ -10,6 +10,7 @@ from . import initialsetup
from . import kodimonitor from . import kodimonitor
from . import sync, library_sync from . import sync, library_sync
from . import websocket_client from . import websocket_client
from . import webservice
from . import plex_companion from . import plex_companion
from . import plex_functions as PF, playqueue as PQ from . import plex_functions as PF, playqueue as PQ
from . import playback_starter from . import playback_starter
@ -433,6 +434,7 @@ class Service(object):
self.setup.setup() self.setup.setup()
# Initialize important threads # Initialize important threads
self.webservice = webservice.WebService()
self.ws = websocket_client.PMS_Websocket() self.ws = websocket_client.PMS_Websocket()
self.alexa = websocket_client.Alexa_Websocket() self.alexa = websocket_client.Alexa_Websocket()
self.sync = sync.Sync() self.sync = sync.Sync()
@ -494,6 +496,12 @@ class Service(object):
xbmc.sleep(100) xbmc.sleep(100)
continue 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: # Before proceeding, need to make sure:
# 1. Server is online # 1. Server is online
# 2. User is set # 2. User is set
@ -523,6 +531,7 @@ class Service(object):
continue continue
elif not self.startup_completed: elif not self.startup_completed:
self.startup_completed = True self.startup_completed = True
self.webservice.start()
self.ws.start() self.ws.start()
self.sync.start() self.sync.start()
self.plexcompanion.start() self.plexcompanion.start()

View file

@ -92,6 +92,9 @@ DEVICENAME = DEVICENAME.replace(' ', "")
COMPANION_PORT = int(_ADDON.getSetting('companionPort')) COMPANION_PORT = int(_ADDON.getSetting('companionPort'))
# Port for the PKC webservice
WEBSERVICE_PORT = 57578
# Unique ID for this Plex client; also see clientinfo.py # Unique ID for this Plex client; also see clientinfo.py
PKC_MACHINE_IDENTIFIER = None PKC_MACHINE_IDENTIFIER = None

319
resources/lib/webservice.py Normal file
View file

@ -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 ----===##')

View file

@ -29,11 +29,19 @@ PLEX_TYPE = None
SECTION_ID = None SECTION_ID = None
APPEND_SHOW_TITLE = None APPEND_SHOW_TITLE = None
APPEND_SXXEXX = None APPEND_SXXEXX = None
SYNCHED = True
# Need to chain the PMS keys # Need to chain the PMS keys
KEY = None 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): def process_method_on_list(method_to_run, items):
""" """
helper method that processes a method on each listitem with pooling if the 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' Attaches the kodi db_item to the xml's children, attribute 'pkc_db_item'
""" """
if not SYNCHED:
return
with PlexDB(lock=False) as plexdb: with PlexDB(lock=False) as plexdb:
for child in xml: for child in xml:
api = API(child) api = API(child)