diff --git a/addon.xml b/addon.xml index ff3db886..21330134 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,22 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.4.4: + version 3.5.0: +- versions 3.4.5-3.4.7 for everyone + +version 3.4.7 (beta only): +- Tell the PMS if a video's audio stream or potentially subtitle stream has changed. For subtitles, this functionality is broken due to a Kodi bug +- Transcoding: Fix Plex burning-in subtitles when it should not +- Large refactoring of playlist and playqueue code +- Refactor usage of a media part's id + +version 3.4.6 (beta only): +- Fix RecursionError if a video lies in a root directory + +version 3.4.5 (beta only): +- Implement "Reset resume position" from the Kodi context menu + +version 3.4.4: - Initial compatibility with Kodi 20 Nexus. Keep in mind that development for Kodi Nexus has not even officially reached alpha stage - any issues you encounter are probably caused by that - version 3.4.3 for everyone diff --git a/changelog.txt b/changelog.txt index 6605152d..ba380ba2 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,18 @@ +version 3.5.0: +- versions 3.4.5-3.4.7 for everyone + +version 3.4.7 (beta only): +- Tell the PMS if a video's audio stream or potentially subtitle stream has changed. For subtitles, this functionality is broken due to a Kodi bug +- Transcoding: Fix Plex burning-in subtitles when it should not +- Large refactoring of playlist and playqueue code +- Refactor usage of a media part's id + +version 3.4.6 (beta only): +- Fix RecursionError if a video lies in a root directory + +version 3.4.5 (beta only): +- Implement "Reset resume position" from the Kodi context menu + version 3.4.4: - Initial compatibility with Kodi 20 Nexus. Keep in mind that development for Kodi Nexus has not even officially reached alpha stage - any issues you encounter are probably caused by that - version 3.4.3 for everyone diff --git a/resources/lib/db.py b/resources/lib/db.py index 4b0b9480..2769c719 100644 --- a/resources/lib/db.py +++ b/resources/lib/db.py @@ -4,19 +4,13 @@ import sqlite3 from functools import wraps from . import variables as v, app +from .exceptions import LockedDatabase DB_WRITE_ATTEMPTS = 100 DB_WRITE_ATTEMPTS_TIMEOUT = 1 # in seconds DB_CONNECTION_TIMEOUT = 10 -class LockedDatabase(Exception): - """ - Dedicated class to make sure we're not silently catching locked DBs. - """ - pass - - def catch_operationalerrors(method): """ sqlite.OperationalError is raised immediately if another DB connection diff --git a/resources/lib/exceptions.py b/resources/lib/exceptions.py new file mode 100644 index 00000000..85a395df --- /dev/null +++ b/resources/lib/exceptions.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +class PlaylistError(Exception): + """ + Exception for our playlist constructs + """ + pass + + +class LockedDatabase(Exception): + """ + Dedicated class to make sure we're not silently catching locked DBs. + """ + pass + + +class SubtitleError(Exception): + """ + Exceptions relating to subtitles + """ + pass + + +class ProcessingNotDone(Exception): + """ + Exception to detect whether we've completed our sync and did not have to + abort or suspend. + """ + pass diff --git a/resources/lib/json_rpc.py b/resources/lib/json_rpc.py index 1f6fa56a..5e970191 100644 --- a/resources/lib/json_rpc.py +++ b/resources/lib/json_rpc.py @@ -420,6 +420,41 @@ def get_item(playerid): 'properties': ['title', 'file']})['result']['item'] +def get_current_audio_stream_index(playerid): + """ + Returns the currently active audio stream index [int] + """ + return JsonRPC('Player.GetProperties').execute({ + 'playerid': playerid, + 'properties': ['currentaudiostream']})['result']['currentaudiostream']['index'] + + +def get_current_subtitle_stream_index(playerid): + """ + Returns the currently active subtitle stream index [int] or None if there + are no subs + PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The + JSON reply won't change even though subtitles are changed :-( + """ + try: + return JsonRPC('Player.GetProperties').execute({ + 'playerid': playerid, + 'properties': ['currentsubtitle', ]})['result']['currentsubtitle']['index'] + except KeyError: + pass + + +def get_subtitle_enabled(playerid): + """ + Returns True if a subtitle is currently enabled, False otherwise. + PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The + JSON reply won't change even though subtitles are changed :-( + """ + return JsonRPC('Player.GetProperties').execute({ + 'playerid': playerid, + 'properties': ['subtitleenabled', ]})['result']['subtitleenabled'] + + def get_player_props(playerid): """ Returns a dict for the active Kodi player with the following values: diff --git a/resources/lib/kodi_db/video.py b/resources/lib/kodi_db/video.py index ad79e119..36b5fd9a 100644 --- a/resources/lib/kodi_db/video.py +++ b/resources/lib/kodi_db/video.py @@ -602,6 +602,22 @@ class KodiVideoDB(common.KodiDBBase): return return movie_id, typus + def file_id_from_id(self, kodi_id, kodi_type): + """ + Returns the Kodi file_id for the item with kodi_id and kodi_type or + None + """ + if kodi_type == v.KODI_TYPE_MOVIE: + identifier = 'idMovie' + elif kodi_type == v.KODI_TYPE_EPISODE: + identifier = 'idEpisode' + self.cursor.execute('SELECT idFile FROM %s WHERE %s = ? LIMIT 1' + % (kodi_type, identifier), (kodi_id, )) + try: + return self.cursor.fetchone()[0] + except TypeError: + pass + def get_resume(self, file_id): """ Returns the first resume point in seconds (int) if found, else None for diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index b07d5e80..de88a3c0 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -13,11 +13,13 @@ import xbmc from .plex_api import API from .plex_db import PlexDB +from .kodi_db import KodiVideoDB from . import kodi_db from .downloadutils import DownloadUtils as DU 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 +from . import exceptions LOG = getLogger('PLEX.kodimonitor') @@ -28,7 +30,7 @@ class KodiMonitor(xbmc.Monitor): """ def __init__(self): self._already_slept = False - self._switch_to_plex_streams = None + self._switched_to_plex_streams = True xbmc.Monitor.__init__(self) for playerid in app.PLAYSTATE.player_states: app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template) @@ -66,7 +68,7 @@ class KodiMonitor(xbmc.Monitor): self.PlayBackStart(data) elif method == 'Player.OnAVChange': with app.APP.lock_playqueues: - self.on_av_change() + self._on_av_change(data) elif method == "Player.OnStop": with app.APP.lock_playqueues: _playback_cleanup(ended=data.get('end')) @@ -85,7 +87,8 @@ class KodiMonitor(xbmc.Monitor): with app.APP.lock_playqueues: self._playlist_onclear(data) elif method == "VideoLibrary.OnUpdate": - _videolibrary_onupdate(data) + with app.APP.lock_playqueues: + _videolibrary_onupdate(data) elif method == "VideoLibrary.OnRemove": pass elif method == "System.OnSleep": @@ -178,7 +181,7 @@ class KodiMonitor(xbmc.Monitor): try: for i, item in enumerate(items): PL.add_item_to_plex_playqueue(playqueue, i + 1, kodi_item=item) - except PL.PlaylistError: + except exceptions.PlaylistError: LOG.info('Could not build Plex playlist for: %s', items) def _json_item(self, playerid): @@ -315,7 +318,7 @@ class KodiMonitor(xbmc.Monitor): return try: item = PL.init_plex_playqueue(playqueue, plex_id=plex_id) - except PL.PlaylistError: + except exceptions.PlaylistError: LOG.info('Could not initialize the Plex playlist') return item.file = path @@ -340,8 +343,7 @@ class KodiMonitor(xbmc.Monitor): container_key = '/library/metadata/%s' % plex_id # Mechanik for Plex skip intro feature if utils.settings('enableSkipIntro') == 'true': - api = API(item.xml) - status['intro_markers'] = api.intro_markers() + status['intro_markers'] = item.api.intro_markers() # Remember the currently playing item app.PLAYSTATE.item = item # Remember that this player has been active @@ -356,60 +358,96 @@ class KodiMonitor(xbmc.Monitor): status['plex_type'] = plex_type status['playmethod'] = item.playmethod status['playcount'] = item.playcount - try: - status['external_player'] = app.APP.player.isExternalPlayer() == 1 - except AttributeError: - # Kodi version < 17 - pass + status['external_player'] = app.APP.player.isExternalPlayer() == 1 LOG.debug('Set the player state: %s', status) # Workaround for the Kodi add-on Up Next if not app.SYNC.direct_paths: _notify_upnext(item) - self._switch_to_plex_streams = item + self._switched_to_plex_streams = False - def on_av_change(self): + def _on_av_change(self, data): """ Will be called when Kodi has a video, audio or subtitle stream. Also happens when the stream changes. + + Example data as returned by Kodi: + {'item': {'id': 5, 'type': 'movie'}, + 'player': {'playerid': 1, 'speed': 1}} + + PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! + Kodi subs will never change. Also see json_rpc.py """ - if self._switch_to_plex_streams is not None: - self.switch_to_plex_streams(self._switch_to_plex_streams) - self._switch_to_plex_streams = None + playerid = data['player']['playerid'] + if not playerid == v.KODI_VIDEO_PLAYER_ID: + # We're just messing with Kodi's videoplayer + return + if not self._switched_to_plex_streams: + # We need to switch to the Plex streams ONCE upon playback start + # after onavchange has been fired + self.switch_to_plex_streams() + self._switched_to_plex_streams = True + else: + item = app.PLAYSTATE.item + if item is None: + # Player might've quit + return + kodi_audio_stream = js.get_current_audio_stream_index(playerid) + sub_enabled = js.get_subtitle_enabled(playerid) + kodi_sub_stream = js.get_current_subtitle_stream_index(playerid) + # Audio + if kodi_audio_stream != item.current_kodi_audio_stream: + item.on_kodi_audio_stream_change(kodi_audio_stream) + # Subtitles - CURRENTLY BROKEN ON THE KODI SIDE! + # current_kodi_sub_stream may also be zero + subs_off = (None, False) + if ((sub_enabled and item.current_kodi_sub_stream in subs_off) + or (not sub_enabled and item.current_kodi_sub_stream not in subs_off) + or (kodi_sub_stream is not None + and kodi_sub_stream != item.current_kodi_sub_stream)): + item.on_kodi_subtitle_stream_change(kodi_sub_stream, + sub_enabled) @staticmethod - def switch_to_plex_streams(item): + def switch_to_plex_streams(): """ Override Kodi audio and subtitle streams with Plex PMS' selection """ + item = app.PLAYSTATE.item + if item is None: + # Player might've quit + return for typus in ('audio', 'subtitle'): try: plex_index, language_tag = item.active_plex_stream_index(typus) except TypeError: - if typus == 'subtitle': - LOG.info('Deactivating Kodi subtitles because the PMS ' - 'told us to not show any subtitles') - app.APP.player.showSubtitles(False) + LOG.debug('Deactivating Kodi subtitles because the PMS ' + 'told us to not show any subtitles') + app.APP.player.showSubtitles(False) + item.current_kodi_sub_stream = False continue - LOG.info('The PMS wants to display %s stream with Plex id %s and ' - 'languageTag %s', - typus, plex_index, language_tag) - kodi_index = item.kodi_stream_index(plex_index, - typus) + LOG.debug('The PMS wants to display %s stream with Plex id %s and ' + 'languageTag %s', + typus, plex_index, language_tag) + kodi_index = item.kodi_stream_index(plex_index, typus) if kodi_index is None: - LOG.info('Leaving Kodi %s stream settings untouched since we ' - 'could not parse Plex %s stream with id %s to a Kodi ' - 'index', typus, typus, plex_index) + LOG.debug('Leaving Kodi %s stream settings untouched since we ' + 'could not parse Plex %s stream with id %s to a Kodi' + ' index', typus, typus, plex_index) else: - LOG.info('Switching to Kodi %s stream number %s because the ' - 'PMS told us to show stream with Plex id %s', - typus, kodi_index, plex_index) + LOG.debug('Switching to Kodi %s stream number %s because the ' + 'PMS told us to show stream with Plex id %s', + typus, kodi_index, plex_index) # If we're choosing an "illegal" index, this function does # need seem to fail nor log any errors - if typus == 'subtitle': - app.APP.player.setSubtitleStream(kodi_index) - else: + if typus == 'audio': app.APP.player.setAudioStream(kodi_index) + else: + app.APP.player.setSubtitleStream(kodi_index) + if typus == 'audio': + item.current_kodi_audio_stream = kodi_index + else: + item.current_kodi_sub_stream = kodi_index def _playback_cleanup(ended=False): @@ -585,11 +623,10 @@ def _next_episode(current_api): current_api.grandparent_title()) return try: - next_api = API(xml[counter + 1]) + return API(xml[counter + 1]) except IndexError: # Was the last episode - return - return next_api + pass def _complete_artwork_keys(info): @@ -615,7 +652,7 @@ def _notify_upnext(item): """ if not item.plex_type == v.PLEX_TYPE_EPISODE: return - this_api = API(item.xml) + this_api = item.api next_api = _next_episode(this_api) if next_api is None: return @@ -651,17 +688,34 @@ def _videolibrary_onupdate(data): A specific Kodi library item has been updated. This seems to happen if the user marks an item as watched/unwatched or if playback of the item just stopped + + 2 kinds of messages possible, e.g. + Method: VideoLibrary.OnUpdate Data: ("Reset resume position" and also + fired just after stopping playback - BEFORE OnStop fires) + {'id': 1, 'type': 'movie'} + Method: VideoLibrary.OnUpdate Data: ("Mark as watched") + {'item': {'id': 1, 'type': 'movie'}, 'playcount': 1} """ - playcount = data.get('playcount') - item = data.get('item') - if playcount is None or item is None: - return + item = data.get('item') if 'item' in data else data try: kodi_id = item['id'] kodi_type = item['type'] except (KeyError, TypeError): - LOG.info("Item is invalid for playstate update.") + LOG.debug("Item is invalid for a Plex playstate update") return + playcount = data.get('playcount') + if playcount is None: + # "Reset resume position" + # Kodi might set as watched or unwatched! + with KodiVideoDB(lock=False) as kodidb: + file_id = kodidb.file_id_from_id(kodi_id, kodi_type) + if file_id is None: + return + if kodidb.get_resume(file_id): + # We do have an existing bookmark entry - not toggling to + # either watched or unwatched on the Plex side + return + playcount = kodidb.get_playcount(file_id) or 0 if app.PLAYSTATE.item and kodi_id == app.PLAYSTATE.item.kodi_id and \ kodi_type == app.PLAYSTATE.item.kodi_type: # Kodi updates an item immediately after playback. Hence we do NOT diff --git a/resources/lib/library_sync/additional_metadata.py b/resources/lib/library_sync/additional_metadata.py index 70f5b575..ac87d708 100644 --- a/resources/lib/library_sync/additional_metadata.py +++ b/resources/lib/library_sync/additional_metadata.py @@ -5,6 +5,7 @@ from . import additional_metadata_tmdb from ..plex_db import PlexDB from .. import backgroundthread, utils from .. import variables as v, app +from ..exceptions import ProcessingNotDone logger = getLogger('PLEX.sync.metadata') @@ -22,12 +23,6 @@ SUPPORTED_METADATA = { } -class ProcessingNotDone(Exception): - """Exception to detect whether we've completed our sync and did not have to - abort or suspend.""" - pass - - def processing_is_activated(item_getter): """Checks the PKC settings whether processing is even activated.""" if item_getter == 'missing_fanart': diff --git a/resources/lib/path_ops.py b/resources/lib/path_ops.py index 7dd81dfd..a0c99be0 100644 --- a/resources/lib/path_ops.py +++ b/resources/lib/path_ops.py @@ -31,10 +31,8 @@ def append_os_sep(path): Appends either a '\\' or '/' - IRRELEVANT of the host OS!! (os.path.join is dependant on the host OS) """ - if '/' in path: - return path + '/' - else: - return path + '\\' + separator = '/' if '/' in path else '\\' + return path if path.endswith(separator) else path + separator def translate_path(path): diff --git a/resources/lib/playback.py b/resources/lib/playback.py index 37926b94..a40d4685 100644 --- a/resources/lib/playback.py +++ b/resources/lib/playback.py @@ -15,6 +15,7 @@ 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 +from . import exceptions ############################################################################### LOG = getLogger('PLEX.playback') @@ -191,7 +192,7 @@ def _playback_init(plex_id, plex_type, playqueue, pos, resume): # Special case - we already got a filled Kodi playqueue try: _init_existing_kodi_playlist(playqueue, pos) - except PL.PlaylistError: + except exceptions.PlaylistError: LOG.error('Playback_init for existing Kodi playlist failed') _ensure_resolve(abort=True) return @@ -311,7 +312,7 @@ def _init_existing_kodi_playlist(playqueue, pos): 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') + raise exceptions.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 @@ -443,27 +444,26 @@ def _conclude_playback(playqueue, pos): """ LOG.debug('Concluding playback for playqueue position %s', pos) item = playqueue.items[pos] - api = API(item.xml) - if api.mediastream_number() is None: + if item.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): + item.api.part = item.part or 0 + playback_decision.set_pkc_playmethod(item.api, item) + if not playback_decision.audio_subtitle_prefs(item.api, item): LOG.info('Did not set audio subtitle prefs, aborting silently') _ensure_resolve() return - playback_decision.set_playurl(api, item) + playback_decision.set_playurl(item.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 = item.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()) + listitem.setSubtitles(item.api.cache_external_subs()) transfer.send(listitem) LOG.debug('Done concluding playback') diff --git a/resources/lib/playback_decision.py b/resources/lib/playback_decision.py index fda46db7..8443efbe 100644 --- a/resources/lib/playback_decision.py +++ b/resources/lib/playback_decision.py @@ -328,19 +328,12 @@ def audio_subtitle_prefs(api, item): Returns None if user cancelled or we need to abort, True otherwise """ # Set media and part where we're at - if (api.mediastream is None and - api.mediastream_number() is None): + if api.mediastream is None and api.mediastream_number() is None: return - try: - mediastreams = api.plex_media_streams() - except (TypeError, IndexError): - LOG.error('Could not get media %s, part %s', - api.mediastream, api.part) - return - part_id = mediastreams.attrib['id'] if item.playmethod != v.PLAYBACK_METHOD_TRANSCODE: return True - return setup_transcoding_audio_subtitle_prefs(mediastreams, part_id) + return setup_transcoding_audio_subtitle_prefs(api.plex_media_streams(), + api.part_id()) def setup_transcoding_audio_subtitle_prefs(mediastreams, part_id): @@ -425,7 +418,8 @@ def setup_transcoding_audio_subtitle_prefs(mediastreams, part_id): action_type='PUT', parameters=args) - select_subs_index = '' + # Zero telling the PMS to deactivate subs altogether + select_subs_index = 0 if sub_num == 1: # Note: we DO need to tell the PMS that we DONT want any sub # Otherwise, the PMS might pick-up the last one @@ -444,15 +438,8 @@ def setup_transcoding_audio_subtitle_prefs(mediastreams, part_id): LOG.info('User chose to not burn-in any subtitles') else: LOG.info('User chose to burn-in subtitle %s: %s', - select_subs_index, - subtitle_streams[resp]) + select_subs_index, subtitle_streams[resp]) select_subs_index = subtitle_streams_list[resp - 1] # Now prep the PMS for our choice - args = { - 'subtitleStreamID': select_subs_index, - 'allParts': 1 - } - DU().downloadUrl('{server}/library/parts/%s' % part_id, - action_type='PUT', - parameters=args) + PF.change_subtitle(select_subs_index, part_id) return True diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index e1ce7a48..153b0aed 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -11,22 +11,17 @@ from . import plex_functions as PF from .kodi_db import kodiid_from_filename from .downloadutils import DownloadUtils as DU from . import utils +from .utils import cast from . import json_rpc as js from . import variables as v from . import app +from .exceptions import PlaylistError from .subtitles import accessible_plex_subtitles LOG = getLogger('PLEX.playlist_func') -class PlaylistError(Exception): - """ - Exception for our playlist constructs - """ - pass - - class Playqueue_Object(object): """ PKC object to represent PMS playQueues and Kodi playlist for queueing @@ -150,13 +145,14 @@ class PlaylistItem(object): file = None [str] Path to the item's file. STRING!! uri = None [str] PMS path to item; will be auto-set with plex_id guid = None [str] Weird Plex guid - xml = None [etree] XML from PMS, 1 lvl below + api = None [API] API of xml 1 lvl below playmethod = None [str] either 'DirectPath', 'DirectStream', 'Transcode' playcount = None [int] how many times the item has already been played offset = None [int] the item's view offset UPON START in Plex time part = 0 [int] part number if Plex video consists of mult. parts force_transcode [bool] defaults to False """ + def __init__(self): self.id = None self._plex_id = None @@ -166,7 +162,7 @@ class PlaylistItem(object): self.file = None self._uri = None self.guid = None - self.xml = None + self.api = None self.playmethod = None self.playcount = None self.offset = None @@ -180,6 +176,16 @@ class PlaylistItem(object): # False: do NOT resume, don't ask user # True: do resume, don't ask user self.resume = None + # Get the Plex audio and subtitle streams in the same order as Kodi + # uses them (Kodi uses indexes to activate them, not ids like Plex) + self._streams_have_been_processed = False + self._audio_streams = None + self._subtitle_streams = None + # Which Kodi streams are active? + self.current_kodi_audio_stream = None + # False means "deactivated", None means "we do not have a Kodi + # equivalent for this Plex subtitle" + self.current_kodi_sub_stream = None @property def plex_id(self): @@ -195,6 +201,18 @@ class PlaylistItem(object): def uri(self): return self._uri + @property + def audio_streams(self): + if not self._streams_have_been_processed: + self._process_streams() + return self._audio_streams + + @property + def subtitle_streams(self): + if not self._streams_have_been_processed: + self._process_streams() + return self._subtitle_streams + def __repr__(self): return ("{{" "'id': {self.id}, " @@ -211,85 +229,100 @@ class PlaylistItem(object): "'force_transcode': {self.force_transcode}, " "'part': {self.part}".format(self=self)) + def _process_streams(self): + """ + Builds audio and subtitle streams and enables matching between Plex + and Kodi using self.audio_streams and self.subtitle_streams + """ + # The playqueue response from the PMS does not contain a stream filename + # thanks Plex + self._subtitle_streams = accessible_plex_subtitles( + self.playmethod, + self.file, + self.api.plex_media_streams()) + # Audio streams are much easier - they're always available and sorted + # the same in Kodi and Plex + self._audio_streams = [x for x in self.api.plex_media_streams() + if x.get('streamType') == '2'] + self._streams_have_been_processed = True + + def _get_iterator(self, stream_type): + if stream_type == 'audio': + return self.audio_streams + elif stream_type == 'subtitle': + return self.subtitle_streams + def plex_stream_index(self, kodi_stream_index, stream_type): """ Pass in the kodi_stream_index [int] in order to receive the Plex stream - index. - + index [int]. stream_type: 'video', 'audio', 'subtitle' - Returns None if unsuccessful """ - stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type] - count = 0 - if kodi_stream_index == -1: - # Kodi telling us "it's the last one" - iterator = list(reversed(self.xml[0][self.part])) - kodi_stream_index = 0 - else: - iterator = self.xml[0][self.part] - # Kodi indexes differently than Plex - for stream in iterator: - if (stream.get('streamType') == stream_type and - 'key' in stream.attrib): - if count == kodi_stream_index: - return stream.get('id') - count += 1 - for stream in iterator: - if (stream.get('streamType') == stream_type and - 'key' not in stream.attrib): - if count == kodi_stream_index: - return stream.get('id') - count += 1 + if stream_type == 'audio': + return int(self.audio_streams[kodi_stream_index].get('id')) + elif stream_type == 'subtitle': + try: + return int(self.subtitle_streams[kodi_stream_index].get('id')) + except (IndexError, TypeError): + pass def kodi_stream_index(self, plex_stream_index, stream_type): """ - Pass in the kodi_stream_index [int] in order to receive the Plex stream - index. - + Pass in the plex_stream_index [int] in order to receive the Kodi stream + index [int]. stream_type: 'video', 'audio', 'subtitle' - Returns None if unsuccessful """ if plex_stream_index is None: return - stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type] - count = 0 - streams = self.sorted_accessible_plex_subtitles(stream_type) - for stream in streams: - if utils.cast(int, stream.get('id')) == plex_stream_index: - return count - count += 1 + for i, stream in enumerate(self._get_iterator(stream_type)): + if cast(int, stream.get('id')) == plex_stream_index: + return i def active_plex_stream_index(self, stream_type): """ Returns the following tuple for the active stream on the Plex side: - (id [int], languageTag [str]) + (Plex stream id [int], languageTag [str] or None) Returns None if no stream has been selected """ - stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type] - for stream in self.xml[0][self.part]: - if stream.get('streamType') == stream_type \ - and stream.get('selected') == '1': - return (utils.cast(int, stream.get('id')), - stream.get('languageTag')) + for i, stream in enumerate(self._get_iterator(stream_type)): + if stream.get('selected') == '1': + return (int(stream.get('id')), stream.get('languageTag')) - def sorted_accessible_plex_subtitles(self, stream_type): + def on_kodi_subtitle_stream_change(self, kodi_stream_index, subs_enabled): """ - Returns only the subtitles that Kodi can access when PKC Direct Paths - are used; i.e. Kodi has access to a video's directory. - NOT supported: additional subtitles downloaded using the Plex interface + Call this method if Kodi changed its subtitle and you want Plex to + know. """ - # The playqueue response from the PMS does not contain a stream filename - # thanks Plex - if stream_type == '3': - streams = accessible_plex_subtitles(self.playmethod, - self.file, - self.xml[0][self.part]) + if subs_enabled: + try: + plex_stream_index = int(self.subtitle_streams[kodi_stream_index].get('id')) + except (IndexError, TypeError): + LOG.debug('Kodi subtitle change detected to a sub %s that is ' + 'NOT available on the Plex side', kodi_stream_index) + self.current_kodi_sub_stream = None + return + LOG.debug('Kodi subtitle change detected: telling Plex about ' + 'switch to index %s, Plex stream id %s', + kodi_stream_index, plex_stream_index) + self.current_kodi_sub_stream = kodi_stream_index else: - streams = [x for x in self.xml[0][self.part] - if x.get('streamType') == stream_type] - return streams + plex_stream_index = 0 + LOG.debug('Kodi subtitle has been deactivated, telling Plex') + self.current_kodi_sub_stream = False + PF.change_subtitle(plex_stream_index, self.api.part_id()) + + def on_kodi_audio_stream_change(self, kodi_stream_index): + """ + Call this method if Kodi changed its audio stream and you want Plex to + know. kodi_stream_index [int] + """ + plex_stream_index = int(self.audio_streams[kodi_stream_index].get('id')) + LOG.debug('Changing Plex audio stream to %s, Kodi index %s', + plex_stream_index, kodi_stream_index) + PF.change_audio_stream(plex_stream_index, self.api.part_id()) + self.current_kodi_audio_stream = kodi_stream_index def playlist_item_from_kodi(kodi_item): @@ -315,7 +348,7 @@ def playlist_item_from_kodi(kodi_item): except IndexError: query = '' query = dict(utils.parse_qsl(query)) - item.plex_id = utils.cast(int, query.get('plex_id')) + item.plex_id = cast(int, query.get('plex_id')) item.plex_type = query.get('itemType') LOG.debug('Made playlist item from Kodi: %s', item) return item @@ -413,14 +446,15 @@ def playlist_item_from_xml(xml_video_element, kodi_id=None, kodi_type=None): item.guid = api.guid_html_escaped() item.playcount = api.viewcount() item.offset = api.resume_point() - item.xml = xml_video_element + item.api = api LOG.debug('Created new playlist item from xml: %s', item) return item -def _get_playListVersion_from_xml(playlist, xml): +def _update_playlist_version(playlist, xml): """ - Takes a PMS xml as input to overwrite the playlist version (e.g. Plex + Takes a PMS xml (one level above the xml-depth where we're usually applying + API()) as input to overwrite the playlist version (e.g. Plex playQueueVersion). Raises PlaylistError if unsuccessful @@ -441,18 +475,13 @@ def get_playlist_details_from_xml(playlist, xml): """ if xml is None: raise PlaylistError('No playlist received for playlist %s' % playlist) - playlist.id = utils.cast(int, - xml.get('%sID' % playlist.kind)) - playlist.version = utils.cast(int, - xml.get('%sVersion' % playlist.kind)) - playlist.shuffled = utils.cast(int, - xml.get('%sShuffled' % playlist.kind)) - playlist.selectedItemID = utils.cast(int, - xml.get('%sSelectedItemID' - % playlist.kind)) - playlist.selectedItemOffset = utils.cast(int, - xml.get('%sSelectedItemOffset' - % playlist.kind)) + playlist.id = cast(int, xml.get('%sID' % playlist.kind)) + playlist.version = cast(int, xml.get('%sVersion' % playlist.kind)) + playlist.shuffled = cast(int, xml.get('%sShuffled' % playlist.kind)) + playlist.selectedItemID = cast(int, xml.get('%sSelectedItemID' + % playlist.kind)) + playlist.selectedItemOffset = cast(int, xml.get('%sSelectedItemOffset' + % playlist.kind)) LOG.debug('Updated playlist from xml: %s', playlist) @@ -603,7 +632,7 @@ def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None): raise PlaylistError('Could not add item %s to playlist %s' % (kodi_item, playlist)) api = API(xml[-1]) - item.xml = xml[-1] + item.api = api item.id = api.item_id() item.guid = api.guid_html_escaped() item.offset = api.resume_point() @@ -611,7 +640,7 @@ def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None): playlist.items.append(item) if pos == len(playlist.items) - 1: # Item was added at the end - _get_playListVersion_from_xml(playlist, xml) + _update_playlist_version(playlist, xml) else: # Move the new item to the correct position move_playlist_item(playlist, @@ -655,7 +684,7 @@ def add_item_to_kodi_playlist(playlist, pos, kodi_id=None, kodi_type=None, {'id': kodi_id, 'type': kodi_type, 'file': file}) if item.plex_id is not None: xml = PF.GetPlexMetadata(item.plex_id) - item.xml = xml[-1] + item.api = API(xml[-1]) playlist.items.insert(pos, item) return item @@ -679,9 +708,10 @@ def move_playlist_item(playlist, before_pos, after_pos): playlist.id, playlist.items[before_pos].id, playlist.items[after_pos - 1].id) - # We need to increment the playlistVersion - _get_playListVersion_from_xml( - playlist, DU().downloadUrl(url, action_type="PUT")) + # Tell the PMS that we're moving items around + xml = DU().downloadUrl(url, action_type="PUT") + # We need to increment the playlist version for communicating with the PMS + _update_playlist_version(playlist, xml) # Move our item's position in our internal playlist playlist.items.insert(after_pos, playlist.items.pop(before_pos)) LOG.debug('Done moving for %s', playlist) @@ -729,7 +759,7 @@ def delete_playlist_item_from_PMS(playlist, pos): playlist.repeat), action_type="DELETE") del playlist.items[pos] - _get_playListVersion_from_xml(playlist, xml) + _update_playlist_version(playlist, xml) # Functions operating on the Kodi playlist objects ########## diff --git a/resources/lib/playlists/__init__.py b/resources/lib/playlists/__init__.py index 6c17fa98..1c7a0466 100644 --- a/resources/lib/playlists/__init__.py +++ b/resources/lib/playlists/__init__.py @@ -14,13 +14,13 @@ from logging import getLogger from sqlite3 import OperationalError -from .common import Playlist, PlaylistError, PlaylistObserver, \ - kodi_playlist_hash +from .common import Playlist, PlaylistObserver, kodi_playlist_hash from . import pms, db, kodi_pl, plex_pl from ..watchdog import events from ..plex_api import API from .. import utils, path_ops, variables as v, app +from ..exceptions import PlaylistError ############################################################################### LOG = getLogger('PLEX.playlists') diff --git a/resources/lib/playlists/common.py b/resources/lib/playlists/common.py index 75a8884f..04d14132 100644 --- a/resources/lib/playlists/common.py +++ b/resources/lib/playlists/common.py @@ -11,6 +11,8 @@ from ..watchdog.observers import Observer from ..watchdog.utils.bricks import OrderedSetQueue from .. import path_ops, variables as v, app +from ..exceptions import PlaylistError + ############################################################################### LOG = getLogger('PLEX.playlists.common') @@ -19,13 +21,6 @@ SIMILAR_EVENTS = (events.EVENT_TYPE_CREATED, events.EVENT_TYPE_MODIFIED) ############################################################################### -class PlaylistError(Exception): - """ - The one main exception thrown if anything goes awry - """ - pass - - class Playlist(object): """ Class representing a synced Playlist with info for both Kodi and Plex. diff --git a/resources/lib/playlists/db.py b/resources/lib/playlists/db.py index 1c641a7f..a309f01b 100644 --- a/resources/lib/playlists/db.py +++ b/resources/lib/playlists/db.py @@ -6,10 +6,12 @@ module """ from logging import getLogger -from .common import Playlist, PlaylistError +from .common import Playlist from ..plex_db import PlexDB from ..kodi_db import kodiid_from_filename from .. import utils, variables as v +from ..exceptions import PlaylistError + ############################################################################### LOG = getLogger('PLEX.playlists.db') @@ -120,7 +122,7 @@ def m3u_to_plex_ids(playlist): def playlist_file_to_plex_ids(playlist): """ Takes the playlist file located at path [unicode] and parses it. - Returns a list of plex_ids (str) or raises PL.PlaylistError if a single + Returns a list of plex_ids (str) or raises PlaylistError if a single item cannot be parsed from Kodi to Plex. """ if playlist.kodi_extension == 'm3u': diff --git a/resources/lib/playlists/kodi_pl.py b/resources/lib/playlists/kodi_pl.py index 75685f76..04d97a36 100644 --- a/resources/lib/playlists/kodi_pl.py +++ b/resources/lib/playlists/kodi_pl.py @@ -6,11 +6,13 @@ Create and delete playlists on the Kodi side of things from logging import getLogger import re -from .common import Playlist, PlaylistError, kodi_playlist_hash +from .common import Playlist, kodi_playlist_hash from . import db, pms from ..plex_api import API from .. import utils, path_ops, variables as v +from ..exceptions import PlaylistError + ############################################################################### LOG = getLogger('PLEX.playlists.kodi_pl') REGEX_FILE_NUMBERING = re.compile(r'''_(\d\d)\.\w+$''') diff --git a/resources/lib/playlists/plex_pl.py b/resources/lib/playlists/plex_pl.py index 626262ac..9d170c8f 100644 --- a/resources/lib/playlists/plex_pl.py +++ b/resources/lib/playlists/plex_pl.py @@ -5,8 +5,9 @@ Create and delete playlists on the Plex side of things """ from logging import getLogger -from .common import PlaylistError from . import pms, db +from ..exceptions import PlaylistError + ############################################################################### LOG = getLogger('PLEX.playlists.plex_pl') # Used for updating Plex playlists due to Kodi changes - Plex playlist diff --git a/resources/lib/playlists/pms.py b/resources/lib/playlists/pms.py index a22dee7d..581fb403 100644 --- a/resources/lib/playlists/pms.py +++ b/resources/lib/playlists/pms.py @@ -6,11 +6,11 @@ manipulate playlists """ from logging import getLogger -from .common import PlaylistError - from ..plex_api import API from ..downloadutils import DownloadUtils as DU from .. import utils, app, variables as v +from ..exceptions import PlaylistError + ############################################################################### LOG = getLogger('PLEX.playlists.pms') diff --git a/resources/lib/playqueue.py b/resources/lib/playqueue.py index b1924bf3..1f06cb32 100644 --- a/resources/lib/playqueue.py +++ b/resources/lib/playqueue.py @@ -11,6 +11,7 @@ import xbmc from .plex_api import API from . import playlist_func as PL, plex_functions as PF from . import backgroundthread, utils, json_rpc as js, app, variables as v +from . import exceptions ############################################################################### LOG = getLogger('PLEX.playqueue') @@ -87,7 +88,7 @@ def init_playqueue_from_plex_children(plex_id, transient_token=None): api = API(child) try: PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id) - except PL.PlaylistError: + except exceptions.PlaylistError: LOG.error('Could not add Plex item to our playlist: %s, %s', child.tag, child.attrib) playqueue.plex_transient_token = transient_token @@ -150,7 +151,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread): i + j, i) try: PL.move_playlist_item(playqueue, i + j, i) - except PL.PlaylistError: + except exceptions.PlaylistError: LOG.error('Could not modify playqueue positions') LOG.error('This is likely caused by mixing audio and ' 'video tracks in the Kodi playqueue') @@ -166,7 +167,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread): PL.add_item_to_plex_playqueue(playqueue, i, kodi_item=new_item) - except PL.PlaylistError: + except exceptions.PlaylistError: # Could not add the element pass except KeyError: @@ -195,7 +196,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread): LOG.debug('Detected deletion of playqueue element at pos %s', i) try: PL.delete_playlist_item_from_PMS(playqueue, i) - except PL.PlaylistError: + except exceptions.PlaylistError: LOG.error('Could not delete PMS element from position %s', i) LOG.error('This is likely caused by mixing audio and ' 'video tracks in the Kodi playqueue') diff --git a/resources/lib/plex_api/base.py b/resources/lib/plex_api/base.py index 6cb6978c..c26d820f 100644 --- a/resources/lib/plex_api/base.py +++ b/resources/lib/plex_api/base.py @@ -262,6 +262,12 @@ class Base(object): """ return self.xml[self.mediastream][self.part] + def part_id(self): + """ + Returns the unique id of the currently active part [int] + """ + return int(self.xml[self.mediastream][self.part].attrib['id']) + def plot(self): """ Returns the plot or None. diff --git a/resources/lib/plex_companion.py b/resources/lib/plex_companion.py index 184c0526..25cf12aa 100644 --- a/resources/lib/plex_companion.py +++ b/resources/lib/plex_companion.py @@ -20,6 +20,7 @@ from . import playqueue as PQ from . import variables as v from . import backgroundthread from . import app +from . import exceptions ############################################################################### @@ -50,7 +51,7 @@ def update_playqueue_from_PMS(playqueue, with app.APP.lock_playqueues: try: xml = PL.get_PMS_playlist(playqueue, playqueue_id) - except PL.PlaylistError: + except exceptions.PlaylistError: LOG.error('Could now download playqueue %s', playqueue_id) return if playqueue.id == playqueue_id: @@ -63,7 +64,7 @@ def update_playqueue_from_PMS(playqueue, # Get new metadata for the playqueue first try: PL.get_playlist_details_from_xml(playqueue, xml) - except PL.PlaylistError: + except exceptions.PlaylistError: LOG.error('Could not get playqueue ID %s', playqueue_id) return playqueue.repeat = 0 if not repeat else int(repeat) diff --git a/resources/lib/plex_functions.py b/resources/lib/plex_functions.py index b3c3a44d..23ab6814 100644 --- a/resources/lib/plex_functions.py +++ b/resources/lib/plex_functions.py @@ -1116,3 +1116,35 @@ def playback_decision(path, media, part, playmethod, video=True, args=None): return DU().downloadUrl(utils.extend_url(url, arguments), headerOptions=v.STREAMING_HEADERS, reraise=True) + + +def change_subtitle(plex_stream_id, part_id): + """ + Tell the PMS to display/burn-in the subtitle stream with id plex_stream_id + for the Part (PMS XML etree tag "Part") with unique id part_id. + - plex_stream_id = 0 will deactivate the subtitle + - We always do this for ALL parts of a video + """ + arguments = { + 'subtitleStreamID': plex_stream_id, + 'allParts': 1 + } + url = '{server}/library/parts/%s' % part_id + return DU().downloadUrl(utils.extend_url(url, arguments), + action_type='PUT') + + +def change_audio_stream(plex_stream_id, part_id): + """ + Tell the PMS to display/burn-in the subtitle stream with id plex_stream_id + for the Part (PMS XML etree tag "Part") with unique id part_id. + - plex_stream_id = 0 will deactivate the subtitle + - We always do this for ALL parts of a video + """ + arguments = { + 'audioStreamID': plex_stream_id, + 'allParts': 1 + } + url = '{server}/library/parts/%s' % part_id + return DU().downloadUrl(utils.extend_url(url, arguments), + action_type='PUT') diff --git a/resources/lib/subtitles.py b/resources/lib/subtitles.py index b77f98c4..78fc9c83 100644 --- a/resources/lib/subtitles.py +++ b/resources/lib/subtitles.py @@ -8,6 +8,7 @@ import xml.etree.ElementTree as etree from . import app from . import path_ops from . import variables as v +from .exceptions import SubtitleError LOG = getLogger('PLEX.subtitles') @@ -466,7 +467,3 @@ def external_subs_from_filesystem(dirname, filename): class DummySub(etree.Element): def __init__(self): super(DummySub, self).__init__('Stream-subtitle-dummy') - - -class SubtitleError(Exception): - pass diff --git a/resources/lib/variables.py b/resources/lib/variables.py index 0da3dd67..8174c5ea 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -29,6 +29,10 @@ ADDON_PATH = _ADDON.getAddonInfo('path') ADDON_FOLDER = xbmcvfs.translatePath('special://home') ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile')) +# Used e.g. for json_rpc +KODI_VIDEO_PLAYER_ID = 1 +KODI_AUDIO_PLAYER_ID = 0 + KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1) KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2]) KODILONGVERSION = xbmc.getInfoLabel('System.BuildVersion') @@ -88,17 +92,14 @@ MIN_DB_VERSION = '3.2.1' # Supported databases - version numbers in tuples should decrease SUPPORTED_VIDEO_DB = { - # Kodi 19 - EXTREMLY EXPERIMENTAL! 19: (119, ), 20: (119, ), } SUPPORTED_MUSIC_DB = { - # Kodi 19 - EXTREMLY EXPERIMENTAL! 19: (82, ), 20: (82, ), } SUPPORTED_TEXTURE_DB = { - # Kodi 19 - EXTREMLY EXPERIMENTAL! 19: (13, ), 20: (13, ), }