#!/usr/bin/env python # -*- coding: utf-8 -*- """ Used to kick off Kodi playback """ from logging import getLogger from threading import Thread import datetime import xbmc from .plex_api import API from .plex_db import PlexDB from .kodi_db import KodiVideoDB from . import plex_functions as PF, playlist_func as PL, playqueue as PQ from . import json_rpc as js, variables as v, utils, transfer from . import playback_decision, app ############################################################################### LOG = getLogger('PLEX.playback') # Do we need to return ultimately with a setResolvedUrl? RESOLVE = True TRY_TO_SEEK_FOR = 300 # =300 seconds IGNORE_SECONDS_AT_START = 15 ############################################################################### def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True, resume=False): """ Hit this function for addon path playback, Plex trailers, etc. Will setup playback first, then on second call complete playback. Will set Playback_Successful() with potentially a PKCListItem() attached (to be consumed by setResolvedURL in default.py) If trailers or additional (movie-)parts are added, default.py is released and a completely new player instance is called with a new playlist. This circumvents most issues with Kodi & playqueues Set resolve to False if you do not want setResolvedUrl to be called on the first pass - e.g. if you're calling this function from the original service.py Python instance """ try: _playback_triage(plex_id, plex_type, path, resolve, resume) finally: # Reset some playback variables the user potentially set to init # playback app.PLAYSTATE.context_menu_play = False app.PLAYSTATE.force_transcode = False def _playback_triage(plex_id, plex_type, path, resolve, resume): plex_id = utils.cast(int, plex_id) LOG.debug('playback_triage called with plex_id %s, plex_type %s, path %s, ' 'resolve %s, resume %s', plex_id, plex_type, path, resolve, resume) global RESOLVE # If started via Kodi context menu, we never resolve RESOLVE = resolve if not app.PLAYSTATE.context_menu_play else False if not app.CONN.online or not app.ACCOUNT.authenticated: if not app.CONN.online: LOG.error('PMS not online for playback') # "{0} offline" utils.dialog('notification', utils.lang(29999), utils.lang(39213).format(app.CONN.server_name), icon='{plex}') else: LOG.error('Not yet authenticated for PMS, abort starting playback') # "Unauthorized for PMS" utils.dialog('notification', utils.lang(29999), utils.lang(30017)) _ensure_resolve(abort=True) return with app.APP.lock_playqueues: playqueue = PQ.get_playqueue_from_type( v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type]) try: pos = js.get_position(playqueue.playlistid) except KeyError: # Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for # add-on paths LOG.debug('No position returned from player! Assuming playlist') playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO) try: pos = js.get_position(playqueue.playlistid) except KeyError: LOG.debug('Assuming video instead of audio playlist playback') playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_VIDEO) try: pos = js.get_position(playqueue.playlistid) except KeyError: LOG.error('Still no position - abort') # "Play error" utils.dialog('notification', utils.lang(29999), utils.lang(30128), icon='{error}') _ensure_resolve(abort=True) return # HACK to detect playback of playlists for add-on paths items = js.playlist_get_items(playqueue.playlistid) try: item = items[pos] except IndexError: LOG.debug('Could not apply playlist hack! Probably Widget playback') else: if ('id' not in item and item.get('type') == 'unknown' and item.get('title') == ''): LOG.debug('Kodi playlist play detected') _playlist_playback(plex_id) return # Can return -1 (as in "no playlist") pos = pos if pos != -1 else 0 LOG.debug('playQueue position %s for %s', pos, playqueue) # Have we already initiated playback? try: item = playqueue.items[pos] except IndexError: LOG.debug('PKC playqueue yet empty, need to initialize playback') initiate = True else: if item.plex_id != plex_id: LOG.debug('Received new plex_id%s, expected %s', plex_id, item.plex_id) initiate = True else: initiate = False if initiate: _playback_init(plex_id, plex_type, playqueue, pos, resume) else: # kick off playback on second pass, resume was already set on first # pass (threaded_playback will seek to resume) _conclude_playback(playqueue, pos) def _playlist_playback(plex_id): """ Really annoying Kodi behavior: Kodi will throw the ENTIRE playlist some- where, causing Playlist.onAdd to fire for each item like this: Playlist.OnAdd Data: {u'item': {u'type': u'episode', u'id': 164}, u'playlistid': 0, u'position': 2} This does NOT work for Addon paths, type and id will be unknown: {u'item': {u'type': u'unknown'}, u'playlistid': 0, u'position': 7} At the end, only the element being played actually shows up in the Kodi playqueue. Hence: if we fail the first addon paths call, Kodi will start playback for the next item in line :-) (by the way: trying to get active Kodi player id will return []) """ xml = PF.GetPlexMetadata(plex_id, reraise=True) if xml in (None, 401): _ensure_resolve(abort=True) return # Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback # has actually started. Need to tell Kodimonitor playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO) playqueue.clear(kodi=False) # Set the flag for the potentially WRONG audio playlist so Kodimonitor # can pick up on it playqueue.kodi_playlist_playback = True playlist_item = PL.playlist_item_from_xml(xml[0]) playqueue.items.append(playlist_item) _conclude_playback(playqueue, pos=0) def _playback_init(plex_id, plex_type, playqueue, pos, resume): """ Playback setup if Kodi starts playing an item for the first time. """ LOG.debug('Initializing PKC playback') # Stop playback so we don't get an error message that the last item of the # queue failed to play app.APP.player.stop() xml = PF.GetPlexMetadata(plex_id, reraise=True) if xml in (None, 401): LOG.error('Could not get a PMS xml for plex id %s', plex_id) _ensure_resolve(abort=True) return if (xbmc.getCondVisibility('Window.IsVisible(Home.xml)') and plex_type in v.PLEX_VIDEOTYPES and playqueue.kodi_pl.size() > 1): # playqueue.kodi_pl.size() could return more than one - since playback # was initiated from the audio queue! LOG.debug('Detected widget playback for videos') elif playqueue.kodi_pl.size() > 1: # Special case - we already got a filled Kodi playqueue try: _init_existing_kodi_playlist(playqueue, pos) except PL.PlaylistError: LOG.error('Playback_init for existing Kodi playlist failed') _ensure_resolve(abort=True) return # Now we need to use setResolvedUrl for the item at position ZERO # playqueue.py will pick up the missing items _conclude_playback(playqueue, 0) return # "Usual" case - consider trailers and parts and build both Kodi and Plex # playqueues # Release default.py _ensure_resolve() api = API(xml[0]) if (app.PLAYSTATE.context_menu_play and api.resume_point() and api.plex_type in v.PLEX_VIDEOTYPES): # User chose to either play via PMS or to force transcode # Need to prompt whether we should resume_playback resume = resume_dialog(int(api.resume_point())) if resume is None: # User cancelled dialog return LOG.debug('Using resume %s', resume) resume = resume or False trailers = False if (not resume and plex_type == v.PLEX_TYPE_MOVIE and utils.settings('enableCinema') == "true"): if utils.settings('askCinema') == "true": # "Play trailers?" trailers = utils.yesno_dialog(utils.lang(29999), utils.lang(33016)) else: trailers = True LOG.debug('Resuming: %s. Playing trailers: %s', resume, trailers) playqueue.clear() if plex_type != v.PLEX_TYPE_CLIP: # Post to the PMS to create a playqueue - in any case due to Companion xml = PF.init_plex_playqueue(plex_id, plex_type, xml.get('librarySectionUUID'), trailers=trailers) if xml is None: LOG.error('Could not get a playqueue xml for plex id %s', plex_id) # "Play error" utils.dialog('notification', utils.lang(29999), utils.lang(30128), icon='{error}') # Do NOT use _ensure_resolve() because we resolved above already return PL.get_playlist_details_from_xml(playqueue, xml) stack = _prep_playlist_stack(xml, resume) _process_stack(playqueue, stack) offset = _use_kodi_db_offset(playqueue.items[pos].plex_id, playqueue.items[pos].plex_type, playqueue.items[pos].offset) if resume else 0 # New thread to release this one sooner (e.g. harddisk spinning up) thread = Thread(target=threaded_playback, args=(playqueue.kodi_pl, pos, offset)) thread.setDaemon(True) LOG.debug('Done initializing playback, starting Kodi player at pos %s and ' 'offset %s', pos, offset) # Ensure that PKC playqueue monitor ignores the changes we just made playqueue.pkc_edit = True # By design, PKC will start Kodi playback using Player().play(). Kodi # caches paths like our plugin://pkc. If we use Player().play() between # 2 consecutive startups of exactly the same Kodi library item, Kodi's # cache will have been flushed for some reason. Hence the 2nd call for # plugin://pkc will be lost; Kodi will try to startup playback for an empty # path: log entry is "CGUIWindowVideoBase::OnPlayMedia " thread.start() def _ensure_resolve(abort=False): """ Will check whether RESOLVE=True and if so, fail Kodi playback startup with the path 'PKC_Dummy_Path_Which_Fails' using setResolvedUrl (and some pickling) This way we're making sure that other Python instances (calling default.py) will be destroyed. """ if RESOLVE: # Releases the other Python thread without a ListItem transfer.send(True) # Wait for default.py to have completed xbmcplugin.setResolvedUrl() transfer.wait_for_transfer(source='default') if abort: utils.dialog('notification', heading='{plex}', message=utils.lang(30128), icon='{error}', time=3000) def resume_dialog(resume): """ Pass the resume [int] point in seconds. Returns True if user chose to resume. Returns None if user cancelled """ # "Resume from {0:s}" # "Start from beginning" resume = datetime.timedelta(seconds=resume) LOG.debug('Showing PKC resume dialog for resume: %s', resume) answ = utils.dialog('contextmenu', [utils.lang(12022).replace('{0:s}', '{0}').format(str(resume)), utils.lang(12021)]) if answ == -1: return return answ == 0 def _init_existing_kodi_playlist(playqueue, pos): """ Will take the playqueue's kodi_pl with MORE than 1 element and initiate playback (without adding trailers) """ LOG.debug('Kodi playlist size: %s', playqueue.kodi_pl.size()) kodi_items = js.playlist_get_items(playqueue.playlistid) if not kodi_items: LOG.error('No Kodi items returned') raise PL.PlaylistError('No Kodi items returned') item = PL.init_plex_playqueue(playqueue, kodi_item=kodi_items[pos]) item.force_transcode = app.PLAYSTATE.force_transcode # playqueue.py will add the rest - this will likely put the PMS under # a LOT of strain if the following Kodi setting is enabled: # Settings -> Player -> Videos -> Play next video automatically LOG.debug('Done init_existing_kodi_playlist') def _prep_playlist_stack(xml, resume): """ resume [bool] will set the resume point of the LAST item of the stack, for part 1 only """ stack = [] for i, item in enumerate(xml): api = API(item) if (app.PLAYSTATE.context_menu_play is False and api.plex_type not in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_EPISODE)): # If user chose to play via PMS or force transcode, do not # use the item path stored in the Kodi DB with PlexDB(lock=False) as plexdb: db_item = plexdb.item_by_id(api.plex_id, api.plex_type) kodi_id = db_item['kodi_id'] if db_item else None kodi_type = db_item['kodi_type'] if db_item else None else: # We will never store clips (trailers) in the Kodi DB. # Also set kodi_id to None for playback via PMS, so that we're # using add-on paths. # Also do NOT associate episodes with library items for addon paths # as artwork lookup is broken (episode path does not link back to # season and show) kodi_id = None kodi_type = None for part, _ in enumerate(item[0]): api.part = part if kodi_id is None: # Need to redirect again to PKC to conclude playback path = api.fullpath(force_addon=True)[0] # Using different paths than the ones saved in the Kodi DB # fixes Kodi immediately resuming the video if one restarts # the same video again after playback # WARNING: This fixes startup, but renders Kodi unstable # path = path.replace('plugin.video.plexkodiconnect.tvshows', # 'plugin.video.plexkodiconnect', 1) # path = path.replace('plugin.video.plexkodiconnect.movies', # 'plugin.video.plexkodiconnect', 1) listitem = api.listitem() listitem.setPath(path) else: # Will add directly via the Kodi DB path = None listitem = None stack.append({ 'kodi_id': kodi_id, 'kodi_type': kodi_type, 'file': path, 'xml_video_element': item, 'listitem': listitem, 'part': part, 'playcount': api.viewcount(), 'offset': api.resume_point(), 'resume': resume if part == 0 and i + 1 == len(xml) else None, 'id': api.item_id() }) return stack def _process_stack(playqueue, stack): """ Takes our stack and adds the items to the PKC and Kodi playqueues. """ # getposition() can return -1 pos = max(playqueue.kodi_pl.getposition(), 0) + 1 for item in stack: if item['kodi_id'] is None: playlist_item = PL.add_listitem_to_Kodi_playlist( playqueue, pos, item['listitem'], file=item['file'], xml_video_element=item['xml_video_element']) else: # Directly add element so we have full metadata playlist_item = PL.add_item_to_kodi_playlist( playqueue, pos, kodi_id=item['kodi_id'], kodi_type=item['kodi_type'], xml_video_element=item['xml_video_element']) playlist_item.playcount = item['playcount'] playlist_item.offset = item['offset'] playlist_item.part = item['part'] playlist_item.id = item['id'] playlist_item.force_transcode = app.PLAYSTATE.force_transcode playlist_item.resume = item['resume'] pos += 1 def _use_kodi_db_offset(plex_id, plex_type, plex_offset): """ Do NOT use item.offset directly but get it from the Kodi DB (Plex might not have gotten the last resume point) """ if plex_type not in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_EPISODE): return plex_offset with PlexDB(lock=False) as plexdb: db_item = plexdb.item_by_id(plex_id, plex_type) if db_item: with KodiVideoDB(lock=False) as kodidb: return kodidb.get_resume(db_item['kodi_fileid']) else: return plex_offset def _conclude_playback(playqueue, pos): """ ONLY if actually being played (e.g. at 5th position of a playqueue). Decide on direct play, direct stream, transcoding path to direct paths: file itself PMS URL Web URL audiostream (e.g. let user choose) subtitle stream (e.g. let user choose) Init Kodi Playback (depending on situation): start playback return PKC listitem attached to result """ LOG.debug('Concluding playback for playqueue position %s', pos) item = playqueue.items[pos] api = API(item.xml) if api.mediastream_number() is None: # E.g. user could choose between several media streams and cancelled LOG.debug('Did not get a mediastream_number') _ensure_resolve() return api.part = item.part or 0 playback_decision.set_pkc_playmethod(api, item) if not playback_decision.audio_subtitle_prefs(api, item): LOG.info('Did not set audio subtitle prefs, aborting silently') _ensure_resolve() return playback_decision.set_playurl(api, item) if not item.file: LOG.info('Did not get a playurl, aborting playback silently') _ensure_resolve() return listitem = api.listitem(listitem=transfer.PKCListItem, resume=False) listitem.setPath(item.file) if item.playmethod != v.PLAYBACK_METHOD_DIRECT_PATH: listitem.setSubtitles(api.cache_external_subs()) transfer.send(listitem) LOG.debug('Done concluding playback') def process_indirect(key, offset, resolve=True): """ Called e.g. for Plex "Play later" - Plex items where we need to fetch an additional xml for the actual playurl. In the PMS metadata, indirect="1" is set. Will release default.py with setResolvedUrl Set resolve to False if playback should be kicked off directly, not via setResolvedUrl """ LOG.debug('process_indirect called with key: %s, offset: %s, resolve: %s', key, offset, resolve) global RESOLVE RESOLVE = resolve offset = int(v.PLEX_TO_KODI_TIMEFACTOR * float(offset)) if offset != '0' else None if key.startswith('http') or key.startswith('{server}'): xml = PF.get_playback_xml(key, app.CONN.server_name) elif key.startswith('/system/services'): xml = PF.get_playback_xml('http://node.plexapp.com:32400%s' % key, 'plexapp.com', authenticate=False, token=app.ACCOUNT.plex_token) else: xml = PF.get_playback_xml('{server}%s' % key, app.CONN.server_name) if xml is None: _ensure_resolve(abort=True) return api = API(xml[0]) listitem = api.listitem(listitem=transfer.PKCListItem, resume=False) playqueue = PQ.get_playqueue_from_type( v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type]) playqueue.clear() item = PL.playlist_item_from_xml(xml[0]) item.offset = offset item.playmethod = v.PLAYBACK_METHOD_DIRECT_PLAY # Need to get yet another xml to get the final playback url try: xml = PF.get_playback_xml('http://node.plexapp.com:32400%s' % xml[0][0][0].attrib['key'], 'plexapp.com', authenticate=False, token=app.ACCOUNT.plex_token) except (TypeError, IndexError, AttributeError): LOG.error('XML malformed: %s', xml.attrib) xml = None if xml is None: _ensure_resolve(abort=True) return try: playurl = xml[0].attrib['key'] except (TypeError, IndexError, AttributeError): LOG.error('Last xml malformed: %s', xml.attrib) _ensure_resolve(abort=True) return item.file = playurl listitem.setPath(playurl) playqueue.items.append(item) if resolve is True: transfer.send(listitem) else: thread = Thread(target=app.APP.player.play, args={'item': playurl, 'listitem': listitem}) thread.setDaemon(True) LOG.debug('Done initializing PKC playback, starting Kodi player') thread.start() def play_xml(playqueue, xml, offset=None, start_plex_id=None): """ Play all items contained in the xml passed in. Called by Plex Companion. Either supply the ratingKey of the starting Plex element. Or set playqueue.selectedItemID """ offset = int(offset) / 1000 if offset else None LOG.debug("play_xml called with offset %s, start_plex_id %s", offset, start_plex_id) start_item = start_plex_id if start_plex_id is not None \ else playqueue.selectedItemID for startpos, video in enumerate(xml): api = API(video) if api.plex_id == start_item: break else: startpos = 0 stack = _prep_playlist_stack(xml, resume=False) if offset: stack[startpos]['resume'] = True _process_stack(playqueue, stack) LOG.debug('Playqueue after play_xml update: %s', playqueue) thread = Thread(target=threaded_playback, args=(playqueue.kodi_pl, startpos, offset)) LOG.debug('Done play_xml, starting Kodi player at position %s', startpos) thread.start() def threaded_playback(kodi_playlist, startpos, offset): """ Seek immediately after kicking off playback is not reliable. We even seek to 0 (starting position) in case Kodi wants to resume but we want to start over. offset: resume position in seconds [int/float] """ LOG.debug('threaded_playback with startpos %s, offset %s', startpos, offset) app.APP.player.play(kodi_playlist, None, False, startpos) offset = offset if offset else 0 i = 0 while not app.APP.is_playing or not js.get_player_ids(): if app.APP.monitor.waitForAbort(0.1): # PKC needs to quit return i += 1 if i > TRY_TO_SEEK_FOR: LOG.error('Could not seek to %s', offset) return try: if offset == 0 and app.APP.player.getTime() < IGNORE_SECONDS_AT_START: LOG.debug('Avoiding small jump to the very start of the video') return except RuntimeError: # RuntimeError: XBMC is not playing any media file pass i = 0 answ = None while answ is None or (answ and 'error' in answ): # Kodi sometimes returns {u'message': u'Failed to execute method.', # u'code': -32100} if user quickly switches videos if app.APP.monitor.waitForAbort(0.1): # PKC needs to quit return i += 1 if i > TRY_TO_SEEK_FOR: LOG.error('Failed to seek to %s. Error: %s', offset, answ) return answ = js.seek_to(offset) LOG.debug('Seek to offset %s successful', offset)