diff --git a/README.md b/README.md index f132faf5..eb839862 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![stable version](https://img.shields.io/badge/stable_version-2.9.11-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) -[![beta version](https://img.shields.io/badge/beta_version-2.9.11-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) +[![stable version](https://img.shields.io/badge/stable_version-2.10.0-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) +[![beta version](https://img.shields.io/badge/beta_version-2.10.0-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) [![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation) [![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq) @@ -50,8 +50,8 @@ Some people argue that PKC is 'hacky' because of the way it directly accesses th ### PKC Features +- Kodi 19 Matrix is not yet supported (PKC is written in Python 2) - Support for Kodi 18 Leia -- Support for Kodi 17 Krypton - [Amazon Alexa voice recognition](https://www.plex.tv/apps/streaming-devices/amazon-alexa) - [Cinema Trailers & Extras](https://support.plex.tv/articles/202934883-cinema-trailers-extras/) - [Plex Watch Later / Plex It!](https://support.plex.tv/hc/en-us/sections/200211783-Plex-It-) diff --git a/addon.xml b/addon.xml index bc1b5b9d..24607d7d 100644 --- a/addon.xml +++ b/addon.xml @@ -1,11 +1,11 @@ - + - - + + video audio image @@ -83,7 +83,27 @@ Natūralioji „Plex“ integracija į „Kodi“ Prijunkite „Kodi“ prie „Plex Medija Serverio“. Šiame papildinyje daroma prielaida, kad valdote visus savo vaizdo įrašus naudodami „Plex“ (ir nė vieno su „Kodi“). Galite prarasti jau saugomus „Kodi“ vaizdo įrašų ir muzikos duomenų bazių duomenis (kadangi šis papildinys juos tiesiogiai pakeičia). Naudokite savo pačių rizika! Naudokite savo pačių rizika - version 2.9.11: + version 2.10.0: +- version 2.9.12 - 2.9.14 for everyone +- Get rid of some obsolete code for the ContextMonitor we dropped + +version 2.9.14 (beta only): +- Fix resume when starting playback via PMS or when force transcoding +- Get rid of ContextMonitor and the dedicated Python thread - with new resume mechanics, this is not needed anymore +- Optimize clean-up of file table in the Kodi video database after stopping playback +- Get rid of some obsolete imports + +version 2.9.13 (beta only): +- Fix PKC resuming instead of playing from the beginning + +version 2.9.12 (beta only): +- Fix resume not working in some cases +- Support Plex search across all media and Plex Media Servers: Navigate to the PlexKodiConnect Add-on, then "Search" +- Always use the current Kodi language when communicating with the PMS (restart Kodi when changing the language!) +- Fix Kodi crashing when casting from e.g. Plex Web or Plex for Windows +- Fix PKC throwing error if m3u playlist contains resume information + +version 2.9.11: - version 2.9.10 for everyone version 2.9.10 (beta only): diff --git a/changelog.txt b/changelog.txt index 1c1ac47b..34b5542a 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,23 @@ +version 2.10.0: +- version 2.9.12 - 2.9.14 for everyone +- Get rid of some obsolete code for the ContextMonitor we dropped + +version 2.9.14 (beta only): +- Fix resume when starting playback via PMS or when force transcoding +- Get rid of ContextMonitor and the dedicated Python thread - with new resume mechanics, this is not needed anymore +- Optimize clean-up of file table in the Kodi video database after stopping playback +- Get rid of some obsolete imports + +version 2.9.13 (beta only): +- Fix PKC resuming instead of playing from the beginning + +version 2.9.12 (beta only): +- Fix resume not working in some cases +- Support Plex search across all media and Plex Media Servers: Navigate to the PlexKodiConnect Add-on, then "Search" +- Always use the current Kodi language when communicating with the PMS (restart Kodi when changing the language!) +- Fix Kodi crashing when casting from e.g. Plex Web or Plex for Windows +- Fix PKC throwing error if m3u playlist contains resume information + version 2.9.11: - version 2.9.10 for everyone diff --git a/default.py b/default.py index 86f33252..a05dc5d4 100644 --- a/default.py +++ b/default.py @@ -61,6 +61,13 @@ class Main(): elif mode == 'channels': entrypoint.browse_plex(key='/channels/all') + elif mode == 'search': + # "Search" + entrypoint.browse_plex(key='/hubs/search', + args={'includeCollections': 1, + 'includeExternalMedia': 1}, + prompt=utils.lang(137)) + elif mode == 'route_to_extras': # Hack so we can store this path in the Kodi DB handle = ('plugin://%s?mode=extras&plex_id=%s' diff --git a/resources/lib/app/playstate.py b/resources/lib/app/playstate.py index d5235ff4..688ee401 100644 --- a/resources/lib/app/playstate.py +++ b/resources/lib/app/playstate.py @@ -56,12 +56,6 @@ class PlayState(object): # Currently playing PKC item, a PlaylistItem() self.item = None - # Set by SpecialMonitor - did user choose to resume playback or start from the - # beginning? - # Set to None if resume dialog has not been shown - # True if dialog has been shown and user selected to resume - # False if dialog has been shown and user chose to start from beginning - self.resume_playback = None # Was the playback initiated by the user using the Kodi context menu? self.context_menu_play = False # Set by context menu - shall we force-transcode the next playing item? diff --git a/resources/lib/clientinfo.py b/resources/lib/clientinfo.py index 54378598..48df4554 100644 --- a/resources/lib/clientinfo.py +++ b/resources/lib/clientinfo.py @@ -3,6 +3,8 @@ from __future__ import absolute_import, division, unicode_literals from logging import getLogger +import xbmc + from . import utils from . import variables as v @@ -31,7 +33,7 @@ def getXArgsDeviceInfo(options=None, include_token=True): 'Connection': 'keep-alive', "Content-Type": "application/x-www-form-urlencoded", # "Access-Control-Allow-Origin": "*", - # 'X-Plex-Language': 'en', + 'Accept-Language': xbmc.getLanguage(xbmc.ISO_639_1), 'X-Plex-Device': v.DEVICE, 'X-Plex-Model': v.MODEL, 'X-Plex-Device-Name': v.DEVICENAME, diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index d8b3b883..8880501e 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -146,6 +146,8 @@ def show_main_menu(content_type=None): if content_type: path += '&content_type=%s' % content_type directory_item('Plex Hub', path) + # Plex Search "Search" + directory_item(utils.lang(137), "plugin://%s?mode=search" % v.ADDON_ID) # Plex Watch later if content_type not in ('image', 'audio'): directory_item(utils.lang(39211), @@ -466,7 +468,7 @@ def watchlater(): def browse_plex(key=None, plex_type=None, section_id=None, synched=True, - prompt=None): + args=None, prompt=None): """ Lists the content of a Plex folder, e.g. channels. Either pass in key (to be used directly for PMS url {server}) or the section_id @@ -474,28 +476,43 @@ def browse_plex(key=None, plex_type=None, section_id=None, synched=True, Pass synched=False if the items have NOT been synched to the Kodi DB """ LOG.debug('Browsing to key %s, section %s, plex_type: %s, synched: %s, ' - 'prompt "%s"', key, section_id, plex_type, synched, prompt) + 'prompt "%s", args %s', key, section_id, plex_type, synched, + prompt, args) if not _wait_for_auth(): xbmcplugin.endOfDirectory(int(sys.argv[1]), False) return app.init(entrypoint=True) + args = args or {} if prompt: prompt = utils.dialog('input', prompt) if prompt is None: # User cancelled return prompt = prompt.strip().decode('utf-8') - if '?' not in key: - key = '%s?query=%s' % (key, prompt) - else: - key = '%s&query=%s' % (key, prompt) - xml = DU().downloadUrl('{server}%s' % key) + args['query'] = prompt + xml = DU().downloadUrl(utils.extend_url('{server}%s' % key, args)) try: - xml.attrib - except AttributeError: + xml[0].attrib + except (TypeError, IndexError, AttributeError): LOG.error('Could not browse to key %s, section %s', key, section_id) return + if xml[0].tag == 'Hub': + # E.g. when hitting the endpoint '/hubs/search' + answ = utils.etree.Element(xml.tag, attrib=xml.attrib) + for hub in xml: + if not utils.cast(int, hub.get('size')): + # Empty category + continue + for entry in hub: + api = API(entry) + if api.plex_type == v.PLEX_TYPE_TAG: + # Append the type before the actual element for all "tags" + # like genres, actors, etc. + entry.attrib['tag'] = '%s: %s' % (hub.get('title'), + api.tag_label()) + answ.append(entry) + xml = answ show_listing(xml, plex_type, section_id, synched, key) diff --git a/resources/lib/kodi_db/video.py b/resources/lib/kodi_db/video.py index c2b01981..2c4d409a 100644 --- a/resources/lib/kodi_db/video.py +++ b/resources/lib/kodi_db/video.py @@ -5,7 +5,7 @@ from logging import getLogger from sqlite3 import IntegrityError from . import common -from .. import path_ops, timing, variables as v, app +from .. import path_ops, timing, variables as v LOG = getLogger('PLEX.kodi_db.video') diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index 0067c8ba..8e722580 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -11,21 +11,17 @@ import json import binascii import xbmc -import xbmcgui from .plex_api import API from .plex_db import PlexDB from . import kodi_db from .downloadutils import DownloadUtils as DU -from . import utils, timing, plex_functions as PF, playback +from . import utils, timing, plex_functions as PF from . import json_rpc as js, playqueue as PQ, playlist_func as PL from . import backgroundthread, app, variables as v LOG = getLogger('PLEX.kodimonitor') -# "Start from beginning", "Play from beginning" -STRINGS = (utils.lang(12021).encode('utf-8'), utils.lang(12023).encode('utf-8')) - class KodiMonitor(xbmc.Monitor): """ @@ -33,7 +29,6 @@ class KodiMonitor(xbmc.Monitor): """ def __init__(self): self._already_slept = False - self.hack_replay = None xbmc.Monitor.__init__(self) for playerid in app.PLAYSTATE.player_states: app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template) @@ -57,9 +52,6 @@ class KodiMonitor(xbmc.Monitor): Monitor the PKC settings for changes made by the user """ LOG.debug('PKC settings change detected') - # Assume that the user changed something so we can try to reconnect - # app.APP.suspend = False - # app.APP.resume_threads(block=False) def onNotification(self, sender, method, data): """ @@ -69,28 +61,12 @@ class KodiMonitor(xbmc.Monitor): data = loads(data, 'utf-8') LOG.debug("Method: %s Data: %s", method, data) - # Hack - if not method == 'Player.OnStop': - self.hack_replay = None - if method == "Player.OnPlay": with app.APP.lock_playqueues: self.PlayBackStart(data) elif method == "Player.OnStop": - # Should refresh our video nodes, e.g. on deck - # xbmc.executebuiltin('ReloadSkin()') - if (self.hack_replay and not data.get('end') and - self.hack_replay == data['item']): - # Hack for add-on paths - self.hack_replay = None - with app.APP.lock_playqueues: - self._hack_addon_paths_replay_video() - elif data.get('end'): - with app.APP.lock_playqueues: - _playback_cleanup(ended=True) - else: - with app.APP.lock_playqueues: - _playback_cleanup() + with app.APP.lock_playqueues: + _playback_cleanup(ended=data.get('end')) elif method == 'Playlist.OnAdd': if 'item' in data and data['item'].get('type') == v.KODI_TYPE_SHOW: # Hitting the "browse" button on tv show info dialog @@ -126,39 +102,6 @@ class KodiMonitor(xbmc.Monitor): elif method == 'Other.plugin.video.plexkodiconnect_play_action': self._start_next_episode(data) - @staticmethod - def _hack_addon_paths_replay_video(): - """ - Hack we need for RESUMABLE items because Kodi lost the path of the - last played item that is now being replayed (see playback.py's - Player().play()) Also see playqueue.py _compare_playqueues() - - Needed if user re-starts the same video from the library using addon - paths. (Video is only added to playqueue, then immediately stoppen. - There is no playback initialized by Kodi.) Log excerpts: - Method: Playlist.OnAdd Data: - {u'item': {u'type': u'movie', u'id': 4}, - u'playlistid': 1, - u'position': 0} - Now we would hack! - Method: Player.OnStop Data: - {u'item': {u'type': u'movie', u'id': 4}, - u'end': False} - (within the same micro-second!) - """ - LOG.info('Detected re-start of playback of last item') - old = app.PLAYSTATE.old_player_states[1] - kwargs = { - 'plex_id': old['plex_id'], - 'plex_type': old['plex_type'], - 'path': old['file'], - 'resolve': False - } - task = backgroundthread.FunctionAsTask(playback.playback_triage, - None, - **kwargs) - backgroundthread.BGThreader.addTasksToFront([task]) - def _playlist_onadd(self, data): """ Called if an item is added to a Kodi playlist. Example data dict: @@ -171,15 +114,7 @@ class KodiMonitor(xbmc.Monitor): } Will NOT be called if playback initiated by Kodi widgets """ - if 'id' not in data['item']: - return - old = app.PLAYSTATE.old_player_states[data['playlistid']] - if (not app.SYNC.direct_paths and - data['position'] == 0 and data['playlistid'] == 1 and - not PQ.PLAYQUEUES[data['playlistid']].items and - data['item']['type'] == old['kodi_type'] and - data['item']['id'] == old['kodi_id']): - self.hack_replay = data['item'] + pass def _playlist_onremove(self, data): """ @@ -451,7 +386,7 @@ def _playback_cleanup(ended=False): app.PLAYSTATE.active_players = set() app.PLAYSTATE.item = None utils.delete_temporary_subtitles() - LOG.info('Finished PKC playback cleanup') + LOG.debug('Finished PKC playback cleanup') def _record_playstate(status, ended): @@ -528,13 +463,15 @@ def _clean_file_table(): This function tries for at most 5 seconds to clean the file table. """ LOG.debug('Start cleaning Kodi files table') - app.APP.monitor.waitForAbort(2) + if app.APP.monitor.waitForAbort(2): + # PKC should exit + return try: - with kodi_db.KodiVideoDB() as kodidb_1: - with kodi_db.KodiVideoDB(lock=False) as kodidb_2: - for file_id in kodidb_1.obsolete_file_ids(): - LOG.debug('Removing obsolete Kodi file_id %s', file_id) - kodidb_2.remove_file(file_id, remove_orphans=False) + with kodi_db.KodiVideoDB() as kodidb: + obsolete_file_ids = list(kodidb.obsolete_file_ids()) + for file_id in obsolete_file_ids: + LOG.debug('Removing obsolete Kodi file_id %s', file_id) + kodidb.remove_file(file_id, remove_orphans=False) except utils.OperationalError: LOG.debug('Database was locked, unable to clean file table') else: @@ -653,36 +590,3 @@ def _videolibrary_onupdate(data): PF.scrobble(db_item['plex_id'], 'watched') else: PF.scrobble(db_item['plex_id'], 'unwatched') - - -class ContextMonitor(backgroundthread.KillableThread): - """ - Detect the resume dialog for widgets. Could also be used to detect - external players (see Emby implementation) - - Let's not register this thread because it won't quit due to - xbmc.getCondVisibility - It should still exit at some point due to xbmc.abortRequested - """ - def run(self): - LOG.info("----===## Starting ContextMonitor ##===----") - # app.APP.register_thread(self) - try: - self._run() - finally: - # app.APP.deregister_thread(self) - LOG.info("##===---- ContextMonitor Stopped ----===##") - - def _run(self): - while not self.isCanceled(): - # The following function will block if called while PKC should - # exit! - if xbmc.getCondVisibility('Window.IsVisible(DialogContextMenu.xml)'): - if xbmc.getInfoLabel('Control.GetLabel(1002)') in STRINGS: - # Remember that the item IS indeed resumable - control = int(xbmcgui.Window(10106).getFocusId()) - app.PLAYSTATE.resume_playback = True if control == 1001 else False - else: - # Different context menu is displayed - app.PLAYSTATE.resume_playback = False - xbmc.sleep(100) diff --git a/resources/lib/playback.py b/resources/lib/playback.py index 786d2735..45903217 100644 --- a/resources/lib/playback.py +++ b/resources/lib/playback.py @@ -30,7 +30,8 @@ RESOLVE = True ############################################################################### -def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True): +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. @@ -47,19 +48,18 @@ def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True): service.py Python instance """ try: - _playback_triage(plex_id, plex_type, path, resolve) + _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 - app.PLAYSTATE.resume_playback = None -def _playback_triage(plex_id, plex_type, path, resolve): +def _playback_triage(plex_id, plex_type, path, resolve, resume): plex_id = utils.cast(int, plex_id) - LOG.info('playback_triage called with plex_id %s, plex_type %s, path %s, ' - 'resolve %s', plex_id, plex_type, path, resolve) + 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 @@ -85,12 +85,12 @@ def _playback_triage(plex_id, plex_type, path, resolve): except KeyError: # Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for # add-on paths - LOG.info('No position returned from player! Assuming playlist') + 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.info('Assuming video instead of audio playlist playback') + 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) @@ -108,12 +108,12 @@ def _playback_triage(plex_id, plex_type, path, resolve): try: item = items[pos] except IndexError: - LOG.info('Could not apply playlist hack! Probably Widget playback') + 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.info('Kodi playlist play detected') - _playlist_playback(plex_id, plex_type) + LOG.debug('Kodi playlist play detected') + _playlist_playback(plex_id) return # Can return -1 (as in "no playlist") @@ -127,22 +127,20 @@ def _playback_triage(plex_id, plex_type, path, resolve): initiate = True else: if item.plex_id != plex_id: - LOG.debug('Received new plex_id %s, expected %s', + LOG.debug('Received new plex_id%s, expected %s', plex_id, item.plex_id) initiate = True else: initiate = False - if not initiate and app.PLAYSTATE.resume_playback is not None: - LOG.debug('Detected re-playing of the same item') - initiate = True if initiate: - _playback_init(plex_id, plex_type, playqueue, pos) + _playback_init(plex_id, plex_type, playqueue, pos, resume) else: - # kick off playback on second pass + # 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, plex_type): +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: @@ -175,11 +173,11 @@ def _playlist_playback(plex_id, plex_type): _conclude_playback(playqueue, pos=0) -def _playback_init(plex_id, plex_type, playqueue, pos): +def _playback_init(plex_id, plex_type, playqueue, pos, resume): """ Playback setup if Kodi starts playing an item for the first time. """ - LOG.info('Initializing PKC playback') + 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() @@ -211,18 +209,17 @@ def _playback_init(plex_id, plex_type, playqueue, pos): # Release default.py _ensure_resolve() api = API(xml[0]) - if api.resume_point() and (app.SYNC.direct_paths or - app.PLAYSTATE.context_menu_play): - # Since Kodi won't ask if user wants to resume playback - - # we need to ask ourselves + 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: - LOG.info('User cancelled resume dialog') + # User cancelled dialog return - elif app.SYNC.direct_paths: - resume = False - else: - resume = app.PLAYSTATE.resume_playback or False + 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"): @@ -251,11 +248,17 @@ def _playback_init(plex_id, plex_type, playqueue, pos): 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, None)) + args=(playqueue.kodi_pl, pos, offset)) thread.setDaemon(True) - LOG.info('Done initializing playback, starting Kodi player at pos %s', pos) + 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 @@ -263,8 +266,6 @@ def _playback_init(plex_id, plex_type, playqueue, pos): # plugin://pkc will be lost; Kodi will try to startup playback for an empty # path: log entry is "CGUIWindowVideoBase::OnPlayMedia " thread.start() - # Ensure that PKC playqueue monitor ignores the changes we just made - playqueue.pkc_edit = True def _ensure_resolve(abort=False): @@ -297,6 +298,7 @@ def resume_dialog(resume): # "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(unicode(resume)), utils.lang(12021)]) @@ -376,7 +378,7 @@ def _prep_playlist_stack(xml, resume): 'part': part, 'playcount': api.viewcount(), 'offset': api.resume_point(), - 'resume': resume if i + 1 == len(xml) and part == 0 else False, + 'resume': resume if part == 0 and i + 1 == len(xml) else None, 'id': api.item_id() }) return stack @@ -413,33 +415,20 @@ def _process_stack(playqueue, stack): pos += 1 -def _set_resume(listitem, item, api): - if item.plex_type in (v.PLEX_TYPE_SONG, v.PLEX_TYPE_CLIP): - return - if item.resume is True: - # Do NOT use item.offset directly but get it from the DB - # (user might have initiated same video twice) - with PlexDB(lock=False) as plexdb: - db_item = plexdb.item_by_id(item.plex_id, item.plex_type) - if db_item: - file_id = db_item['kodi_fileid'] - with KodiVideoDB(lock=False) as kodidb: - item.offset = kodidb.get_resume(file_id) - LOG.info('Resuming playback at %s', item.offset) - if v.KODIVERSION >= 18 and api: - # Kodi 18 Alpha 3 broke StartOffset - try: - percent = (item.offset or api.resume_point()) / api.runtime() * 100.0 - except ZeroDivisionError: - percent = 0.0 - LOG.debug('Resuming at %s percent', percent) - listitem.setProperty('StartPercent', str(percent)) - else: - listitem.setProperty('StartOffset', str(item.offset)) - listitem.setProperty('resumetime', str(item.offset)) - elif v.KODIVERSION >= 18: - # Make sure that the video starts from the beginning - listitem.setProperty('StartPercent', '0') +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): @@ -457,19 +446,14 @@ def _conclude_playback(playqueue, pos): start playback return PKC listitem attached to result """ - LOG.info('Concluding playback for playqueue position %s', pos) + LOG.debug('Concluding playback for playqueue position %s', pos) item = playqueue.items[pos] - if item.xml is not None: - # Got a Plex element - api = API(item.xml) - api.part = item.part or 0 - listitem = api.listitem(listitem=transfer.PKCListItem) - set_playurl(api, item) - else: - listitem = transfer.PKCListItem() - api = None + api = API(item.xml) + api.part = item.part or 0 + listitem = api.listitem(listitem=transfer.PKCListItem, resume=False) + set_playurl(api, item) if not item.file: - LOG.info('Did not get a playurl, aborting playback silently') + LOG.debug('Did not get a playurl, aborting playback silently') _ensure_resolve() return listitem.setPath(item.file.encode('utf-8')) @@ -478,9 +462,8 @@ def _conclude_playback(playqueue, pos): elif item.playmethod in (v.PLAYBACK_METHOD_DIRECT_STREAM, v.PLAYBACK_METHOD_TRANSCODE): audio_subtitle_prefs(api, listitem) - _set_resume(listitem, item, api) transfer.send(listitem) - LOG.info('Done concluding playback') + LOG.debug('Done concluding playback') def process_indirect(key, offset, resolve=True): @@ -494,8 +477,8 @@ def process_indirect(key, offset, resolve=True): Set resolve to False if playback should be kicked off directly, not via setResolvedUrl """ - LOG.info('process_indirect called with key: %s, offset: %s, resolve: %s', - key, offset, resolve) + 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 @@ -513,7 +496,7 @@ def process_indirect(key, offset, resolve=True): return api = API(xml[0]) - listitem = api.listitem(listitem=transfer.PKCListItem) + 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() @@ -552,7 +535,7 @@ def process_indirect(key, offset, resolve=True): args={'item': utils.try_encode(playurl), 'listitem': listitem}) thread.setDaemon(True) - LOG.info('Done initializing PKC playback, starting Kodi player') + LOG.debug('Done initializing PKC playback, starting Kodi player') thread.start() @@ -563,9 +546,9 @@ def play_xml(playqueue, xml, offset=None, start_plex_id=None): Either supply the ratingKey of the starting Plex element. Or set playqueue.selectedItemID """ - offset = int(offset) if offset else None - LOG.info("play_xml called with offset %s, start_plex_id %s", - offset, start_plex_id) + 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): @@ -581,21 +564,40 @@ def play_xml(playqueue, xml, offset=None, start_plex_id=None): LOG.debug('Playqueue after play_xml update: %s', playqueue) thread = Thread(target=threaded_playback, args=(playqueue.kodi_pl, startpos, offset)) - LOG.info('Done play_xml, starting Kodi player at position %s', startpos) + 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. + 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) - if offset and offset != '0': - i = 0 - while not app.APP.is_playing or not js.get_player_ids(): - app.APP.monitor.waitForAbort(0.1) - i += 1 - if i > 100: - LOG.error('Could not seek to %s', offset) - return - js.seek_to(int(offset)) + 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 > 200: + LOG.error('Could not seek to %s', offset) + return + i = 0 + answ = js.seek_to(offset * 1000) + while 'error' in answ: + # Kodi sometimes returns {u'message': u'Failed to execute method.', + # u'code': -32100} if user quickly switches videos + i += 1 + if i > 10: + LOG.error('Failed to seek to %s', offset) + return + app.APP.monitor.waitForAbort(0.1) + answ = js.seek_to(offset * 1000) + LOG.debug('Seek to offset %s successful', offset) diff --git a/resources/lib/playback_decision.py b/resources/lib/playback_decision.py index 25970099..522caf77 100644 --- a/resources/lib/playback_decision.py +++ b/resources/lib/playback_decision.py @@ -6,7 +6,7 @@ from requests import exceptions from .downloadutils import DownloadUtils as DU from .plex_api import API -from . import plex_functions as PF, utils, app, variables as v +from . import plex_functions as PF, utils, variables as v LOG = getLogger('PLEX.playback_decision') diff --git a/resources/lib/playback_starter.py b/resources/lib/playback_starter.py index 9c84e18d..c46996ed 100644 --- a/resources/lib/playback_starter.py +++ b/resources/lib/playback_starter.py @@ -37,10 +37,15 @@ class PlaybackTask(backgroundthread.Task): resolve = False if params.get('handle') == '-1' else True LOG.debug('Received mode: %s, params: %s', mode, params) if mode == 'play': + if params.get('resume'): + resume = params.get('resume') == '1' + else: + resume = None playback.playback_triage(plex_id=params.get('plex_id'), plex_type=params.get('plex_type'), path=params.get('path'), - resolve=resolve) + resolve=resolve, + resume=resume) elif mode == 'plex_node': playback.process_indirect(params['key'], params['offset'], diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index dcc4ce4c..c1500bc2 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -213,6 +213,7 @@ class PlaylistItem(object): "'guid': '{self.guid}', " "'playmethod': '{self.playmethod}', " "'playcount': {self.playcount}, " + "'resume': {self.resume}," "'offset': {self.offset}, " "'force_transcode': {self.force_transcode}, " "'part': {self.part}".format(self=self)) diff --git a/resources/lib/playlists/db.py b/resources/lib/playlists/db.py index 265fddee..6b63c39a 100644 --- a/resources/lib/playlists/db.py +++ b/resources/lib/playlists/db.py @@ -64,7 +64,11 @@ def _m3u_iterator(text): lines = iter(text.split('\n')) for line in lines: if line.startswith('#EXTINF:'): - yield next(lines).strip() + next_line = next(lines).strip() + if next_line.startswith('#EXT-KX-OFFSET:'): + yield next(lines).strip() + else: + yield next_line def m3u_to_plex_ids(playlist): diff --git a/resources/lib/plex_api/base.py b/resources/lib/plex_api/base.py index e14bcf21..ef98f83f 100644 --- a/resources/lib/plex_api/base.py +++ b/resources/lib/plex_api/base.py @@ -57,6 +57,12 @@ class Base(object): """ return self.xml.tag + def tag_label(self): + """ + Returns the 'tag' attribute of the xml + """ + return self.xml.get('tag') + @property def attrib(self): """ @@ -605,11 +611,16 @@ class Base(object): % (v.ADDON_ID, url, v.PLEX_TYPE_CLIP)) return url - def listitem(self, listitem=xbmcgui.ListItem): + def listitem(self, listitem=xbmcgui.ListItem, resume=True): """ Returns a xbmcgui.ListItem() (or PKCListItem) for this Plex element + + Pass resume=False in order to NOT set a resume point (but let Kodi + automatically handle it) """ item = widgets.generate_item(self) + if not resume and 'resume' in item: + del item['resume'] item = widgets.prepare_listitem(item) return widgets.create_listitem(item, as_tuple=False, listitem=listitem) diff --git a/resources/lib/plex_api/file.py b/resources/lib/plex_api/file.py index e200e0d9..51ef98f1 100644 --- a/resources/lib/plex_api/file.py +++ b/resources/lib/plex_api/file.py @@ -2,7 +2,6 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, unicode_literals -from ..utils import cast from .. import utils, variables as v, app diff --git a/resources/lib/plex_companion.py b/resources/lib/plex_companion.py index 80ab3bd2..036ac864 100644 --- a/resources/lib/plex_companion.py +++ b/resources/lib/plex_companion.py @@ -59,10 +59,8 @@ def update_playqueue_from_PMS(playqueue, # reconnects and Kodi is already playing something - silly, really # For all other cases, a new playqueue is generated by Plex LOG.debug('Update for existing playqueue detected') - new = False - else: - new = True - playqueue.clear() + return + playqueue.clear() # Get new metadata for the playqueue first try: PL.get_playlist_details_from_xml(playqueue, xml) @@ -71,33 +69,10 @@ def update_playqueue_from_PMS(playqueue, return playqueue.repeat = 0 if not repeat else int(repeat) playqueue.plex_transient_token = transient_token - if new: - playback.play_xml(playqueue, - xml, - offset=offset, - start_plex_id=start_plex_id) - return - # Updates to playqueues could potentially become a bit more ugly... - if app.APP.is_playing: - try: - playerid = js.get_player_ids()[0] - except IndexError: - LOG.error('Unexpectately could not get Kodi player id') - return - if app.PLAYSTATE.player_states[playerid]['plex_id'] == start_plex_id: - # Nothing to do - let's not seek to avoid jumps in playback - return - pos = playqueue.position_from_plex_id(start_plex_id) - LOG.debug('Skipping to position %s for %s', pos, playqueue) - js.skipto(pos) - if offset: - js.seek_to(offset) - return - # Need to initiate playback again using our existing playqueue - app.APP.player.play(playqueue.kodi_pl, - None, - False, - playqueue.position_from_plex_id(start_plex_id)) + playback.play_xml(playqueue, + xml, + offset=offset, + start_plex_id=start_plex_id) class PlexCompanion(backgroundthread.KillableThread): @@ -151,11 +126,10 @@ class PlexCompanion(backgroundthread.KillableThread): playback.play_xml(playqueue, xml, offset) else: app.CONN.plex_transient_token = data.get('token') - if data.get('offset') != '0': - app.PLAYSTATE.resume_playback = True playback.playback_triage(api.plex_id, api.plex_type, - resolve=False) + resolve=False, + resume=data.get('offset') not in ('0', None)) @staticmethod def _process_node(data): diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index 70e97a90..b7b10aed 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -95,7 +95,6 @@ class Service(object): self.setup = None self.alexa = None self.playqueue = None - self.context_monitor = None # Flags for other threads self.connection_check_running = False self.auth_running = False @@ -422,9 +421,6 @@ class Service(object): # Some plumbing app.init() app.APP.monitor = kodimonitor.KodiMonitor() - self.context_monitor = kodimonitor.ContextMonitor() - # Start immediately to catch user input even before auth - self.context_monitor.start() app.APP.player = xbmc.Player() # Initialize the PKC playqueues PQ.init_playqueues() diff --git a/resources/lib/timing.py b/resources/lib/timing.py index 818a3629..5ace8a90 100644 --- a/resources/lib/timing.py +++ b/resources/lib/timing.py @@ -63,25 +63,21 @@ def kodi_now(): def millis_to_kodi_time(milliseconds): """ - Converts time in milliseconds to the time dict used by the Kodi JSON RPC: + Converts time in milliseconds [int or float] to the time dict used by the + Kodi JSON RPC: { 'hours': [int], 'minutes': [int], 'seconds'[int], 'milliseconds': [int] } - Pass in the time in milliseconds as an int """ seconds = int(milliseconds / 1000) minutes = int(seconds / 60) - seconds = seconds % 60 - hours = int(minutes / 60) - minutes = minutes % 60 - milliseconds = milliseconds % 1000 - return {'hours': hours, - 'minutes': minutes, - 'seconds': seconds, - 'milliseconds': milliseconds} + return {'hours': int(minutes / 60), + 'minutes': int(minutes % 60), + 'seconds': int(seconds % 60), + 'milliseconds': int(milliseconds % 1000)} def kodi_time_to_millis(time): diff --git a/resources/lib/variables.py b/resources/lib/variables.py index d848bf95..e12d07d3 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -181,6 +181,9 @@ PLEX_TYPE_PHOTO = 'photo' PLEX_TYPE_PLAYLIST = 'playlist' PLEX_TYPE_CHANNEL = 'channel' +# E.g. PMS answer when hitting the PMS endpoint /hubs/search +PLEX_TYPE_TAG = 'tag' + # Used for /:/timeline XML messages PLEX_PLAYLIST_TYPE_VIDEO = 'video' PLEX_PLAYLIST_TYPE_AUDIO = 'music' diff --git a/resources/lib/widgets.py b/resources/lib/widgets.py index 44789e07..368e2977 100644 --- a/resources/lib/widgets.py +++ b/resources/lib/widgets.py @@ -105,9 +105,10 @@ def _generate_folder(api): return content else: art = api.artwork() + title = api.title() if api.plex_type != v.PLEX_TYPE_TAG else api.tag_label() return { - 'title': api.title(), - 'label': api.title(), + 'title': title, + 'label': title, 'file': api.directory_path(section_id=SECTION_ID, plex_type=PLEX_TYPE, old_key=KEY),