# -*- coding: utf-8 -*- ############################################################################### from logging import getLogger from hashlib import md5 import json import requests import socket import time from datetime import datetime import credentials as cred ############################################################################### # Disable annoying requests warnings import requests.packages.urllib3 requests.packages.urllib3.disable_warnings() log = getLogger("PLEX."+__name__) ############################################################################### CONNECTIONSTATE = { 'Unavailable': 0, 'ServerSelection': 1, 'ServerSignIn': 2, 'SignedIn': 3, 'ConnectSignIn': 4, 'ServerUpdateNeeded': 5 } CONNECTIONMODE = { 'Local': 0, 'Remote': 1, 'Manual': 2 } ############################################################################### def getServerAddress(server, mode): modes = { CONNECTIONMODE['Local']: server.get('LocalAddress'), CONNECTIONMODE['Remote']: server.get('RemoteAddress'), CONNECTIONMODE['Manual']: server.get('ManualAddress') } return (modes.get(mode) or server.get('ManualAddress', server.get('LocalAddress', server.get('RemoteAddress')))) class ConnectionManager(object): default_timeout = 20 apiClients = [] minServerVersion = "3.0.5930" connectUser = None def __init__(self, appName, appVersion, deviceName, deviceId, capabilities=None, devicePixelRatio=None): log.info("Begin ConnectionManager constructor") self.credentialProvider = cred.Credentials() self.appName = appName self.appVersion = appVersion self.deviceName = deviceName self.deviceId = deviceId self.capabilities = capabilities self.devicePixelRatio = devicePixelRatio def setFilePath(self, path): # Set where to save persistant data self.credentialProvider.setPath(path) def _getAppVersion(self): return self.appVersion def _getCapabilities(self): return self.capabilities def _getDeviceId(self): return self.deviceId def _connectUserId(self): return self.credentialProvider.getCredentials().get('ConnectUserId') def _connectToken(self): return self.credentialProvider.getCredentials().get('ConnectAccessToken') def getServerInfo(self, id_): servers = self.credentialProvider.getCredentials()['Servers'] for s in servers: if s['Id'] == id_: return s def _getLastUsedServer(self): servers = self.credentialProvider.getCredentials()['Servers'] if not len(servers): return try: servers.sort(key=lambda x: datetime.strptime(x['DateLastAccessed'], "%Y-%m-%dT%H:%M:%SZ"), reverse=True) except TypeError: servers.sort(key=lambda x: datetime(*(time.strptime(x['DateLastAccessed'], "%Y-%m-%dT%H:%M:%SZ")[0:6])), reverse=True) return servers[0] def _mergeServers(self, list1, list2): for i in range(0, len(list2), 1): try: self.credentialProvider.addOrUpdateServer(list1, list2[i]) except KeyError: continue return list1 def _connectUser(self): return self.connectUser def _resolveFailure(self): return { 'State': CONNECTIONSTATE['Unavailable'], 'ConnectUser': self._connectUser() } def _getMinServerVersion(self, val=None): if val is not None: self.minServerVersion = val return self.minServerVersion def _updateServerInfo(self, server, systemInfo): if server is None or systemInfo is None: return server['Name'] = systemInfo['ServerName'] server['Id'] = systemInfo['Id'] if systemInfo.get('LocalAddress'): server['LocalAddress'] = systemInfo['LocalAddress'] if systemInfo.get('WanAddress'): server['RemoteAddress'] = systemInfo['WanAddress'] if systemInfo.get('MacAddress'): server['WakeOnLanInfos'] = [{'MacAddress': systemInfo['MacAddress']}] def _getHeaders(self, request): headers = request.setdefault('headers', {}) if request.get('dataType') == "json": headers['Accept'] = "application/json" request.pop('dataType') headers['X-Application'] = self._addAppInfoToConnectRequest() headers['Content-type'] = request.get('contentType', 'application/x-www-form-urlencoded; charset=UTF-8') def requestUrl(self, request): if not request: raise AttributeError("Request cannot be null") self._getHeaders(request) request['timeout'] = request.get('timeout') or self.default_timeout request['verify'] = request.get('ssl') or False action = request['type'] request.pop('type', None) request.pop('ssl', None) log.debug("ConnectionManager requesting %s" % request) try: r = self._requests(action, **request) log.info("ConnectionManager response status: %s" % r.status_code) r.raise_for_status() except Exception as e: # Elaborate on exceptions? log.error(e) raise else: try: return r.json() except ValueError: r.content # Read response to release connection return def _requests(self, action, **kwargs): if action == "GET": r = requests.get(**kwargs) elif action == "POST": r = requests.post(**kwargs) return r def getEmbyServerUrl(self, baseUrl, handler): return "%s/emby/%s" % (baseUrl, handler) def getConnectUrl(self, handler): return "https://connect.emby.media/service/%s" % handler def _findServers(self, foundServers): servers = [] for foundServer in foundServers: server = self._convertEndpointAddressToManualAddress(foundServer) info = { 'Id': foundServer['Id'], 'LocalAddress': server or foundServer['Address'], 'Name': foundServer['Name'] } info['LastCONNECTIONMODE'] = CONNECTIONMODE['Manual'] if info.get('ManualAddress') else CONNECTIONMODE['Local'] servers.append(info) else: return servers def _convertEndpointAddressToManualAddress(self, info): if info.get('Address') and info.get('EndpointAddress'): address = info['EndpointAddress'].split(':')[0] # Determine the port, if any parts = info['Address'].split(':') if len(parts) > 1: portString = parts[len(parts)-1] try: address += ":%s" % int(portString) return self._normalizeAddress(address) except ValueError: pass return None def _serverDiscovery(self): MULTI_GROUP = ("", 7359) MESSAGE = "who is EmbyServer?" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(1.0) # This controls the socket.timeout exception sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 20) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_LOOP, 1) sock.setsockopt(socket.IPPROTO_IP, socket.SO_REUSEADDR, 1) log.debug("MultiGroup : %s" % str(MULTI_GROUP)) log.debug("Sending UDP Data: %s" % MESSAGE) servers = [] try: sock.sendto(MESSAGE, MULTI_GROUP) except Exception as error: log.error(error) return servers while True: try: data, addr = sock.recvfrom(1024) # buffer size servers.append(json.loads(data)) except socket.timeout: log.info("Found Servers: %s" % servers) return servers except Exception as e: log.error("Error trying to find servers: %s" % e) return servers def _normalizeAddress(self, address): # Attempt to correct bad input address = address.strip() address = address.lower() if 'http' not in address: address = "http://%s" % address return address def connectToAddress(self, address, options={}): if not address: return False address = self._normalizeAddress(address) def _onFail(): log.error("connectToAddress %s failed" % address) return self._resolveFailure() try: publicInfo = self._tryConnect(address, options=options) except Exception: return _onFail() else: log.info("connectToAddress %s succeeded" % address) server = { 'ManualAddress': address, 'LastCONNECTIONMODE': CONNECTIONMODE['Manual'] } self._updateServerInfo(server, publicInfo) server = self.connectToServer(server, options) if server is False: return _onFail() else: return server def onAuthenticated(self, result, options={}): credentials = self.credentialProvider.getCredentials() for s in credentials['Servers']: if s['Id'] == result['ServerId']: server = s break else: # Server not found? return if options.get('updateDateLastAccessed') is not False: server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') server['UserId'] = result['User']['Id'] server['AccessToken'] = result['AccessToken'] self.credentialProvider.addOrUpdateServer(credentials['Servers'], server) self._saveUserInfoIntoCredentials(server, result['User']) self.credentialProvider.getCredentials(credentials) def _tryConnect(self, url, timeout=None, options={}): url = self.getEmbyServerUrl(url, "system/info/public") log.info("tryConnect url: %s" % url) return self.requestUrl({ 'type': "GET", 'url': url, 'dataType': "json", 'timeout': timeout, 'ssl': options.get('ssl') }) def _addAppInfoToConnectRequest(self): return "%s/%s" % (self.appName, self.appVersion) def _getConnectServers(self, credentials): log.info("Begin getConnectServers") servers = [] if not credentials.get('ConnectAccessToken') or not credentials.get('ConnectUserId'): return servers url = self.getConnectUrl("servers?userId=%s" % credentials['ConnectUserId']) request = { 'type': "GET", 'url': url, 'dataType': "json", 'headers': { 'X-Connect-UserToken': credentials['ConnectAccessToken'] } } for server in self.requestUrl(request): servers.append({ 'ExchangeToken': server['AccessKey'], 'ConnectServerId': server['Id'], 'Id': server['SystemId'], 'Name': server['Name'], 'RemoteAddress': server['Url'], 'LocalAddress': server['LocalAddress'], 'UserLinkType': "Guest" if server['UserType'].lower() == "guest" else "LinkedUser", }) return servers def getAvailableServers(self): log.info("Begin getAvailableServers") # Clone the array credentials = self.credentialProvider.getCredentials() connectServers = self._getConnectServers(credentials) foundServers = self._findServers(self._serverDiscovery()) servers = list(credentials['Servers']) self._mergeServers(servers, foundServers) self._mergeServers(servers, connectServers) servers = self._filterServers(servers, connectServers) try: servers.sort(key=lambda x: datetime.strptime(x['DateLastAccessed'], "%Y-%m-%dT%H:%M:%SZ"), reverse=True) except TypeError: servers.sort(key=lambda x: datetime(*(time.strptime(x['DateLastAccessed'], "%Y-%m-%dT%H:%M:%SZ")[0:6])), reverse=True) credentials['Servers'] = servers self.credentialProvider.getCredentials(credentials) return servers def _filterServers(self, servers, connectServers): filtered = [] for server in servers: # It's not a connect server, so assume it's still valid if server.get('ExchangeToken') is None: filtered.append(server) continue for connectServer in connectServers: if server['Id'] == connectServer['Id']: filtered.append(server) break else: return filtered def _getConnectPasswordHash(self, password): password = self._cleanConnectPassword(password) return md5(password).hexdigest() def _saveUserInfoIntoCredentials(self, server, user): info = { 'Id': user['Id'], 'IsSignedInOffline': True } self.credentialProvider.addOrUpdateUser(server, info) def _compareVersions(self, a, b): """ -1 a is smaller 1 a is larger 0 equal """ a = a.split('.') b = b.split('.') for i in range(0, max(len(a), len(b)), 1): try: aVal = a[i] except IndexError: aVal = 0 try: bVal = b[i] except IndexError: bVal = 0 if aVal < bVal: return -1 if aVal > bVal: return 1 return 0 def connectToServer(self, server, options={}): log.info("begin connectToServer") tests = [] if server.get('LastCONNECTIONMODE') is not None: #tests.append(server['LastCONNECTIONMODE']) pass if CONNECTIONMODE['Manual'] not in tests: tests.append(CONNECTIONMODE['Manual']) if CONNECTIONMODE['Local'] not in tests: tests.append(CONNECTIONMODE['Local']) if CONNECTIONMODE['Remote'] not in tests: tests.append(CONNECTIONMODE['Remote']) # TODO: begin to wake server log.info("beginning connection tests") return self._testNextCONNECTIONMODE(tests, 0, server, options) def _stringEqualsIgnoreCase(self, str1, str2): return (str1 or "").lower() == (str2 or "").lower() def _testNextCONNECTIONMODE(self, tests, index, server, options): if index >= len(tests): log.info("Tested all connection modes. Failing server connection.") return self._resolveFailure() mode = tests[index] address = getServerAddress(server, mode) enableRetry = False skipTest = False timeout = self.default_timeout if mode == CONNECTIONMODE['Local']: enableRetry = True timeout = 8 if self._stringEqualsIgnoreCase(address, server.get('ManualAddress')): log.info("skipping LocalAddress test because it is the same as ManualAddress") skipTest = True elif mode == CONNECTIONMODE['Manual']: if self._stringEqualsIgnoreCase(address, server.get('LocalAddress')): enableRetry = True timeout = 8 if skipTest or not address: log.info("skipping test at index: %s" % index) return self._testNextCONNECTIONMODE(tests, index+1, server, options) log.info("testing connection mode %s with server %s" % (mode, server.get('Name'))) try: result = self._tryConnect(address, timeout, options) except Exception: log.error("test failed for connection mode %s with server %s" % (mode, server.get('Name'))) if enableRetry: # TODO: wake on lan and retry return self._testNextCONNECTIONMODE(tests, index+1, server, options) else: return self._testNextCONNECTIONMODE(tests, index+1, server, options) else: if self._compareVersions(self._getMinServerVersion(), result['Version']) == 1: log.warn("minServerVersion requirement not met. Server version: %s" % result['Version']) return { 'State': CONNECTIONSTATE['ServerUpdateNeeded'], 'Servers': [server] } else: log.info("calling onSuccessfulConnection with connection mode %s with server %s" % (mode, server.get('Name'))) return self._onSuccessfulConnection(server, result, mode, options) def _onSuccessfulConnection(self, server, systemInfo, CONNECTIONMODE, options): credentials = self.credentialProvider.getCredentials() if credentials.get('ConnectAccessToken') and options.get('enableAutoLogin') is not False: if self._ensureConnectUser(credentials) is not False: if server.get('ExchangeToken'): self._addAuthenticationInfoFromConnect(server, CONNECTIONMODE, credentials, options) return self._afterConnectValidated(server, credentials, systemInfo, CONNECTIONMODE, True, options) def _afterConnectValidated(self, server, credentials, systemInfo, CONNECTIONMODE, verifyLocalAuthentication, options): if options.get('enableAutoLogin') is False: server['UserId'] = None server['AccessToken'] = None elif (verifyLocalAuthentication and server.get('AccessToken') and options.get('enableAutoLogin') is not False): if self._validateAuthentication(server, CONNECTIONMODE, options) is not False: return self._afterConnectValidated(server, credentials, systemInfo, CONNECTIONMODE, False, options) return self._updateServerInfo(server, systemInfo) server['LastCONNECTIONMODE'] = CONNECTIONMODE if options.get('updateDateLastAccessed') is not False: server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') self.credentialProvider.addOrUpdateServer(credentials['Servers'], server) self.credentialProvider.getCredentials(credentials) result = { 'Servers': [], 'ConnectUser': self._connectUser() } result['State'] = CONNECTIONSTATE['SignedIn'] if (server.get('AccessToken') and options.get('enableAutoLogin') is not False) else CONNECTIONSTATE['ServerSignIn'] result['Servers'].append(server) # Connected return result def _validateAuthentication(self, server, CONNECTIONMODE, options={}): url = getServerAddress(server, CONNECTIONMODE) request = { 'type': "GET", 'url': self.getEmbyServerUrl(url, "System/Info"), 'ssl': options.get('ssl'), 'dataType': "json", 'headers': { 'X-MediaBrowser-Token': server['AccessToken'] } } try: systemInfo = self.requestUrl(request) self._updateServerInfo(server, systemInfo) if server.get('UserId'): user = self.requestUrl({ 'type': "GET", 'url': self.getEmbyServerUrl(url, "users/%s" % server['UserId']), 'ssl': options.get('ssl'), 'dataType': "json", 'headers': { 'X-MediaBrowser-Token': server['AccessToken'] } }) except Exception: server['UserId'] = None server['AccessToken'] = None return False def loginToConnect(self, username, password): if not username: raise AttributeError("username cannot be empty") if not password: raise AttributeError("password cannot be empty") md5 = self._getConnectPasswordHash(password) request = { 'type': "POST", 'url': self.getConnectUrl("user/authenticate"), 'data': { 'nameOrEmail': username, 'password': md5 }, 'dataType': "json" } try: result = self.requestUrl(request) except Exception as e: # Failed to login log.error(e) return False else: credentials = self.credentialProvider.getCredentials() credentials['ConnectAccessToken'] = result['AccessToken'] credentials['ConnectUserId'] = result['User']['Id'] credentials['ConnectUser'] = result['User']['DisplayName'] self.credentialProvider.getCredentials(credentials) # Signed in self._onConnectUserSignIn(result['User']) return result def _onConnectUserSignIn(self, user): self.connectUser = user log.info("connectusersignedin %s" % user) def _getConnectUser(self, userId, accessToken): if not userId: raise AttributeError("null userId") if not accessToken: raise AttributeError("null accessToken") url = self.getConnectUrl('user?id=%s' % userId) return self.requestUrl({ 'type': "GET", 'url': url, 'dataType': "json", 'headers': { 'X-Connect-UserToken': accessToken } }) def _addAuthenticationInfoFromConnect(self, server, CONNECTIONMODE, credentials, options={}): if not server.get('ExchangeToken'): raise KeyError("server['ExchangeToken'] cannot be null") if not credentials.get('ConnectUserId'): raise KeyError("credentials['ConnectUserId'] cannot be null") url = getServerAddress(server, CONNECTIONMODE) url = self.getEmbyServerUrl(url, "Connect/Exchange?format=json") auth = ('MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"' % (self.appName, self.deviceName, self.deviceId, self.appVersion)) try: auth = self.requestUrl({ 'url': url, 'type': "GET", 'dataType': "json", 'ssl': options.get('ssl'), 'params': { 'ConnectUserId': credentials['ConnectUserId'] }, 'headers': { 'X-MediaBrowser-Token': server['ExchangeToken'], 'X-Emby-Authorization': auth } }) except Exception: server['UserId'] = None server['AccessToken'] = None return False else: server['UserId'] = auth['LocalUserId'] server['AccessToken'] = auth['AccessToken'] return auth def _ensureConnectUser(self, credentials): if self.connectUser and self.connectUser['Id'] == credentials['ConnectUserId']: return elif credentials.get('ConnectUserId') and credentials.get('ConnectAccessToken'): self.connectUser = None try: result = self._getConnectUser(credentials['ConnectUserId'], credentials['ConnectAccessToken']) self._onConnectUserSignIn(result) except Exception: return False def connect(self, options={}): log.info("Begin connect") servers = self.getAvailableServers() return self._connectToServers(servers, options) def _connectToServers(self, servers, options): log.info("Begin connectToServers, with %s servers" % len(servers)) if len(servers) == 1: result = self.connectToServer(servers[0], options) if result and result.get('State') == CONNECTIONSTATE['Unavailable']: result['State'] = CONNECTIONSTATE['ConnectSignIn'] if result['ConnectUser'] == None else CONNECTIONSTATE['ServerSelection'] log.info("resolving connectToServers with result['State']: %s" % result) return result firstServer = self._getLastUsedServer() # See if we have any saved credentials and can auto sign in if firstServer: result = self.connectToServer(firstServer, options) if result and result.get('State') == CONNECTIONSTATE['SignedIn']: return result # Return loaded credentials if exists credentials = self.credentialProvider.getCredentials() self._ensureConnectUser(credentials) return { 'Servers': servers, 'State': CONNECTIONSTATE['ConnectSignIn'] if (not len(servers) and not self._connectUser()) else CONNECTIONSTATE['ServerSelection'], 'ConnectUser': self._connectUser() } def _cleanConnectPassword(self, password): password = password or "" password = password.replace("&", '&') password = password.replace("/", '\') password = password.replace("!", '!') password = password.replace("$", '$') password = password.replace("\"", '"') password = password.replace("<", '<') password = password.replace(">", '>') password = password.replace("'", ''') return password def clearData(self): log.info("connection manager clearing data") self.connectUser = None credentials = self.credentialProvider.getCredentials() credentials['ConnectAccessToken'] = None credentials['ConnectUserId'] = None credentials['Servers'] = [] self.credentialProvider.getCredentials(credentials)