def settingOptionsFillerLibrarySections(handle, options): # retrieve the media provider mediaProvider = xbmcmediaimport.getProvider(handle) if not mediaProvider: log('cannot retrieve media provider', xbmc.LOGERROR) return # retrieve the media import mediaImport = xbmcmediaimport.getImport(handle) if not mediaImport: log('cannot retrieve media import', xbmc.LOGERROR) return # prepare the media provider settings if not mediaProvider.prepareSettings(): log('cannot prepare media provider settings', xbmc.LOGERROR) return server = Server(mediaProvider) if not server.Authenticate(): log('failed to connect to Plex Media Server for {}'.format(mediaProvider2str(mediaProvider)), xbmc.LOGWARNING) return plexServer = server.PlexServer() # get all library sections mediaTypes = mediaImport.getMediaTypes() librarySections = getLibrarySections(plexServer, mediaTypes) sections = [ (section['title'], section['key']) for section in librarySections ] # get the import's settings settings = mediaImport.getSettings() # pass the list of views back to Kodi settings.setStringOptions(plex.constants.SETTINGS_IMPORT_LIBRARY_SECTIONS, sections)
def isImportReady(handle: int, _options: dict): """Validate that MediaImport at handle ID and associated provider are ready :param handle: Handle id from input :type handle: int :param _options: Options/parameters passed in with the call, Unused :type _options: dict """ # retrieve the media import mediaImport = xbmcmediaimport.getImport(handle) if not mediaImport: log("cannot retrieve media import", xbmc.LOGERROR) xbmcmediaimport.setImportReady(handle, False) return # prepare and get the media import settings importSettings = mediaImport.prepareSettings() if not importSettings: log("cannot prepare media import settings", xbmc.LOGERROR) xbmcmediaimport.setImportReady(handle, False) return # retrieve the media provider mediaProvider = xbmcmediaimport.getProvider(handle) if not mediaProvider: log("cannot retrieve media provider", xbmc.LOGERROR) xbmcmediaimport.setImportReady(handle, False) return # prepare the media provider settings if not mediaProvider.prepareSettings(): log("cannot prepare media provider settings", xbmc.LOGERROR) xbmcmediaimport.setImportReady(handle, False) return try: server = Server(mediaProvider) except: pass importReady = False # check if authentication works with the current provider settings if server.Authenticate(): # check if the chosen library sections exist selectedLibrarySections = getLibrarySectionsFromSettings(importSettings) matchingLibrarySections = getMatchingLibrarySections( server.PlexServer(), mediaImport.getMediaTypes(), selectedLibrarySections ) importReady = len(matchingLibrarySections) > 0 xbmcmediaimport.setImportReady(handle, importReady)
def settingOptionsFillerLibrarySections(handle: int, _options: dict): """Find and set the library sections setting from Plex matching a mediaImport's media type :param handle: Handle id from input :type handle: int :param _options: Options/parameters passed in with the call, Unused :type _options: dict """ # retrieve the media provider mediaProvider = xbmcmediaimport.getProvider(handle) if not mediaProvider: log('cannot retrieve media provider', xbmc.LOGERROR) return # retrieve the media import mediaImport = xbmcmediaimport.getImport(handle) if not mediaImport: log('cannot retrieve media import', xbmc.LOGERROR) return # prepare the media provider settings if not mediaProvider.prepareSettings(): log('cannot prepare media provider settings', xbmc.LOGERROR) return server = Server(mediaProvider) if not server.Authenticate(): log( f"failed to connect to Plex Media Server for {mediaProvider2str(mediaProvider)}", xbmc.LOGWARNING) return plexServer = server.PlexServer() # get all library sections mediaTypes = mediaImport.getMediaTypes() librarySections = getLibrarySections(plexServer, mediaTypes) sections = [(section['title'], section['key']) for section in librarySections] # get the import's settings settings = mediaImport.getSettings() # pass the list of views back to Kodi settings.setStringOptions(plex.constants.SETTINGS_IMPORT_LIBRARY_SECTIONS, sections)
def refreshMetadata(item: ListItem, itemId: int, mediaProvider: xbmcmediaimport.MediaProvider): # create a Plex server instance server = Server(mediaProvider) if not server.Authenticate(): contextLog( f"failed to connect to Plex Media Server for {mediaProvider2str(mediaProvider)}", xbmc.LOGWARNING, entry='refresh') return plexItemClass = Api.getPlexMediaClassFromListItem(item) # get the Plex item with all its details plexItem = Api.getPlexItemDetails(server.PlexServer(), itemId, plexItemClass=plexItemClass) if not plexItem: contextLog( f"failed to determine Plex item for {listItem2str(item, itemId)} from {mediaProvider2str(mediaProvider)}", xbmc.LOGWARNING, entry='refresh') return # trigger a metadata refresh on the Plex server plexItem.refresh() contextLog( f"triggered metadata refresh for {listItem2str(item, itemId)} on {mediaProvider2str(mediaProvider)}", entry="refresh")
def isImportReady(handle, options): # retrieve the media import mediaImport = xbmcmediaimport.getImport(handle) if not mediaImport: log('cannot retrieve media import', xbmc.LOGERROR) return # prepare and get the media import settings importSettings = mediaImport.prepareSettings() if not importSettings: log('cannot prepare media import settings', xbmc.LOGERROR) return # retrieve the media provider mediaProvider = xbmcmediaimport.getProvider(handle) if not mediaProvider: log('cannot retrieve media provider', xbmc.LOGERROR) return # prepare the media provider settings if not mediaProvider.prepareSettings(): log('cannot prepare media provider settings', xbmc.LOGERROR) return try: server = Server(mediaProvider) except: pass importReady = False # check if authentication works with the current provider settings if server.Authenticate(): # check if the chosen library sections exist selectedLibrarySections = getLibrarySectionsFromSettings(importSettings) matchingLibrarySections = getMatchingLibrarySections(server.PlexServer(), mediaImport.getMediaTypes(), selectedLibrarySections) importReady = len(matchingLibrarySections) > 0 xbmcmediaimport.setImportReady(handle, importReady)
def synchronize(item: ListItem, itemId: int, mediaProvider): # find the matching media import mediaImport = getMediaImport(mediaProvider, item) if not mediaImport: contextLog( f"cannot find the media import of {listItem2str(item, itemId)} from {mediaProvider2str(mediaProvider)}", xbmc.LOGERROR, entry='sync') return # determine whether Direct Play is allowed mediaProviderSettings = mediaProvider.getSettings() allowDirectPlay = mediaProviderSettings.getBool(SETTINGS_PROVIDER_PLAYBACK_ALLOW_DIRECT_PLAY) # create a Plex server instance server = Server(mediaProvider) if not server.Authenticate(): contextLog( f"failed to connect to Plex Media Server for {mediaProvider2str(mediaProvider)}", xbmc.LOGWARNING, entry='sync') return plexItemClass = Api.getPlexMediaClassFromListItem(item) # synchronize the active item syncedItem = synchronizeItem(item, itemId, mediaProvider, server.PlexServer(), plexItemClass=plexItemClass, allowDirectPlay=allowDirectPlay) if not syncedItem: return syncedItems = [(xbmcmediaimport.MediaImportChangesetTypeChanged, syncedItem)] if xbmcmediaimport.changeImportedItems(mediaImport, syncedItems): contextLog(f"synchronized {listItem2str(item, itemId)} from {mediaProvider2str(mediaProvider)}", entry='sync') else: contextLog( f"failed to synchronize {listItem2str(item, itemId)} from {mediaProvider2str(mediaProvider)}", xbmc.LOGWARNING, entry='sync')
class ProviderObserver: """Class for observing the PMS websocket stream for live updates and processing the messages.""" class Action: Start = 0 Stop = 1 ENDPOINT = '/:/websockets/notifications' def __init__(self): # default values self._actions = [] self._connected = False self._imports = [] self._mediaProvider = None self._server = None self._settings = None # create the websocket self._websocket = websocket.WebSocket() self._websocket.settimeout(0.1) def __del__(self): self._StopAction() def AddImport(self, mediaImport: xbmcmediaimport.MediaImport): """Add, or update if existing, mediaImport into list of imports :param mediaImport: mediaImport object to update into the import list :type mediaImport: :class:`xbmcmediaimport.MediaImport` """ if not mediaImport: raise ValueError('invalid mediaImport') # look for a matching import matchingImportIndices = self._FindImportIndices(mediaImport) # if a matching import has been found update it if matchingImportIndices: self._imports[matchingImportIndices[0]] = mediaImport log(f"media import {mediaImport2str(mediaImport)} updated", xbmc.LOGINFO) else: # other add the import to the list self._imports.append(mediaImport) log(f"media import {mediaImport2str(mediaImport)} added", xbmc.LOGINFO) def RemoveImport(self, mediaImport: xbmcmediaimport.MediaImport): """Remove an existing mediaImport from the list of imports :param mediaImport: mediaImport object to remove from the import plist :type mediaImport: :class:`xbmcmediaimport.MediaImport` """ if not mediaImport: raise ValueError('invalid mediaImport') # look for a matching import matchingImportIndices = self._FindImportIndices(mediaImport) if not matchingImportIndices: return # remove the media import from the list del self._imports[matchingImportIndices[0]] log(f"media import {mediaImport2str(mediaImport)} removed", xbmc.LOGINFO) def Start(self, mediaProvider: xbmcmediaimport.MediaProvider): """Trigger start of observation for the provided mediaProvider :param mediaProvider: Media provider to start observing :type mediaProvider: :class:`xbmcmediaimport.MediaProvider` """ if not mediaProvider: raise ValueError('invalid mediaProvider') self._actions.append((ProviderObserver.Action.Start, mediaProvider)) def Stop(self): """End all observation tasks""" self._actions.append((ProviderObserver.Action.Stop, None)) def Process(self): """Main trigger to process all queued messages and provider actions""" # process any open actions self._ProcessActions() # process any incoming messages self._ProcessMessages() def _FindImportIndices( self, mediaImport: xbmcmediaimport.MediaImport) -> List[int]: """Find the list index for the provided mediaImport object in the imports list if present :param mediaImport: The mediaImport object to find in the imports list :type mediaImport: :class:`xbmcmediaimport.MediaImport` :return: List of indexes where the mediaImport object was found :rtype: list """ if not mediaImport: raise ValueError('invalid mediaImport') return [ i for i, x in enumerate(self._imports) if x.getProvider().getIdentifier() == mediaImport.getProvider().getIdentifier() and \ x.getMediaTypes() == mediaImport.getMediaTypes() ] def _ProcessActions(self): """Process pending provider actions in the queue (start/stop observation)""" for (action, data) in self._actions: if action == ProviderObserver.Action.Start: self._StartAction(data) elif action == ProviderObserver.Action.Stop: self._StopAction() else: log(f"unknown action {action} to process", xbmc.LOGWARNING) self._actions = [] def _ProcessMessages(self): """Trigger processing of messages from the media providers websocket""" # nothing to do if we are not connected to a Plex server if not self._connected: return while True: try: message = self._websocket.recv() if not message: break messageObj = json.loads(message) if not messageObj: log(( f"invalid JSON message ({len(message)}) from {mediaProvider2str(self._mediaProvider)} " f"received: {message}"), xbmc.LOGWARNING) continue self._ProcessMessage(messageObj) except websocket.WebSocketTimeoutException: break except Exception as e: # TODO(Montellese): remove workaround for Kodi Python 3 issue if e.args and e.args[0] == 'timed out': break log( f"unknown exception when receiving data from {mediaProvider2str(self._mediaProvider)}: " f"{e.args[0]}", xbmc.LOGWARNING) break def _ProcessMessage(self, message: dict): """Determine message type and pass to appropriate processing function :param message: Message data to be processed :type message: dict """ if not message: return if constants.WS_MESSAGE_NOTIFICATION_CONTAINER not in message: log(( f"message without '{constants.WS_MESSAGE_NOTIFICATION_CONTAINER}'" f"received from {mediaProvider2str(self._mediaProvider)}: {json.dumps(message)}" ), xbmc.LOGWARNING) return messageData = message[constants.WS_MESSAGE_NOTIFICATION_CONTAINER] if constants.WS_MESSAGE_NOTIFICATION_TYPE not in messageData: log(( f"message without '{constants.WS_MESSAGE_NOTIFICATION_TYPE}'" f"received from {mediaProvider2str(self._mediaProvider)}: {json.dumps(message)}" ), xbmc.LOGWARNING) return messageType = messageData[constants.WS_MESSAGE_NOTIFICATION_TYPE] if messageType == constants.WS_MESSAGE_NOTIFICATION_TYPE_TIMELINE: self._ProcessMessageTimelineEntry(messageData) elif messageType == constants.WS_MESSAGE_NOTIFICATION_TYPE_PLAYING: self._ProcessMessagePlaySessionState(messageData) elif messageType == constants.WS_MESSAGE_NOTIFICATION_TYPE_ACTIVITY: self._ProcessMessageActivity(messageData) else: log( f"ignoring '{messageType}' from {mediaProvider2str(self._mediaProvider)}: {json.dumps(message)}", xbmc.LOGWARNING) def _ProcessMessageTimelineEntry(self, data: dict): """Gather details from a Timeline Entry message, format into a plex change and trigger further processing :param data: Timeline message data to process :type data: dict """ if constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY not in data: log( f"invalid timeline message received from {mediaProvider2str(self._mediaProvider)}: {json.dumps(data)}", xbmc.LOGWARNING) return timelineEntries = data[ constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY] if not timelineEntries: return required_keys = ( constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_IDENTIFIER, constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_ITEM_ID, constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE, constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE) changedPlexItems = [] for timelineEntry in timelineEntries: if not all(key in timelineEntry for key in required_keys): continue # we are only interested in library changes if (timelineEntry[ constants. WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_IDENTIFIER] != constants. WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_IDENTIFIER_LIBRARY): continue plexItemId = int(timelineEntry[ constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_ITEM_ID]) if not plexItemId: continue # filter and determine the changed item's library type / class plexItemType = timelineEntry[ constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE] if plexItemType == constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE_MOVIE: plexItemLibraryType = api.PLEX_LIBRARY_TYPE_MOVIE elif plexItemType == constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE_TVSHOW: plexItemLibraryType = api.PLEX_LIBRARY_TYPE_TVSHOW elif plexItemType == constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE_SEASON: plexItemLibraryType = api.PLEX_LIBRARY_TYPE_SEASON elif plexItemType == constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE_EPISODE: plexItemLibraryType = api.PLEX_LIBRARY_TYPE_EPISODE else: continue plexItemMediaClass = api.Api.getPlexMediaClassFromLibraryType( plexItemLibraryType) # filter and process the changed item's state plexItemState = timelineEntry[ constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE] if plexItemState == constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE_CREATED: plexItemChangesetType = xbmcmediaimport.MediaImportChangesetTypeAdded elif plexItemState == constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE_FINISHED: plexItemChangesetType = xbmcmediaimport.MediaImportChangesetTypeChanged elif plexItemState == constants.WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE_DELETED: plexItemChangesetType = xbmcmediaimport.MediaImportChangesetTypeRemoved else: continue changedPlexItems.append( (plexItemChangesetType, plexItemId, plexItemMediaClass)) self._ProcessChangedPlexItems(changedPlexItems) def _ProcessMessagePlaySessionState(self, data: dict): """ Gather details from a Play Session message, format into a plex change and trigger further processing Not Implemented :param data: Play Session message data to process :type data: dict """ if constants.WS_MESSAGE_NOTIFICATION_PLAY_SESSION_STATE not in data: log( f"invalid playing message received from {mediaProvider2str(self._mediaProvider)}: {json.dumps(data)}", xbmc.LOGWARNING) return playSessionStates = data[ constants.WS_MESSAGE_NOTIFICATION_PLAY_SESSION_STATE] if not playSessionStates: return # TODO(Montellese) # for playSessionState in playSessionStates: # pass def _ProcessMessageActivity(self, data: dict): """Gather details from an Activity message, format into a plex change and trigger further processing :param data: Activity message data to process :type data: dict """ if constants.WS_MESSAGE_NOTIFICATION_ACTIVITY not in data: log( f"invalid activity message received from {mediaProvider2str(self._mediaProvider)}: {json.dumps(data)}", xbmc.LOGWARNING) return activities = data[constants.WS_MESSAGE_NOTIFICATION_ACTIVITY] if not activities: return required_activity_keys = ( constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_EVENT, constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY) required_activity_details_keys = ( constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_TYPE, constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_CONTEXT) changedPlexItems = [] for activity in activities: if not all(key in activity for key in required_activity_keys): continue # we are only interested in the final result if (activity[constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_EVENT] != constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_EVENT_ENDED): continue activityDetails = activity[ constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY] if not all(key in activityDetails for key in required_activity_details_keys): continue # we are only interested in changes to library items if (activityDetails[ constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_TYPE] != constants. WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_TYPE_REFRESH_ITEMS ): continue context = activityDetails[ constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_CONTEXT] if constants.WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_CONTEXT_KEY not in context: continue plexItemKey = context[ constants. WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_CONTEXT_KEY] plexItemId = api.Api.getItemIdFromPlexKey(plexItemKey) if not plexItemId: continue changedPlexItems.append( (xbmcmediaimport.MediaImportChangesetTypeChanged, plexItemId, None)) self._ProcessChangedPlexItems(changedPlexItems) def _ProcessChangedPlexItems(self, changedPlexItems: List[tuple]): """ Determine if change was an add/update or remove operation, pull the details of the item being changed, format and trigger further processing :param changedPlexItems: List of plex change tuples parsed from websocket messages to process :type changedPlexItems: list """ changedItems = [] for (changesetType, plexItemId, plexItemClass) in changedPlexItems: item = None if changesetType in ( xbmcmediaimport.MediaImportChangesetTypeAdded, xbmcmediaimport.MediaImportChangesetTypeChanged): # get all details for the added / changed item item = self._GetItemDetails(plexItemId, plexItemClass) if not item: log( f"failed to get details for changed item with id {plexItemId}", xbmc.LOGWARNING) continue else: # find the removed item in the list of imported items importedItems = xbmcmediaimport.getImportedItemsByProvider( self._mediaProvider) matchingItems = [ importedItem for importedItem in importedItems if api.Api.getItemIdFromListItem(importedItem) == plexItemId ] if not matchingItems: log(f"failed to find removed item with id {plexItemId}", xbmc.LOGWARNING) continue if len(matchingItems) > 1: log( f"multiple imported items for item with id {plexItemId} found => only removing the first one", xbmc.LOGWARNING) item = matchingItems[0] if not item: log(f"failed to process changed item with id {plexItemId}", xbmc.LOGWARNING) continue changedItems.append((changesetType, item, plexItemId)) self._ChangeItems(changedItems) def _ChangeItems(self, changedItems: List[tuple]): """Send change details to Kodi to perform library updates :param changedItems: List of change detail tuples to process :type changedItems: list """ # map the changed items to their media import changedItemsMap = {} for (changesetType, item, plexItemId) in changedItems: if not item: continue # find a matching import for the changed item mediaImport = self._FindImportForItem(item) if not mediaImport: log( f"failed to determine media import for changed item with id {plexItemId}", xbmc.LOGWARNING) continue if mediaImport not in changedItemsMap: changedItemsMap[mediaImport] = [] changedItemsMap[mediaImport].append((changesetType, item)) # finally pass the changed items grouped by their media import to Kodi for (mediaImport, itemsByImport) in changedItemsMap.items(): if xbmcmediaimport.changeImportedItems(mediaImport, itemsByImport): log( f"changed {len(itemsByImport)} imported items for media import {mediaImport2str(mediaImport)}", xbmc.LOGINFO) else: log((f"failed to change {len(itemsByImport)} imported items " f"for media import {mediaImport2str(mediaImport)}"), xbmc.LOGWARNING) def _GetItemDetails( self, plexItemId: int, plexItemClass: plexapi.video.Video = None) -> xbmcgui.ListItem: """Pull details of plex item from the PMS server as a kodi ListItem :param plexItemId: ID of the item in PMS to get the details of :type plexItemId: int :param plexItemClass: Plex video object media type :type plexItemClass: :class:`plexapi.video.Video`, optional :return: ListItem object populated with the details of the plex item :rtype: :class:`xbmc.ListItem` """ return api.Api.getPlexItemAsListItem( self._server.PlexServer(), plexItemId, plexItemClass, allowDirectPlay=self._settings.getBool( constants.SETTINGS_PROVIDER_PLAYBACK_ALLOW_DIRECT_PLAY)) def _FindImportForItem( self, item: xbmcgui.ListItem) -> xbmcmediaimport.MediaImport: """Find a matching MediaImport in the imports list for the provided ListItem :param item: The plex import item in ListItem object to find a matching import for :type item: :class:`xbmcgui.ListItem` :return: The matching MediaImport item if found :rtype: :class:`xbmcmediaimport.MediaImport` """ videoInfoTag = item.getVideoInfoTag() if not videoInfoTag: return None itemMediaType = videoInfoTag.getMediaType() matchingImports = [ mediaImport for mediaImport in self._imports if itemMediaType in mediaImport.getMediaTypes() ] if not matchingImports: return None return matchingImports[0] def _StartAction(self, mediaProvider: xbmcmediaimport.MediaProvider) -> bool: """Establish websocket connection to the provided MediaProvder and begin listening :param mediaProvider: MediaProvider with websocket to connect to :type mediaProvider: :class:`xbmcmediaimport.MediaProvider` :return: Whether the connection was successful or not :rtype: bool """ if not mediaProvider: raise RuntimeError('invalid mediaProvider') # if we are already connected check if something important changed in the media provider if self._connected: if api.Api.compareMediaProviders(self._mediaProvider, mediaProvider): return True self._StopAction(restart=True) self._mediaProvider = mediaProvider self._settings = self._mediaProvider.prepareSettings() if not self._settings: raise RuntimeError('cannot prepare media provider settings') # create Plex server instance self._server = Server(self._mediaProvider) # first authenticate with the Plex Media Server try: authenticated = self._server.Authenticate() except: authenticated = False if not authenticated: log( f"failed to authenticate with {mediaProvider2str(self._mediaProvider)}", xbmc.LOGERROR) self._Reset() return False # prepare the URL url = self._server.PlexServer().url(self.ENDPOINT, includeToken=True).replace( 'http', 'ws') # connect the websocket try: self._websocket.connect(url) except: log(f"failed to connect to {url} using a websocket", xbmc.LOGERROR) self._Reset() return False log( f"successfully connected to {mediaProvider2str(self._mediaProvider)} to observe media imports", xbmc.LOGINFO) self._connected = True return True def _StopAction(self, restart: bool = False): """Close the current websocket connection and reset connection details back to defaults :param restart: Whether the connection should be re-started or not :type restart: bool, optional """ if not self._connected: return if not restart: log( f"stopped observing media imports from {mediaProvider2str(self._mediaProvider)}", xbmc.LOGINFO) self._websocket.close() self._Reset() def _Reset(self): """Reset connection state variables back to defaults""" self._connected = False self._server = None self._mediaProvider = None
class ProviderObserver: class Action: Start = 0 Stop = 1 ENDPOINT = '/:/websockets/notifications' def __init__(self): # default values self._actions = [] self._connected = False self._imports = [] self._mediaProvider = None self._server = None # create the websocket self._websocket = websocket.WebSocket() self._websocket.settimeout(0.1) def __del__(self): self._StopAction() def AddImport(self, mediaImport): if not mediaImport: raise ValueError('invalid mediaImport') # look for a matching import matchingImportIndices = self._FindImportIndices(mediaImport) # if a matching import has been found update it if matchingImportIndices: self._imports[matchingImportIndices[0]] = mediaImport log('media import {} updated'.format(mediaImport2str(mediaImport))) else: # other add the import to the list self._imports.append(mediaImport) log('media import {} added'.format(mediaImport2str(mediaImport))) def RemoveImport(self, mediaImport): if not mediaImport: raise ValueError('invalid mediaImport') # look for a matching import matchingImportIndices = self._FindImportIndices(mediaImport) if not matchingImportIndices: return # remove the media import from the list del self._imports[matchingImportIndices[0]] log('media import {} removed'.format(mediaImport2str(mediaImport))) def Start(self, mediaProvider): if not mediaProvider: raise ValueError('invalid mediaProvider') self._actions.append((ProviderObserver.Action.Start, mediaProvider)) def Stop(self): self._actions.append((ProviderObserver.Action.Stop, None)) def Process(self): # process any open actions self._ProcessActions() # process any incoming messages self._ProcessMessages() def _FindImportIndices(self, mediaImport): if not mediaImport: raise ValueError('invalid mediaImport') return [ i for i, x in enumerate(self._imports) if x.getPath() == mediaImport.getPath() and x.getMediaTypes() == mediaImport.getMediaTypes() ] def _ProcessActions(self): for (action, data) in self._actions: if action == ProviderObserver.Action.Start: self._StartAction(data) elif action == ProviderObserver.Action.Stop: self._StopAction() else: log('unknown action {} to process'.format(action), xbmc.LOGWARNING) self._actions = [] def _ProcessMessages(self): # nothing to do if we are not connected to an Emby server if not self._connected: return while True: try: message = self._websocket.recv() if message is None: break messageObj = json.loads(message) if not messageObj: log( 'invalid JSON message ({}) from {} received: {}'. format(len(message), mediaProvider2str(self._mediaProvider), message), xbmc.LOGWARNING) continue self._ProcessMessage(messageObj) except websocket.WebSocketTimeoutException: break except Exception as error: log( 'unknown exception when receiving data from {}: {}'.format( mediaProvider2str(self._mediaProvider), error.args[0]), xbmc.LOGWARNING) break def _ProcessMessage(self, message): if not message: return if not WS_MESSAGE_NOTIFICATION_CONTAINER in message: log( 'message without "{}" received from {}: {}'.format( WS_MESSAGE_NOTIFICATION_CONTAINER, mediaProvider2str(self._mediaProvider), json.dumps(message)), xbmc.LOGWARNING) return messageData = message[WS_MESSAGE_NOTIFICATION_CONTAINER] if not WS_MESSAGE_NOTIFICATION_TYPE in messageData: log( 'message without "{}" received from {}: {}'.format( WS_MESSAGE_NOTIFICATION_TYPE, mediaProvider2str(self._mediaProvider), json.dumps(message)), xbmc.LOGWARNING) return messageType = messageData[WS_MESSAGE_NOTIFICATION_TYPE] if messageType == WS_MESSAGE_NOTIFICATION_TYPE_TIMELINE: self._ProcessMessageTimelineEntry(messageData) elif messageType == WS_MESSAGE_NOTIFICATION_TYPE_PLAYING: self._ProcessMessagePlaySessionState(messageData) elif messageType == WS_MESSAGE_NOTIFICATION_TYPE_ACTIVITY: self._ProcessMessageActivity(messageData) else: log( 'ignoring "{}" message from {}: {}'.format( messageType, mediaProvider2str(self._mediaProvider), json.dumps(message)), xbmc.LOGDEBUG) def _ProcessMessageTimelineEntry(self, data): if not WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY in data: log( 'invalid timeline message received from {}: {}'.format( mediaProvider2str(self._mediaProvider), json.dumps(data)), xbmc.LOGWARNING) return timelineEntries = data[WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY] if not timelineEntries: return changedPlexItems = [] for timelineEntry in timelineEntries: if not all(key in timelineEntry for key in ( WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_IDENTIFIER, WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_ITEM_ID, WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE, WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE)): continue # we are only interested in library changes if timelineEntry[ WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_IDENTIFIER] != WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_IDENTIFIER_LIBRARY: continue plexItemId = int( timelineEntry[WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_ITEM_ID]) if not plexItemId: continue # filter and determine the changed item's library type / class plexItemType = timelineEntry[ WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE] if plexItemType == WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE_MOVIE: plexItemLibraryType = PLEX_LIBRARY_TYPE_MOVIE elif plexItemType == WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE_TVSHOW: plexItemLibraryType = PLEX_LIBRARY_TYPE_TVSHOW elif plexItemType == WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE_SEASON: plexItemLibraryType = PLEX_LIBRARY_TYPE_SEASON elif plexItemType == WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_TYPE_EPISODE: plexItemLibraryType = PLEX_LIBRARY_TYPE_EPISODE else: continue plexItemMediaClass = Api.getPlexMediaClassFromLibraryType( plexItemLibraryType) # filter and process the changed item's state plexItemState = timelineEntry[ WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE] if plexItemState == WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE_CREATED: plexItemChangesetType = xbmcmediaimport.MediaImportChangesetTypeAdded elif plexItemState == WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE_FINISHED: plexItemChangesetType = xbmcmediaimport.MediaImportChangesetTypeChanged elif plexItemState == WS_MESSAGE_NOTIFICATION_TIMELINE_ENTRY_STATE_DELETED: plexItemChangesetType = xbmcmediaimport.MediaImportChangesetTypeRemoved else: continue changedPlexItems.append( (plexItemChangesetType, plexItemId, plexItemMediaClass)) self._ProcessChangedPlexItems(changedPlexItems) def _ProcessMessagePlaySessionState(self, data): if not WS_MESSAGE_NOTIFICATION_PLAY_SESSION_STATE in data: log( 'invalid playing message received from {}: {}'.format( mediaProvider2str(self._mediaProvider), json.dumps(data)), xbmc.LOGWARNING) return playSessionStates = data[WS_MESSAGE_NOTIFICATION_PLAY_SESSION_STATE] if not playSessionStates: return for playSessionState in playSessionStates: # TODO(Montellese) pass def _ProcessMessageActivity(self, data): if not WS_MESSAGE_NOTIFICATION_ACTIVITY in data: log( 'invalid activity message received from {}: {}'.format( mediaProvider2str(self._mediaProvider), json.dumps(data)), xbmc.LOGWARNING) return activities = data[WS_MESSAGE_NOTIFICATION_ACTIVITY] if not activities: return changedPlexItems = [] for activity in activities: if not all(key in activity for key in (WS_MESSAGE_NOTIFICATION_ACTIVITY_EVENT, WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY)): continue # we are only interested in the final result if activity[ WS_MESSAGE_NOTIFICATION_ACTIVITY_EVENT] != WS_MESSAGE_NOTIFICATION_ACTIVITY_EVENT_ENDED: continue activityDetails = activity[ WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY] if not all(key in activityDetails for key in ( WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_TYPE, WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_CONTEXT)): continue # we are only interested in changes to library items if activityDetails[ WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_TYPE] != WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_TYPE_REFRESH_ITEMS: continue context = activityDetails[ WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_CONTEXT] if not WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_CONTEXT_KEY in context: continue plexItemKey = context[ WS_MESSAGE_NOTIFICATION_ACTIVITY_ACTIVITY_CONTEXT_KEY] plexItemId = Api.getItemIdFromPlexKey(plexItemKey) if not plexItemId: continue changedPlexItems.append( (xbmcmediaimport.MediaImportChangesetTypeChanged, plexItemId, None)) self._ProcessChangedPlexItems(changedPlexItems) def _ProcessChangedPlexItems(self, changedPlexItems): changedItems = [] for (changesetType, plexItemId, plexItemClass) in changedPlexItems: item = None if changesetType == xbmcmediaimport.MediaImportChangesetTypeAdded or \ changesetType == xbmcmediaimport.MediaImportChangesetTypeChanged: # get all details for the added / changed item item = self._GetItemDetails(plexItemId, plexItemClass) if not item: log( 'failed to get details for changed item with id {}'. format(plexItemId), xbmc.LOGWARNING) continue else: # find the removed item in the list of imported items importedItems = xbmcmediaimport.getImportedItemsByProvider( self._mediaProvider) matchingItems = [ importedItem for importedItem in importedItems if Api.getItemIdFromListItem(importedItem) == plexItemId ] if not matchingItems: log( 'failed to find removed item with id {}'.format( plexItemId), xbmc.LOGWARNING) continue if len(matchingItems) > 1: log( 'multiple imported items for item with id {} found => only removing the first one' .format(plexItemId), xbmc.LOGWARNING) item = matchingItems[0] if not item: log( 'failed to process changed item with id {}'.format( plexItemId), xbmc.LOGWARNING) continue changedItems.append((changesetType, item, plexItemId)) self._ChangeItems(changedItems) def _ChangeItems(self, changedItems): # map the changed items to their media import changedItemsMap = {} for (changesetType, item, plexItemId) in changedItems: if not item: continue # find a matching import for the changed item mediaImport = self._FindImportForItem(item) if not mediaImport: log( 'failed to determine media import for changed item with id {}' .format(plexItemId), xbmc.LOGWARNING) continue if mediaImport not in changedItemsMap: changedItemsMap[mediaImport] = [] changedItemsMap[mediaImport].append((changesetType, item)) # finally pass the changed items grouped by their media import to Kodi for (mediaImport, changedItems) in changedItemsMap.items(): if xbmcmediaimport.changeImportedItems(mediaImport, changedItems): log('changed {} imported items for media import {}'.format( len(changedItems), mediaImport2str(mediaImport))) else: log( 'failed to change {} imported items for media import {}'. format(len(changedItems), mediaImport2str(mediaImport)), xbmc.LOGWARNING) def _GetItemDetails(self, plexItemId, plexItemClass=None): return Api.getPlexItemAsListItem(self._server.PlexServer(), plexItemId, plexItemClass) def _FindImportForItem(self, item): videoInfoTag = item.getVideoInfoTag() if not videoInfoTag: return None itemMediaType = videoInfoTag.getMediaType() matchingImports = [ mediaImport for mediaImport in self._imports if itemMediaType in mediaImport.getMediaTypes() ] if not matchingImports: return None return matchingImports[0] def _StartAction(self, mediaProvider): if not mediaProvider: raise RuntimeError('invalid mediaProvider') # if we are already connected check if something important changed in the media provider if self._connected: if Api.compareMediaProviders(self._mediaProvider, mediaProvider): return True self._StopAction(restart=True) self._mediaProvider = mediaProvider settings = self._mediaProvider.prepareSettings() if not settings: raise RuntimeError('cannot prepare media provider settings') # create Plex server instance self._server = Server(self._mediaProvider) # first authenticate with the Plex Media Server try: authenticated = self._server.Authenticate() except: authenticated = False if not authenticated: log( 'failed to authenticate with {}'.format( mediaProvider2str(self._mediaProvider)), xbmc.LOGERROR) self._Reset() return False # prepare the URL url = self._server.PlexServer().url(self.ENDPOINT, includeToken=True).replace( 'http', 'ws') # connect the websocket try: self._websocket.connect(url) except: log('failed to connect to {} using a websocket'.format(url), xbmc.LOGERROR) self._Reset() return False log('successfully connected to {} to observe media imports'.format( mediaProvider2str(self._mediaProvider))) self._connected = True return True def _StopAction(self, restart=False): if not self._connected: return if not restart: log('stopped observing media imports from {}'.format( mediaProvider2str(self._mediaProvider))) self._websocket.close() self._Reset() def _Reset(self): self._connected = False self._server = None self._mediaProvider = None
def play(item, itemId, mediaProvider): if item.isFolder(): contextLog(f"cannot play folder item {listItem2str(item, itemId)}", xbmc.LOGERROR, entry='play') return # create a Plex server instance server = Server(mediaProvider) if not server.Authenticate(): contextLog( f"failed to connect to Plex Media Server for {mediaProvider2str(mediaProvider)}", xbmc.LOGWARNING, entry='sync') return plexItemClass = Api.getPlexMediaClassFromListItem(item) # cannot play folders if plexItemClass in (collection.Collection, video.Show, video.Season): contextLog(f"cannot play folder item {listItem2str(item, itemId)}", xbmc.LOGERROR, entry='play') return # get the Plex item with all its details plexItem = Api.getPlexItemDetails(server.PlexServer(), itemId, plexItemClass=plexItemClass) if not plexItem: contextLog( f"failed to determine Plex item for {listItem2str(item, itemId)} from {mediaProvider2str(mediaProvider)}", xbmc.LOGWARNING, entry='refresh') return # cannot play folders if not Api.canPlay(plexItem): contextLog(f"cannot play item {listItem2str(item, itemId)}", xbmc.LOGERROR, entry='play') return playChoices = [] playChoicesUrl = [] # determine whether Direct Play is allowed mediaProviderSettings = mediaProvider.getSettings() allowDirectPlay = mediaProviderSettings.getBool(SETTINGS_PROVIDER_PLAYBACK_ALLOW_DIRECT_PLAY) # check if the item supports Direct Play if allowDirectPlay: directPlayUrl = Api.getDirectPlayUrlFromPlexItem(plexItem) if directPlayUrl: playChoices.append(localize(32103)) playChoicesUrl.append(directPlayUrl) # check if the item supports streaming directStreamUrl = Api.getStreamUrlFromPlexItem(plexItem, server.PlexServer()) if directStreamUrl: playChoices.append(localize(32104)) playChoicesUrl.append(directStreamUrl) # check if the item has multiple versions multipleVersions = [] if len(plexItem.media) > 1: for mediaStream in plexItem.media: url = None if allowDirectPlay: directPlayUrl = Api.getDirectPlayUrlFromMedia(mediaStream) if directPlayUrl: url = directPlayUrl if not url: url = Api.getStreamUrlFromMedia(mediaStream, server.PlexServer()) # get the display title of the first videostream for mediaPart in mediaStream.parts: # get all video streams videoStreams = (stream for stream in mediaPart.streams if isinstance(stream, media.VideoStream)) # extract the first non-empty display resolution displayResolution = next( ( stream.displayTitle or stream.extendedDisplayTitle for stream in videoStreams if stream.displayTitle or stream.extendedDisplayTitle ), None) if displayResolution: break # fall back to the basic video resolution of the stream if not displayResolution: displayResolution = mediaStream.videoResolution multipleVersions.append((url, mediaStream.bitrate, displayResolution)) if len(multipleVersions) > 1: playChoices.append(localize(32105)) playChoicesUrl.append(PLAY_MULTIPLE_VERSIONS_KEY) # if there are no options something went wrong if not playChoices: contextLog( f"cannot play {listItem2str(item, itemId)} from {mediaProvider2str(mediaProvider)}", xbmc.LOGERROR, entry='play') return # ask the user how to play playChoice = Dialog().contextmenu(playChoices) if playChoice < 0 or playChoice >= len(playChoices): return playUrl = playChoicesUrl[playChoice] # check if the user chose to choose which version to play if playUrl == PLAY_MULTIPLE_VERSIONS_KEY: playChoices.clear() playChoicesUrl.clear() # sort the available versions by bitrate (second field) multipleVersions.sort(key=lambda version: version[1], reverse=True) for version in multipleVersions: playChoices.append( localize(32106, bitrate=bitrate2str(version[1]), resolution=version[2])) playChoicesUrl.append(version[0]) # ask the user which version to play playChoice = Dialog().contextmenu(playChoices) if playChoice < 0 or playChoice >= len(playChoices): return playUrl = playChoicesUrl[playChoice] # play the item contextLog( ( f'playing {listItem2str(item, itemId)} using "{playChoices[playChoice]}" ({playUrl}) ' f'from {mediaProvider2str(mediaProvider)}' ), entry='play') # overwrite the dynamic path of the ListItem item.setDynamicPath(playUrl) xbmc.Player().play(playUrl, item)
def updateOnProvider(handle, options): # retrieve the media import mediaImport = xbmcmediaimport.getImport(handle) if not mediaImport: log('cannot retrieve media import', xbmc.LOGERROR) return # retrieve the media provider mediaProvider = mediaImport.getProvider() if not mediaProvider: log('cannot retrieve media provider', xbmc.LOGERROR) return # prepare the media provider settings if not mediaProvider.prepareSettings(): log('cannot prepare media provider settings', xbmc.LOGERROR) return # prepare and get the media import settings importSettings = mediaImport.prepareSettings() if not importSettings: log('cannot prepare media import settings', xbmc.LOGERROR) return item = xbmcmediaimport.getUpdatedItem(handle) if not item: log('cannot retrieve updated item', xbmc.LOGERROR) return itemVideoInfoTag = item.getVideoInfoTag() if not itemVideoInfoTag: log('updated item is not a video item', xbmc.LOGERROR) return # determine the item's identifier / ratingKey itemId = Api.getItemIdFromListItem(item) if not itemId: log('cannot determine the identifier of the updated item: {}'.format(itemVideoInfoTag.getPath()), xbmc.LOGERROR) return # create a Plex server instance server = Server(mediaProvider) if not server.Authenticate(): log('failed to connect to Plex Media Server for {}'.format(mediaProvider2str(mediaProvider)), xbmc.LOGWARNING) return plexItem = Api.getPlexItemDetails(server.PlexServer(), itemId, Api.getPlexMediaClassFromMediaType(itemVideoInfoTag.getMediaType())) if not plexItem: log('cannot retrieve details of updated item {} with id {}'.format(itemVideoInfoTag.getPath(), itemId), xbmc.LOGERROR) return # check / update watched state playcount = itemVideoInfoTag.getPlayCount() watched = playcount > 0 if watched != plexItem.isWatched: if watched: plexItem.markWatched() else: plexItem.markUnwatched() # TODO(Montellese): check / update last played # TODO(Montellese): check / update resume point xbmcmediaimport.finishUpdateOnProvider(handle)
def updateOnProvider(handle: int, _options: dict): """Perform update/export of library items from Kodi into conifigured PMS (watch status, resume points, etc.) :param handle: Handle id from input :type handle: int :param _options: Options/parameters passed in with the call, Unused :type _options: dict """ # retrieve the media import mediaImport = xbmcmediaimport.getImport(handle) if not mediaImport: log("cannot retrieve media import", xbmc.LOGERROR) return # retrieve the media provider mediaProvider = mediaImport.getProvider() if not mediaProvider: log("cannot retrieve media provider", xbmc.LOGERROR) return # prepare the media provider settings if not mediaProvider.prepareSettings(): log("cannot prepare media provider settings", xbmc.LOGERROR) return # prepare and get the media import settings importSettings = mediaImport.prepareSettings() if not importSettings: log("cannot prepare media import settings", xbmc.LOGERROR) return item = xbmcmediaimport.getUpdatedItem(handle) if not item: log("cannot retrieve updated item", xbmc.LOGERROR) return itemVideoInfoTag = item.getVideoInfoTag() if not itemVideoInfoTag: log("updated item is not a video item", xbmc.LOGERROR) return # determine the item's identifier / ratingKey itemId = Api.getItemIdFromListItem(item) if not itemId: log( f"cannot determine the identifier of the updated item: {itemVideoInfoTag.getPath()}", xbmc.LOGERROR) return # create a Plex server instance server = Server(mediaProvider) if not server.Authenticate(): log( f"failed to connect to Plex Media Server for {mediaProvider2str(mediaProvider)}", xbmc.LOGWARNING) return plexItem = Api.getPlexItemDetails( server.PlexServer(), itemId, Api.getPlexMediaClassFromMediaType(itemVideoInfoTag.getMediaType())) if not plexItem: log( f"cannot retrieve details of updated item {itemVideoInfoTag.getPath()} with id {itemId}", xbmc.LOGERROR) return # check / update watched state playcount = itemVideoInfoTag.getPlayCount() watched = playcount > 0 if watched != plexItem.isWatched: if watched: plexItem.markWatched() else: plexItem.markUnwatched() # TODO(Montellese): check / update last played # TODO(Montellese): check / update resume point xbmcmediaimport.finishUpdateOnProvider(handle)