import json import http import plexconnection import plexresource import plexserver import myplexserver import signalsmixin import callback import plexapp import gdm import util class SearchContext(dict): def __getattr__(self, attr): return self.get(attr) def __setattr__(self, attr, value): self[attr] = value class PlexServerManager(signalsmixin.SignalsMixin): def __init__(self): signalsmixin.SignalsMixin.__init__(self) # obj.Append(ListenersMixin()) self.serversByUuid = {} self.selectedServer = None self.transcodeServer = None self.channelServer = None self.deferReachabilityTimer = None self.startSelectedServerSearch() self.loadState() plexapp.APP.on("change:user", callback.Callable(self.onAccountChange)) plexapp.APP.on("change:allow_insecure", callback.Callable(self.onSecurityChange)) plexapp.APP.on("change:manual_connections", callback.Callable(self.onManualConnectionChange)) def getSelectedServer(self): return self.selectedServer def setSelectedServer(self, server, force=False): # Don't do anything if the server is already selected. if self.selectedServer and self.selectedServer == server: return False if server: # Don't select servers that don't have connections. if not server.activeConnection: return False # Don't select servers that are not supported if not server.isSupported: return False if not self.selectedServer or force: util.LOG("Setting selected server to {0}".format(server)) self.selectedServer = server # Update our saved state. self.saveState() # Notify anyone who might care. plexapp.APP.trigger("change:selectedServer", server=server) return True return False def getServer(self, uuid=None): if uuid is None: return None elif uuid == "myplex": return myplexserver.MyPlexServer() else: return self.serversByUuid[uuid] def getServers(self): servers = [] for uuid in self.serversByUuid: if uuid != "myplex": servers.append(self.serversByUuid[uuid]) return servers def hasPendingRequests(self): for server in self.getServers(): if server.pendingReachabilityRequests: return True return False def removeServer(self, server): del self.serversByUuid[server.uuid] self.trigger('remove:server') if server == self.selectedServer: util.LOG("The selected server went away") self.setSelectedServer(None, force=True) if server == self.transcodeServer: util.LOG("The selected transcode server went away") self.transcodeServer = None if server == self.channelServer: util.LOG("The selected channel server went away") self.channelServer = None def updateFromConnectionType(self, servers, source): self.markDevicesAsRefreshing() for server in servers: self.mergeServer(server) if self.searchContext and source == plexresource.ResourceConnection.SOURCE_MYPLEX: self.searchContext.waitingForResources = False self.deviceRefreshComplete(source) self.updateReachability(True, True) self.saveState() def updateFromDiscovery(self, server): merged = self.mergeServer(server) if not merged.activeConnection: merged.updateReachability(False, True) else: # self.notifyAboutDevice(merged, True) pass def markDevicesAsRefreshing(self): for uuid in self.serversByUuid.keys(): self.serversByUuid[uuid].markAsRefreshing() def mergeServer(self, server): if server.uuid in self.serversByUuid: existing = self.serversByUuid[server.uuid] existing.merge(server) util.DEBUG_LOG("Merged {0}".format(repr(server.name))) return existing else: self.serversByUuid[server.uuid] = server util.DEBUG_LOG("Added new server {0}".format(repr(server.name))) self.trigger("new:server", server=server) return server def deviceRefreshComplete(self, source): toRemove = [] for uuid in self.serversByUuid: if not self.serversByUuid[uuid].markUpdateFinished(source): toRemove.append(uuid) for uuid in toRemove: server = self.serversByUuid[uuid] util.DEBUG_LOG("Server {0} has no more connections - removing".format(repr(server.name))) # self.notifyAboutDevice(server, False) self.removeServer(server) def updateReachability(self, force=False, preferSearch=False, defer=False): # We don't need to test any servers unless we are signed in and authenticated. if not plexapp.ACCOUNT.isAuthenticated and plexapp.ACCOUNT.isActive(): util.LOG("Ignore testing server reachability until we're authenticated") return # To improve reachability performance and app startup, we'll try to test the # preferred server first, and defer the connection tests for a few seconds. hasPreferredServer = bool(self.searchContext.preferredServer) preferredServerExists = hasPreferredServer and self.searchContext.preferredServer in self.serversByUuid if preferSearch and hasPreferredServer and preferredServerExists: # Update the preferred server immediately if requested and exits util.LOG("Updating reachability for preferred server: force={0}".format(force)) self.serversByUuid[self.searchContext.preferredServer].updateReachability(force) self.deferUpdateReachability() elif defer: self.deferUpdateReachability() elif hasPreferredServer and not preferredServerExists and gdm.DISCOVERY.isActive(): # Defer the update if requested or if GDM discovery is enabled and # active while the preferred server doesn't exist. util.LOG("Defer update reachability until GDM has finished to help locate the preferred server") self.deferUpdateReachability(True, False) else: if self.deferReachabilityTimer: self.deferReachabilityTimer.cancel() self.deferReachabilityTimer = None util.LOG("Updating reachability for all devices: force={0}".format(force)) for uuid in self.serversByUuid: self.serversByUuid[uuid].updateReachability(force) def cancelReachability(self): if self.deferReachabilityTimer: self.deferReachabilityTimer.cancel() self.deferReachabilityTimer = None for uuid in self.serversByUuid: self.serversByUuid[uuid].cancelReachability() def updateReachabilityResult(self, server, reachable=False): searching = not self.selectedServer and self.searchContext if reachable: # If we're in the middle of a search for our selected server, see if # this is a candidate. self.trigger('reachable:server', server=server) if searching: # If this is what we were hoping for, select it if server.uuid == self.searchContext.preferredServer: self.setSelectedServer(server, True) elif server.synced: self.searchContext.fallbackServer = server elif self.compareServers(self.searchContext.bestServer, server) < 0: self.searchContext.bestServer = server else: # If this is what we were hoping for, see if there are any more pending # requests to hope for. if searching and server.uuid == self.searchContext.preferredServer and server.pendingReachabilityRequests <= 0: self.searchContext.preferredServer = None if server == self.selectedServer: util.LOG("Selected server is not reachable") self.setSelectedServer(None, True) if server == self.transcodeServer: util.LOG("The selected transcode server is not reachable") self.transcodeServer = None if server == self.channelServer: util.LOG("The selected channel server is not reachable") self.channelServer = None # See if we should settle for the best we've found so far. self.checkSelectedServerSearch() def checkSelectedServerSearch(self, skip_preferred=False, skip_owned=False): if self.selectedServer: return self.selectedServer elif self.searchContext: # If we're still waiting on the resources response then there's no # reason to settle, so don't even iterate over our servers. if self.searchContext.waitingForResources: util.DEBUG_LOG("Still waiting for plex.tv resources") return waitingForPreferred = False waitingForOwned = False waitingForAnything = False waitingToTestAll = bool(self.deferReachabilityTimer) if skip_preferred: self.searchContext.preferredServer = None if self.deferReachabilityTimer: self.deferReachabilityTimer.cancel() self.deferReachabilityTimer = None if not skip_owned: # Iterate over all our servers and see if we're waiting on any results servers = self.getServers() pendingCount = 0 for server in servers: if server.pendingReachabilityRequests > 0: pendingCount += server.pendingReachabilityRequests if server.uuid == self.searchContext.preferredServer: waitingForPreferred = True elif server.owned: waitingForOwned = True else: waitingForAnything = True pendingString = "{0} pending reachability tests".format(pendingCount) if waitingForPreferred: util.LOG("Still waiting for preferred server: " + pendingString) elif waitingToTestAll: util.LOG("Preferred server not reachable, testing all servers now") self.updateReachability(True, False, False) elif waitingForOwned and (not self.searchContext.bestServer or not self.searchContext.bestServer.owned): util.LOG("Still waiting for an owned server: " + pendingString) elif waitingForAnything and not self.searchContext.bestServer: util.LOG("Still waiting for any server: {0}".format(pendingString)) else: # No hope for anything better, let's select what we found util.LOG("Settling for the best server we found") self.setSelectedServer(self.searchContext.bestServer or self.searchContext.fallbackServer, True) return self.selectedServer def compareServers(self, first, second): if not first or not first.isSupported: return second and -1 or 0 elif not second: return 1 elif first.owned != second.owned: return first.owned and 1 or -1 elif first.isLocalConnection() != second.isLocalConnection(): return first.isLocalConnection() and 1 or -1 else: return 0 def loadState(self): jstring = plexapp.INTERFACE.getRegistry("PlexServerManager") if not jstring: return try: obj = json.loads(jstring) except: util.ERROR() obj = None if not obj: util.ERROR_LOG("Failed to parse PlexServerManager JSON") return for serverObj in obj['servers']: server = plexserver.createPlexServerForName(serverObj['uuid'], serverObj['name']) server.owned = bool(serverObj.get('owned')) server.sameNetwork = serverObj.get('sameNetwork') hasSecureConn = False for i in range(len(serverObj.get('connections', []))): conn = serverObj['connections'][i] if conn['address'][:5] == "https": hasSecureConn = True break for i in range(len(serverObj.get('connections', []))): conn = serverObj['connections'][i] isFallback = hasSecureConn and conn['address'][:5] != "https" sources = plexconnection.PlexConnection.SOURCE_BY_VAL[conn['sources']] connection = plexconnection.PlexConnection(sources, conn['address'], conn['isLocal'], conn['token'], isFallback) # Keep the secure connection on top if connection.isSecure: server.connections.insert(0, connection) else: server.connections.append(connection) self.serversByUuid[server.uuid] = server util.LOG("Loaded {0} servers from registry".format(len(obj['servers']))) self.updateReachability(False, True) def saveState(self): # Serialize our important information to JSON and save it to the registry. # We'll always update server info upon connecting, so we don't need much # info here. We do have to use roArray instead of roList, because Brightscript. obj = {} servers = self.getServers() obj['servers'] = [] for server in servers: # Don't save secondary servers. They should be discovered through GDM or myPlex. if not server.isSecondary(): serverObj = { 'name': server.name, 'uuid': server.uuid, 'owned': server.owned, 'sameNetwork': server.sameNetwork, 'connections': [] } for i in range(len(server.connections)): conn = server.connections[i] serverObj['connections'].append({ 'sources': conn.sources, 'address': conn.address, 'isLocal': conn.isLocal, 'isSecure': conn.isSecure, 'token': conn.token }) obj['servers'].append(serverObj) if self.selectedServer and not self.selectedServer.synced and not self.selectedServer.isSecondary(): plexapp.INTERFACE.setPreference("lastServerId", self.selectedServer.uuid) plexapp.INTERFACE.setRegistry("PlexServerManager", json.dumps(obj)) def clearState(self): plexapp.INTERFACE.setRegistry("PlexServerManager", '') def isValidForTranscoding(self, server): return server and server.activeConnection and server.owned and not server.synced and not server.isSecondary() def getChannelServer(self): if not self.channelServer or not self.channelServer.isReachable(): self.channelServer = None # Attempt to find a server that supports channels and transcoding for s in self.getServers(): if s.supportsVideoTranscoding and s.allowChannelAccess and s.isReachable() and self.compareServers(self.channelServer, s) < 0: self.channelServer = s # Fallback to any server that supports channels if not self.channelServer: for s in self.getServers(): if s.allowChannelAccess and s.isReachable() and self.compareServers(self.channelServer, s) < 0: self.channelServer = s if self.channelServer: util.LOG("Setting channel server to {0}".format(self.channelServer)) return self.channelServer def getTranscodeServer(self, transcodeType=None): if not self.selectedServer: return None transcodeMap = { 'audio': "supportsAudioTranscoding", 'video': "supportsVideoTranscoding", 'photo': "supportsPhotoTranscoding" } transcodeSupport = transcodeMap[transcodeType] # Try to use a better transcoding server for synced or secondary servers if self.selectedServer.synced or self.selectedServer.isSecondary(): if self.transcodeServer and self.transcodeServer.isReachable(): return self.transcodeServer self.transcodeServer = None for server in self.getServers(): if not server.synced and server.isReachable() and self.compareServers(self.transcodeServer, server) < 0: if not transcodeSupport or server.transcodeSupport: self.transcodeServer = server if self.transcodeServer: transcodeTypeString = transcodeType or '' util.LOG("Found a better {0} transcode server than {1}, using: {2}".format(transcodeTypeString, self.selectedserver, self.transcodeServer)) return self.transcodeServer return self.selectedServer def startSelectedServerSearch(self, reset=False): if reset: self.selectedServer = None self.transcodeServer = None self.channelServer = None # Keep track of some information during our search self.searchContext = SearchContext({ 'bestServer': None, 'preferredServer': plexapp.INTERFACE.getPreference('lastServerId', ''), 'waitingForResources': plexapp.ACCOUNT.isSignedIn }) util.LOG("Starting selected server search, hoping for {0}".format(self.searchContext.preferredServer)) def onAccountChange(self, account, reallyChanged=False): # Clear any AudioPlayer data before invalidating the active server if reallyChanged: # AudioPlayer().Cleanup() # PhotoPlayer().Cleanup() # Clear selected and transcode servers on user change self.selectedServer = None self.transcodeServer = None self.channelServer = None self.cancelReachability() if account.isSignedIn: # If the user didn't really change, such as selecting the previous user # on the lock screen, then we don't need to clear anything. We can # avoid a costly round of reachability checks. if not reallyChanged: return # A request to refresh resources has already been kicked off. We need # to clear out any connections for the previous user and then start # our selected server search. self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MYPLEX) self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_DISCOVERED) self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MANUAL) self.startSelectedServerSearch(True) else: # Clear servers/connections from plex.tv self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MYPLEX) def deferUpdateReachability(self, addTimer=True, logInfo=True): if addTimer and not self.deferReachabilityTimer: self.deferReachabilityTimer = plexapp.createTimer(1000, callback.Callable(self.onDeferUpdateReachabilityTimer), repeat=True) plexapp.APP.addTimer(self.deferReachabilityTimer) else: if self.deferReachabilityTimer: self.deferReachabilityTimer.reset() if self.deferReachabilityTimer and logInfo: util.LOG('Defer update reachability for all devices a few seconds: GDMactive={0}'.format(gdm.DISCOVERY.isActive())) def onDeferUpdateReachabilityTimer(self): if not self.selectedServer and self.searchContext: for server in self.getServers(): if server.pendingReachabilityRequests > 0 and server.uuid == self.searchContext.preferredServer: util.DEBUG_LOG( 'Still waiting on {0} responses from preferred server: {1}'.format( server.pendingReachabilityRequests, self.searchContext.preferredServer ) ) return self.deferReachabilityTimer.cancel() self.deferReachabilityTimer = None self.updateReachability(True, False, False) def resetLastTest(self): for uuid in self.serversByUuid: self.serversByUuid[uuid].resetLastTest() def clearServers(self): self.cancelReachability() self.serversByUuid = {} self.saveState() def onSecurityChange(self, value=None): # If the security policy changes, then we will need to allow all # connections to be retested by resetting the last test. We can # simply call `self.resetLastTest()` to allow all connection to be # tested when the server dropdown is enable, but we may as well # test all the connections immediately. plexapp.refreshResources(True) def onManualConnectionChange(self, value=None): # Clear all manual connections on change. We will keep the selected # server around temporarily if it's a manual connection regardless # if it's been removed. # Remember the current server in case it's removed server = self.getSelectedServer() activeConn = [] if server and server.activeConnection: activeConn.append(server.activeConnection) # Clear all manual connections self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MANUAL) # Reused the previous selected server if our manual connection has gone away if not self.getSelectedServer() and activeConn.sources == plexresource.ResourceConnection.SOURCE_MANUAL: server.activeConnection = activeConn server.connections.append(activeConn) self.setSelectedServer(server, True) def refreshManualConnections(self): manualConnections = self.getManualConnections() if not manualConnections: return util.LOG("Refreshing {0} manual connections".format(len(manualConnections))) for conn in manualConnections: # Default to http, as the server will need to be signed in for https to work, # so the client should too. We'd also have to allow hostname entry, instead of # IP address for the cert to validate. proto = "http" port = conn.port or "32400" serverAddress = "{0}://{1}:{2}".format(proto, conn.connection, port) request = http.HttpRequest(serverAddress + "/identity") context = request.createRequestContext("manual_connections", callback.Callable(self.onManualConnectionsResponse)) context.serverAddress = serverAddress context.address = conn.connection context.proto = proto context.port = port plexapp.APP.startRequest(request, context) def onManualConnectionsResponse(self, request, response, context): if not response.isSuccess(): return data = response.getBodyXml() if data is not None: serverAddress = context.serverAddress util.DEBUG_LOG("Received manual connection response for {0}".format(serverAddress)) machineID = data.attrib.get('machineIdentifier') name = context.address if not name or not machineID: return # TODO(rob): Do we NOT want to consider manual connections local? conn = plexconnection.PlexConnection(plexresource.ResourceConnection.SOURCE_MANUAL, serverAddress, True, None) server = plexserver.createPlexServerForConnection(conn) server.uuid = machineID server.name = name server.sourceType = plexresource.ResourceConnection.SOURCE_MANUAL self.updateFromConnectionType([server], plexresource.ResourceConnection.SOURCE_MANUAL) def getManualConnections(self): manualConnections = [] jstring = plexapp.INTERFACE.getPreference('manual_connections') if jstring: connections = json.loads(jstring) if isinstance(connections, list): for conn in connections: conn = util.AttributeDict(conn) if conn.connection: manualConnections.append(conn) return manualConnections # TODO(schuyler): Notifications # TODO(schuyler): Transcode (and primary) server selection MANAGER = PlexServerManager()