From 6a2ea9a4dd6cd06bccb237772fe1d768e11a9812 Mon Sep 17 00:00:00 2001 From: angelblue05 Date: Sun, 4 Sep 2016 05:18:31 -0500 Subject: [PATCH] Emby Connect (#58) * Update with latest connect module * Update string * Change error behavior * Add connectmanager Handle dialogs for emby connect in one place * Add user select dialog * Add manual server dialog * Add onAuthenticated * Filter virtual episodes * Update userclient with new methods --- default.py | 3 +- resources/language/English/strings.xml | 29 +- resources/lib/connect.py | 257 ------ resources/lib/connect/__init__.py | 1 + resources/lib/connect/connectionmanager.py | 802 ++++++++++++++++++ resources/lib/connect/credentials.py | 146 ++++ resources/lib/connectmanager.py | 175 ++++ resources/lib/dialog/__init__.py | 5 + resources/lib/dialog/loginconnect.py | 114 ++- resources/lib/dialog/loginmanual.py | 144 ++++ resources/lib/dialog/serverconnect.py | 144 ++++ resources/lib/dialog/servermanual.py | 145 ++++ resources/lib/dialog/usersconnect.py | 100 +++ resources/lib/downloadutils.py | 85 +- resources/lib/entrypoint.py | 22 +- resources/lib/initialsetup.py | 191 ++--- resources/lib/itemtypes.py | 4 + resources/lib/librarysync.py | 3 + resources/lib/read_embyserver.py | 19 +- resources/lib/userclient.py | 512 ++++------- resources/lib/utils.py | 2 + resources/lib/websocket_client.py | 2 +- resources/settings.xml | 33 +- .../script-emby-connect-login-manual.xml | 145 ++++ .../1080i/script-emby-connect-login.xml | 84 +- .../script-emby-connect-server-manual.xml | 154 ++++ .../1080i/script-emby-connect-server.xml | 280 ++++++ .../1080i/script-emby-connect-users.xml | 198 +++++ .../{separator.png => emby-separator.png} | Bin .../skins/default/media/fading_circle.png | Bin 0 -> 1906 bytes resources/skins/default/media/network.png | Bin 0 -> 727 bytes resources/skins/default/media/user_image.png | Bin 0 -> 662 bytes .../skins/default/media/userflyoutdefault.png | Bin 0 -> 1338 bytes .../default/media/userflyoutdefault2.png | Bin 0 -> 1700 bytes resources/skins/default/media/wifi.png | Bin 0 -> 1095 bytes service.py | 22 +- 36 files changed, 2959 insertions(+), 862 deletions(-) delete mode 100644 resources/lib/connect.py create mode 100644 resources/lib/connect/__init__.py create mode 100644 resources/lib/connect/connectionmanager.py create mode 100644 resources/lib/connect/credentials.py create mode 100644 resources/lib/connectmanager.py create mode 100644 resources/lib/dialog/loginmanual.py create mode 100644 resources/lib/dialog/serverconnect.py create mode 100644 resources/lib/dialog/servermanual.py create mode 100644 resources/lib/dialog/usersconnect.py create mode 100644 resources/skins/default/1080i/script-emby-connect-login-manual.xml create mode 100644 resources/skins/default/1080i/script-emby-connect-server-manual.xml create mode 100644 resources/skins/default/1080i/script-emby-connect-server.xml create mode 100644 resources/skins/default/1080i/script-emby-connect-users.xml rename resources/skins/default/media/{separator.png => emby-separator.png} (100%) create mode 100644 resources/skins/default/media/fading_circle.png create mode 100644 resources/skins/default/media/network.png create mode 100644 resources/skins/default/media/user_image.png create mode 100644 resources/skins/default/media/userflyoutdefault.png create mode 100644 resources/skins/default/media/userflyoutdefault2.png create mode 100644 resources/skins/default/media/wifi.png diff --git a/default.py b/default.py index 9d9c098b..e11c867c 100644 --- a/default.py +++ b/default.py @@ -72,7 +72,8 @@ class Main(): 'recentepisodes': entrypoint.getRecentEpisodes, 'refreshplaylist': entrypoint.refreshPlaylist, 'deviceid': entrypoint.resetDeviceId, - 'delete': entrypoint.deleteItem + 'delete': entrypoint.deleteItem, + 'connect': entrypoint.emby_connect } if "/extrafanart" in sys.argv[0]: diff --git a/resources/language/English/strings.xml b/resources/language/English/strings.xml index 1f626dd9..affdc2da 100644 --- a/resources/language/English/strings.xml +++ b/resources/language/English/strings.xml @@ -3,13 +3,13 @@ Emby for Kodi - Primary Server Address - Play from HTTP instead of SMB - Log level - Device Name + Primary Server Address + Play from HTTP instead of SMB + Log level + Device Name Advanced - Username - Port Number + Username + Port Number Number of recent Music Albums to show: Number of recent Movies to show: @@ -232,12 +232,23 @@ Sign in with Emby Connect - Username or email: - Password: + Password Please see our terms of use. The use of any Emby software constitutes acceptance of these terms. Scan me Sign in - Remind me later + Cancel + Select main server + Username or password cannot be empty + Unable to connect to the selected server + Connect to + Manually add server + Please sign in + Username cannot be empty + Connect to server + Host + Connect + Server or port cannot be empty + Change Emby Connect user Welcome diff --git a/resources/lib/connect.py b/resources/lib/connect.py deleted file mode 100644 index aa3082a7..00000000 --- a/resources/lib/connect.py +++ /dev/null @@ -1,257 +0,0 @@ -# -*- coding: utf-8 -*- - -################################################################################################## - -import json -import requests -import logging - -import clientinfo -from utils import window - -################################################################################################## - -# Disable requests logging -from requests.packages.urllib3.exceptions import InsecureRequestWarning, InsecurePlatformWarning -requests.packages.urllib3.disable_warnings(InsecureRequestWarning) -requests.packages.urllib3.disable_warnings(InsecurePlatformWarning) - -log = logging.getLogger("EMBY."+__name__) - -################################################################################################## - - -class ConnectUtils(): - - # Borg - multiple instances, shared state - _shared_state = {} - clientInfo = clientinfo.ClientInfo() - - # Requests session - c = None - timeout = 30 - - - def __init__(self): - - self.__dict__ = self._shared_state - - - def setUserId(self, userId): - # Reserved for userclient only - self.userId = userId - log.debug("Set connect userId: %s" % userId) - - def setServer(self, server): - # Reserved for userclient only - self.server = server - log.debug("Set connect server: %s" % server) - - def setToken(self, token): - # Reserved for userclient only - self.token = token - log.debug("Set connect token: %s" % token) - - - def startSession(self): - - self.deviceId = self.clientInfo.getDeviceId() - - # User is identified from this point - # Attach authenticated header to the session - verify = False - header = self.getHeader() - - # If user enabled host certificate verification - try: - verify = self.sslverify - if self.sslclient is not None: - verify = self.sslclient - except: - log.info("Could not load SSL settings.") - - # Start session - self.c = requests.Session() - self.c.headers = header - self.c.verify = verify - # Retry connections to the server - self.c.mount("http://", requests.adapters.HTTPAdapter(max_retries=1)) - self.c.mount("https://", requests.adapters.HTTPAdapter(max_retries=1)) - - log.info("Requests session started on: %s" % self.server) - - def stopSession(self): - try: - self.c.close() - except Exception: - log.warn("Requests session could not be terminated") - - def getHeader(self, authenticate=True): - - version = self.clientInfo.getVersion() - - if not authenticate: - # If user is not authenticated - header = { - - 'X-Application': "Kodi/%s" % version, - 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'Accept': "application/json" - } - log.info("Header: %s" % header) - - else: - token = self.token - # Attached to the requests session - header = { - - 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'Accept': "application/json", - 'X-Application': "Kodi/%s" % version, - 'X-Connect-UserToken': token - } - log.info("Header: %s" % header) - - return header - - def doUrl(self, url, data=None, postBody=None, rtype="GET", - parameters=None, authenticate=True, timeout=None): - - log.debug("=== ENTER connectUrl ===") - - default_link = "" - - if timeout is None: - timeout = self.timeout - - # Get requests session - try: - # If connect user is authenticated - if authenticate: - try: - c = self.c - # Replace for the real values - url = url.replace("{server}", self.server) - url = url.replace("{UserId}", self.userId) - - # Prepare request - if rtype == "GET": - r = c.get(url, json=postBody, params=parameters, timeout=timeout) - elif rtype == "POST": - r = c.post(url, data=data, timeout=timeout) - elif rtype == "DELETE": - r = c.delete(url, json=postBody, timeout=timeout) - - except AttributeError: - # request session does not exists - self.server = "https://connect.emby.media/service" - self.userId = window('embyco_currUser') - self.token = window('embyco_accessToken%s' % self.userId) - - header = self.getHeader() - verifyssl = False - - # If user enables ssl verification - try: - verifyssl = self.sslverify - if self.sslclient is not None: - verifyssl = self.sslclient - except AttributeError: - pass - - # Prepare request - if rtype == "GET": - r = requests.get(url, - json=postBody, - params=parameters, - headers=header, - timeout=timeout, - verify=verifyssl) - - elif rtype == "POST": - r = requests.post(url, - data=data, - headers=header, - timeout=timeout, - verify=verifyssl) - # If user is not authenticated - else: - header = self.getHeader(authenticate=False) - verifyssl = False - - # If user enables ssl verification - try: - verifyssl = self.sslverify - if self.sslclient is not None: - verifyssl = self.sslclient - except AttributeError: - pass - - # Prepare request - if rtype == "GET": - r = requests.get(url, - json=postBody, - params=parameters, - headers=header, - timeout=timeout, - verify=verifyssl) - - elif rtype == "POST": - r = requests.post(url, - data=data, - headers=header, - timeout=timeout, - verify=verifyssl) - - ##### THE RESPONSE ##### - log.info(r.url) - log.info(r) - - if r.status_code == 204: - # No body in the response - log.info("====== 204 Success ======") - - elif r.status_code == requests.codes.ok: - - try: - # UNICODE - JSON object - r = r.json() - log.info("====== 200 Success ======") - log.info("Response: %s" % r) - return r - - except: - if r.headers.get('content-type') != "text/html": - log.info("Unable to convert the response for: %s" % url) - else: - r.raise_for_status() - - ##### EXCEPTIONS ##### - - except requests.exceptions.ConnectionError as e: - # Make the addon aware of status - pass - - except requests.exceptions.ConnectTimeout as e: - log.warn("Server timeout at: %s" % url) - - except requests.exceptions.HTTPError as e: - - if r.status_code == 401: - # Unauthorized - pass - - elif r.status_code in (301, 302): - # Redirects - pass - elif r.status_code == 400: - # Bad requests - pass - - except requests.exceptions.SSLError as e: - log.warn("Invalid SSL certificate for: %s" % url) - - except requests.exceptions.RequestException as e: - log.warn("Unknown error connecting to: %s" % url) - - return default_link \ No newline at end of file diff --git a/resources/lib/connect/__init__.py b/resources/lib/connect/__init__.py new file mode 100644 index 00000000..b93054b3 --- /dev/null +++ b/resources/lib/connect/__init__.py @@ -0,0 +1 @@ +# Dummy file to make this directory a package. diff --git a/resources/lib/connect/connectionmanager.py b/resources/lib/connect/connectionmanager.py new file mode 100644 index 00000000..11dc2514 --- /dev/null +++ b/resources/lib/connect/connectionmanager.py @@ -0,0 +1,802 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import hashlib +import json +import logging +import requests +import socket +import time +from datetime import datetime + +import credentials as cred + +################################################################################################# + +# Disable requests logging +from requests.packages.urllib3.exceptions import InsecureRequestWarning, InsecurePlatformWarning, SNIMissingWarning +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) +requests.packages.urllib3.disable_warnings(InsecurePlatformWarning) +requests.packages.urllib3.disable_warnings(SNIMissingWarning) + +log = logging.getLogger("EMBY."+__name__.split('.')[-1]) + +################################################################################################# + +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): + + 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'] = False + + action = request['type'] + request.pop('type') + + 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) + sock.sendto(MESSAGE, MULTI_GROUP) + + 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) + 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 _tryConnect(self, url, timeout=None): + + url = self.getEmbyServerUrl(url, "system/info/public") + log.info("tryConnect url: %s" % url) + + return self.requestUrl({ + + 'type': "GET", + 'url': url, + 'dataType': "json", + 'timeout': timeout + }) + + 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 hashlib.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['Name'])) + try: + result = self._tryConnect(address, timeout) + + except Exception: + log.error("test failed for connection mode %s with server %s" % (mode, server['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['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) + + 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) 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): + + url = getServerAddress(server, connectionMode) + request = { + + 'type': "GET", + 'url': self.getEmbyServerUrl(url, "System/Info"), + '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']), + 'dataType': "json", + 'headers': { + 'X-MediaBrowser-Token': server['AccessToken'] + } + }) + # TODO: add apiclient + + 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'] + self.credentialProvider.getCredentials(credentials) + # Signed in + self._onConnectUserSignIn(result['User']) + + return result + + 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 _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): + + 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") + try: + auth = self.requestUrl({ + + 'url': url, + 'type': "GET", + 'dataType': "json", + 'params': { + 'ConnectUserId': credentials['ConnectUserId'] + }, + 'headers': { + 'X-MediaBrowser-Token': server['ExchangeToken'] + } + }) + 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.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) \ No newline at end of file diff --git a/resources/lib/connect/credentials.py b/resources/lib/connect/credentials.py new file mode 100644 index 00000000..9cc779b6 --- /dev/null +++ b/resources/lib/connect/credentials.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import json +import logging +import os +import time +from datetime import datetime + +################################################################################################# + +log = logging.getLogger("EMBY."+__name__.split('.')[-1]) + +################################################################################################# + + +class Credentials(object): + + credentials = None + path = "" + + + def __init__(self): + pass + + def setPath(self, path): + # Path to save persistant data.txt + self.path = path + + def _ensure(self): + + if self.credentials is None: + try: + with open(os.path.join(self.path, 'data.txt')) as infile: + self.credentials = json.load(infile) + + except Exception as e: # File is either empty or missing + log.warn(e) + self.credentials = {} + + log.info("credentials initialized with: %s" % self.credentials) + self.credentials['Servers'] = self.credentials.setdefault('Servers', []) + + def _get(self): + + self._ensure() + return self.credentials + + def _set(self, data): + + if data: + self.credentials = data + # Set credentials to file + with open(os.path.join(self.path, 'data.txt'), 'w') as outfile: + json.dump(data, outfile, indent=4, ensure_ascii=False) + else: + self._clear() + + log.info("credentialsupdated") + + def _clear(self): + + self.credentials = None + # Remove credentials from file + with open(os.path.join(self.path, 'data.txt'), 'w'): pass + + def getCredentials(self, data=None): + + if data is not None: + self._set(data) + + return self._get() + + def addOrUpdateServer(self, list_, server): + + if server.get('Id') is None: + raise KeyError("Server['Id'] cannot be null or empty") + + # Add default DateLastAccessed if doesn't exist. + server.setdefault('DateLastAccessed', "2001-01-01T00:00:00Z") + + for existing in list_: + if existing['Id'] == server['Id']: + + # Merge the data + if server.get('DateLastAccessed'): + if self._dateObject(server['DateLastAccessed']) > self._dateObject(existing['DateLastAccessed']): + existing['DateLastAccessed'] = server['DateLastAccessed'] + + if server.get('UserLinkType'): + existing['UserLinkType'] = server['UserLinkType'] + + if server.get('AccessToken'): + existing['AccessToken'] = server['AccessToken'] + existing['UserId'] = server['UserId'] + + if server.get('ExchangeToken'): + existing['ExchangeToken'] = server['ExchangeToken'] + + if server.get('RemoteAddress'): + existing['RemoteAddress'] = server['RemoteAddress'] + + if server.get('ManualAddress'): + existing['ManualAddress'] = server['ManualAddress'] + + if server.get('LocalAddress'): + existing['LocalAddress'] = server['LocalAddress'] + + if server.get('Name'): + existing['Name'] = server['Name'] + + if server.get('WakeOnLanInfos'): + existing['WakeOnLanInfos'] = server['WakeOnLanInfos'] + + if server.get('LastConnectionMode') is not None: + existing['LastConnectionMode'] = server['LastConnectionMode'] + + if server.get('ConnectServerId'): + existing['ConnectServerId'] = server['ConnectServerId'] + + return existing + else: + list_.append(server) + return server + + def addOrUpdateUser(self, server, user): + + for existing in server.setdefault('Users', []): + if existing['Id'] == user['Id']: + # Merge the data + existing['IsSignedInOffline'] = True + break + else: + server['Users'].append(user) + + def _dateObject(self, date): + # Convert string to date + try: + date_obj = datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") + except (ImportError, TypeError): + # TypeError: attribute of type 'NoneType' is not callable + # Known Kodi/python error + date_obj = datetime(*(time.strptime(date, "%Y-%m-%dT%H:%M:%SZ")[0:6])) + + return date_obj \ No newline at end of file diff --git a/resources/lib/connectmanager.py b/resources/lib/connectmanager.py new file mode 100644 index 00000000..22afc2eb --- /dev/null +++ b/resources/lib/connectmanager.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- + +################################################################################################## + +import logging + +import xbmc +import xbmcaddon + +import clientinfo +import read_embyserver as embyserver +import connect.connectionmanager as connectionmanager +from dialog import ServerConnect, UsersConnect, LoginConnect, LoginManual, ServerManual + +################################################################################################## + +log = logging.getLogger("EMBY."+__name__) +addon = xbmcaddon.Addon(id='plugin.video.emby') + +XML_PATH = (addon.getAddonInfo('path'), "default", "1080i") + +################################################################################################## + +class ConnectManager(object): + + _shared_state = {} # Borg + state = {} + + + def __init__(self): + + self.__dict__ = self._shared_state + + if not self.state: + client_info = clientinfo.ClientInfo() + self.emby = embyserver.Read_EmbyServer() + + version = client_info.getVersion() + device_name = client_info.getDeviceName() + device_id = client_info.getDeviceId() + + self._connect = connectionmanager.ConnectionManager(appName="Kodi", + appVersion=version, + deviceName=device_name, + deviceId=device_id) + self._connect.setFilePath(xbmc.translatePath( + addon.getAddonInfo('profile')).decode('utf-8')) + self.state = self._connect.connect() + log.info("Started with: %s", self.state) + + + def update_state(self): + + self.state = self._connect.connect({'updateDateLastAccessed': False}) + return self.state + + def get_state(self): + return self.state + + def get_server(self, server): + return self._connect.connectToAddress(server) + + @classmethod + def get_address(cls, server): + return connectionmanager.getServerAddress(server, server['LastConnectionMode']) + + def clear_data(self): + self._connect.clearData() + + def select_servers(self): + # Will return selected server or raise error + state = self._connect.connect({'enableAutoLogin': False}) + user = state.get('ConnectUser') or {} + + dialog = ServerConnect("script-emby-connect-server.xml", *XML_PATH) + kwargs = { + 'connect_manager': self._connect, + 'username': user.get('DisplayName', ""), + 'user_image': user.get('ImageUrl'), + 'servers': state['Servers'], + 'emby_connect': False if user else True + } + dialog.set_args(**kwargs) + dialog.doModal() + + if dialog.is_server_selected(): + log.debug("Server selected") + return dialog.get_server() + + elif dialog.is_connect_login(): + log.debug("Login with Emby Connect") + try: # Login to emby connect + self.login_connect() + except RuntimeError: + pass + return self.select_servers() + + elif dialog.is_manual_server(): + log.debug("Add manual server") + try: # Add manual server address + return self.manual_server() + except RuntimeError: + return self.select_servers() + else: + raise RuntimeError("No server selected") + + def manual_server(self): + # Return server or raise error + dialog = ServerManual("script-emby-connect-server-manual.xml", *XML_PATH) + dialog.set_connect_manager(self._connect) + dialog.doModal() + + if dialog.is_connected(): + return dialog.get_server() + else: + raise RuntimeError("Server is not connected") + + def login_connect(self): + # Return connect user or raise error + dialog = LoginConnect("script-emby-connect-login.xml", *XML_PATH) + dialog.set_connect_manager(self._connect) + dialog.doModal() + + self.update_state() + + if dialog.is_logged_in(): + return dialog.get_user() + else: + raise RuntimeError("Connect user is not logged in") + + def login(self, server=None): + # Return user or raise error + server = server or self.state['Servers'][0] + server_address = connectionmanager.getServerAddress(server, server['LastConnectionMode']) + users = self.emby.getUsers(server_address) + + dialog = UsersConnect("script-emby-connect-users.xml", *XML_PATH) + dialog.set_server(server_address) + dialog.set_users(users) + dialog.doModal() + + if dialog.is_user_selected(): + user = dialog.get_user() + if user['HasPassword']: + log.debug("User has password, present manual login") + try: + return self.login_manual(server_address, user) + except RuntimeError: + return self.login(server) + else: + user = self.emby.loginUser(server_address, user['Name']) + self._connect.onAuthenticated(user) + return user + + elif dialog.is_manual_login(): + try: + return self.login_manual(server_address) + except RuntimeError: + return self.login(server) + else: + raise RuntimeError("No user selected") + + def login_manual(self, server, user=None): + # Return manual login user authenticated or raise error + dialog = LoginManual("script-emby-connect-login-manual.xml", *XML_PATH) + dialog.set_server(server) + dialog.set_user(user) + dialog.doModal() + + if dialog.is_logged_in(): + user = dialog.get_user() + self._connect.onAuthenticated(user) + return user + else: + raise RuntimeError("User is not authenticated") diff --git a/resources/lib/dialog/__init__.py b/resources/lib/dialog/__init__.py index b93054b3..b6c69bf6 100644 --- a/resources/lib/dialog/__init__.py +++ b/resources/lib/dialog/__init__.py @@ -1 +1,6 @@ # Dummy file to make this directory a package. +from serverconnect import ServerConnect +from usersconnect import UsersConnect +from loginconnect import LoginConnect +from loginmanual import LoginManual +from servermanual import ServerManual diff --git a/resources/lib/dialog/loginconnect.py b/resources/lib/dialog/loginconnect.py index 62651e4f..db7c39cc 100644 --- a/resources/lib/dialog/loginconnect.py +++ b/resources/lib/dialog/loginconnect.py @@ -2,52 +2,63 @@ ################################################################################################## +import logging import os import xbmcgui import xbmcaddon +from utils import language as lang + ################################################################################################## +log = logging.getLogger("EMBY."+__name__) addon = xbmcaddon.Addon('plugin.video.emby') +ACTION_PARENT_DIR = 9 +ACTION_PREVIOUS_MENU = 10 ACTION_BACK = 92 SIGN_IN = 200 -REMIND_LATER = 201 +CANCEL = 201 +ERROR_TOGGLE = 202 +ERROR_MSG = 203 +ERROR = { + 'Invalid': 1, + 'Empty': 2 +} + +################################################################################################## class LoginConnect(xbmcgui.WindowXMLDialog): + _user = None + error = None + def __init__(self, *args, **kwargs): xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) - def __add_editcontrol(self, x, y, height, width, password=0): - - media = os.path.join(addon.getAddonInfo('path'), 'resources', 'skins', 'default', 'media') - control = xbmcgui.ControlEdit(0,0,0,0, - label="User", - font="font10", - textColor="ff464646", - focusTexture=os.path.join(media, "button-focus.png"), - noFocusTexture=os.path.join(media, "button-focus.png"), - isPassword=password) + def set_connect_manager(self, connect_manager): + self.connect_manager = connect_manager - control.setPosition(x,y) - control.setHeight(height) - control.setWidth(width) + def is_logged_in(self): + return True if self._user else False + + def get_user(self): + return self._user - self.addControl(control) - return control def onInit(self): - - self.user_field = self.__add_editcontrol(685,385,40,500) + + self.user_field = self._add_editcontrol(725, 385, 40, 500) self.setFocus(self.user_field) - self.password_field = self.__add_editcontrol(685,470,40,500, password=1) + self.password_field = self._add_editcontrol(725, 470, 40, 500, password=1) self.signin_button = self.getControl(SIGN_IN) - self.remind_button = self.getControl(REMIND_LATER) + self.remind_button = self.getControl(CANCEL) + self.error_toggle = self.getControl(ERROR_TOGGLE) + self.error_msg = self.getControl(ERROR_MSG) self.user_field.controlUp(self.remind_button) self.user_field.controlDown(self.password_field) @@ -60,17 +71,66 @@ class LoginConnect(xbmcgui.WindowXMLDialog): if control == SIGN_IN: # Sign in to emby connect - self.user = self.user_field.getText() - __password = self.password_field.getText() + self._disable_error() - ### REVIEW ONCE CONNECT MODULE IS MADE - self.close() + user = self.user_field.getText() + password = self.password_field.getText() - elif control == REMIND_LATER: + if not user or not password: + # Display error + self._error(ERROR['Empty'], lang(30608)) + log.error("Username or password cannot be null") + + elif self._login(user, password): + self.close() + + elif control == CANCEL: # Remind me later self.close() def onAction(self, action): - if action == ACTION_BACK: - self.close() \ No newline at end of file + if (self.error == ERROR['Empty'] + and self.user_field.getText() and self.password_field.getText()): + self._disable_error() + + if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU): + self.close() + + def _add_editcontrol(self, x, y, height, width, password=0): + + media = os.path.join(addon.getAddonInfo('path'), 'resources', 'skins', 'default', 'media') + control = xbmcgui.ControlEdit(0, 0, 0, 0, + label="User", + font="font10", + textColor="ff525252", + focusTexture=os.path.join(media, "button-focus.png"), + noFocusTexture=os.path.join(media, "button-focus.png"), + isPassword=password) + control.setPosition(x, y) + control.setHeight(height) + control.setWidth(width) + + self.addControl(control) + return control + + def _login(self, username, password): + + result = self.connect_manager.loginToConnect(username, password) + if result is False: + self._error(ERROR['Invalid'], lang(33009)) + return False + else: + self._user = result + return True + + def _error(self, state, message): + + self.error = state + self.error_msg.setLabel(message) + self.error_toggle.setVisibleCondition('True') + + def _disable_error(self): + + self.error = None + self.error_toggle.setVisibleCondition('False') diff --git a/resources/lib/dialog/loginmanual.py b/resources/lib/dialog/loginmanual.py new file mode 100644 index 00000000..aa34986b --- /dev/null +++ b/resources/lib/dialog/loginmanual.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- + +################################################################################################## + +import logging +import os + +import xbmcgui +import xbmcaddon + +import read_embyserver as embyserver +from utils import language as lang + +################################################################################################## + +log = logging.getLogger("EMBY."+__name__) +addon = xbmcaddon.Addon('plugin.video.emby') + +ACTION_PARENT_DIR = 9 +ACTION_PREVIOUS_MENU = 10 +ACTION_BACK = 92 +SIGN_IN = 200 +CANCEL = 201 +ERROR_TOGGLE = 202 +ERROR_MSG = 203 +ERROR = { + 'Invalid': 1, + 'Empty': 2 +} + +################################################################################################## + + +class LoginManual(xbmcgui.WindowXMLDialog): + + _user = None + error = None + + + def __init__(self, *args, **kwargs): + + self.emby = embyserver.Read_EmbyServer() + xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) + + def is_logged_in(self): + return True if self._user else False + + def set_server(self, server): + self.server = server + + def set_user(self, user): + self.user = user or {} + + def get_user(self): + return self._user + + def onInit(self): + + self.signin_button = self.getControl(SIGN_IN) + self.cancel_button = self.getControl(CANCEL) + self.error_toggle = self.getControl(ERROR_TOGGLE) + self.error_msg = self.getControl(ERROR_MSG) + self.user_field = self._add_editcontrol(725, 400, 40, 500) + self.password_field = self._add_editcontrol(725, 475, 40, 500, password=1) + + if "Name" in self.user: + self.user_field.setText(self.user['Name']) + self.setFocus(self.password_field) + else: + self.setFocus(self.user_field) + + self.user_field.controlUp(self.cancel_button) + self.user_field.controlDown(self.password_field) + self.password_field.controlUp(self.user_field) + self.password_field.controlDown(self.signin_button) + self.signin_button.controlUp(self.password_field) + self.cancel_button.controlDown(self.user_field) + + def onClick(self, control): + + if control == SIGN_IN: + # Sign in to emby connect + self._disable_error() + + user = self.user_field.getText() + password = self.password_field.getText() + + if not user: + # Display error + self._error(ERROR['Empty'], lang(30613)) + log.error("Username cannot be null") + + elif self._login(user, password): + self.close() + + elif control == CANCEL: + # Remind me later + self.close() + + def onAction(self, action): + + if self.error == ERROR['Empty'] and self.user_field.getText(): + self._disable_error() + + if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU): + self.close() + + def _add_editcontrol(self, x, y, height, width, password=0): + + media = os.path.join(addon.getAddonInfo('path'), 'resources', 'skins', 'default', 'media') + control = xbmcgui.ControlEdit(0, 0, 0, 0, + label="User", + font="font10", + textColor="ff525252", + focusTexture=os.path.join(media, "button-focus.png"), + noFocusTexture=os.path.join(media, "button-focus.png"), + isPassword=password) + control.setPosition(x, y) + control.setHeight(height) + control.setWidth(width) + + self.addControl(control) + return control + + def _login(self, username, password): + + result = self.emby.loginUser(self.server, username, password) + if not result: + self._error(ERROR['Invalid'], lang(33009)) + return False + else: + self._user = result + return True + + def _error(self, state, message): + + self.error = state + self.error_msg.setLabel(message) + self.error_toggle.setVisibleCondition('True') + + def _disable_error(self): + + self.error = None + self.error_toggle.setVisibleCondition('False') diff --git a/resources/lib/dialog/serverconnect.py b/resources/lib/dialog/serverconnect.py new file mode 100644 index 00000000..8a39b09b --- /dev/null +++ b/resources/lib/dialog/serverconnect.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- + +################################################################################################## + +import logging + +import xbmc +import xbmcgui + +import connect.connectionmanager as connectionmanager +from utils import language as lang + +################################################################################################## + +log = logging.getLogger("EMBY."+__name__) + +CONN_STATE = connectionmanager.ConnectionState +ACTION_PARENT_DIR = 9 +ACTION_PREVIOUS_MENU = 10 +ACTION_BACK = 92 +ACTION_SELECT_ITEM = 7 +ACTION_MOUSE_LEFT_CLICK = 100 +USER_IMAGE = 150 +USER_NAME = 151 +LIST = 155 +CANCEL = 201 +MESSAGE_BOX = 202 +MESSAGE = 203 +BUSY = 204 +EMBY_CONNECT = 205 +MANUAL_SERVER = 206 + +################################################################################################## + + +class ServerConnect(xbmcgui.WindowXMLDialog): + + username = "" + user_image = None + servers = [] + + _selected_server = None + _connect_login = False + _manual_server = False + + + def __init__(self, *args, **kwargs): + + xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) + + def set_args(self, **kwargs): + # connect_manager, username, user_image, servers, emby_connect + for key, value in kwargs.iteritems(): + setattr(self, key, value) + + def is_server_selected(self): + return True if self._selected_server else False + + def get_server(self): + return self._selected_server + + def is_connect_login(self): + return self._connect_login + + def is_manual_server(self): + return self._manual_server + + + def onInit(self): + + self.message = self.getControl(MESSAGE) + self.message_box = self.getControl(MESSAGE_BOX) + self.busy = self.getControl(BUSY) + self.list_ = self.getControl(LIST) + + for server in self.servers: + server_type = "wifi" if server.get('ExchangeToken') else "network" + self.list_.addItem(self._add_listitem(server['Name'], server['Id'], server_type)) + + self.getControl(USER_NAME).setLabel("%s %s" % (lang(33000), self.username.decode('utf-8'))) + + if self.user_image is not None: + self.getControl(USER_IMAGE).setImage(self.user_image) + + if not self.emby_connect: # Change connect user + self.getControl(EMBY_CONNECT).setLabel("[UPPERCASE][B]"+lang(30618)+"[/B][/UPPERCASE]") + + self.setFocus(self.list_) + + @classmethod + def _add_listitem(cls, label, server_id, server_type): + + item = xbmcgui.ListItem(label) + item.setProperty('id', server_id) + item.setProperty('server_type', server_type) + + return item + + def onAction(self, action): + + if action in (ACTION_BACK, ACTION_PREVIOUS_MENU, ACTION_PARENT_DIR): + self.close() + + if action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK): + + if self.getFocusId() == LIST: + server = self.list_.getSelectedItem() + selected_id = server.getProperty('id') + log.info('Server Id selected: %s', selected_id) + + if self._connect_server(selected_id): + self.message_box.setVisibleCondition('False') + self.close() + + def onClick(self, control): + + if control == EMBY_CONNECT: + self.connect_manager.clearData() + self._connect_login = True + self.close() + + elif control == MANUAL_SERVER: + self._manual_server = True + self.close() + + elif control == CANCEL: + self.close() + + def _connect_server(self, server_id): + + server = self.connect_manager.getServerInfo(server_id) + self.message.setLabel("%s %s..." % (lang(30610), server['Name'])) + self.message_box.setVisibleCondition('True') + self.busy.setVisibleCondition('True') + result = self.connect_manager.connectToServer(server) + + if result['State'] == CONN_STATE['Unavailable']: + self.busy.setVisibleCondition('False') + self.message.setLabel(lang(30609)) + return False + else: + xbmc.sleep(1000) + self._selected_server = result['Servers'][0] + return True diff --git a/resources/lib/dialog/servermanual.py b/resources/lib/dialog/servermanual.py new file mode 100644 index 00000000..d54199eb --- /dev/null +++ b/resources/lib/dialog/servermanual.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- + +################################################################################################## + +import logging +import os + +import xbmcgui +import xbmcaddon + +import connect.connectionmanager as connectionmanager +from utils import language as lang + +################################################################################################## + +log = logging.getLogger("EMBY."+__name__) +addon = xbmcaddon.Addon('plugin.video.emby') + +CONN_STATE = connectionmanager.ConnectionState +ACTION_PARENT_DIR = 9 +ACTION_PREVIOUS_MENU = 10 +ACTION_BACK = 92 +CONNECT = 200 +CANCEL = 201 +ERROR_TOGGLE = 202 +ERROR_MSG = 203 +ERROR = { + 'Invalid': 1, + 'Empty': 2 +} + +################################################################################################## + + +class ServerManual(xbmcgui.WindowXMLDialog): + + _server = None + error = None + + + def __init__(self, *args, **kwargs): + + xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) + + def set_connect_manager(self, connect_manager): + self.connect_manager = connect_manager + + def is_connected(self): + return True if self._server else False + + def get_server(self): + return self._server + + def onInit(self): + + self.connect_button = self.getControl(CONNECT) + self.cancel_button = self.getControl(CANCEL) + self.error_toggle = self.getControl(ERROR_TOGGLE) + self.error_msg = self.getControl(ERROR_MSG) + self.host_field = self._add_editcontrol(725, 400, 40, 500) + self.port_field = self._add_editcontrol(725, 525, 40, 500) + + self.port_field.setText('8096') + self.setFocus(self.host_field) + + self.host_field.controlUp(self.cancel_button) + self.host_field.controlDown(self.port_field) + self.port_field.controlUp(self.host_field) + self.port_field.controlDown(self.connect_button) + self.connect_button.controlUp(self.port_field) + self.cancel_button.controlDown(self.host_field) + + def onClick(self, control): + + if control == CONNECT: + # Sign in to emby connect + self._disable_error() + + server = self.host_field.getText() + port = self.port_field.getText() + + if not server or not port: + # Display error + self._error(ERROR['Empty'], lang(30617)) + log.error("Server or port cannot be null") + + elif self._connect_to_server(server, port): + self.close() + + elif control == CANCEL: + # Remind me later + self.close() + + def onAction(self, action): + + if self.error == ERROR['Empty'] and self.host_field.getText() and self.port_field.getText(): + self._disable_error() + + if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU): + self.close() + + def _add_editcontrol(self, x, y, height, width): + + media = os.path.join(addon.getAddonInfo('path'), 'resources', 'skins', 'default', 'media') + control = xbmcgui.ControlEdit(0, 0, 0, 0, + label="User", + font="font10", + textColor="ffc2c2c2", + focusTexture=os.path.join(media, "button-focus.png"), + noFocusTexture=os.path.join(media, "button-focus.png")) + control.setPosition(x, y) + control.setHeight(height) + control.setWidth(width) + + self.addControl(control) + return control + + def _connect_to_server(self, server, port): + + server_address = "%s:%s" % (server, port) + self._message("%s %s..." % (lang(30610), server_address)) + result = self.connect_manager.connectToAddress(server_address) + + if result['State'] == CONN_STATE['Unavailable']: + self._message(lang(30609)) + return False + else: + self._server = result['Servers'][0] + return True + + def _message(self, message): + + self.error_msg.setLabel(message) + self.error_toggle.setVisibleCondition('True') + + def _error(self, state, message): + + self.error = state + self.error_msg.setLabel(message) + self.error_toggle.setVisibleCondition('True') + + def _disable_error(self): + + self.error = None + self.error_toggle.setVisibleCondition('False') diff --git a/resources/lib/dialog/usersconnect.py b/resources/lib/dialog/usersconnect.py new file mode 100644 index 00000000..314d2e23 --- /dev/null +++ b/resources/lib/dialog/usersconnect.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- + +################################################################################################## + +import logging + +import xbmcgui + +################################################################################################## + +log = logging.getLogger("EMBY."+__name__) + +ACTION_PARENT_DIR = 9 +ACTION_PREVIOUS_MENU = 10 +ACTION_BACK = 92 +ACTION_SELECT_ITEM = 7 +ACTION_MOUSE_LEFT_CLICK = 100 +LIST = 155 +MANUAL = 200 +CANCEL = 201 + +################################################################################################## + + +class UsersConnect(xbmcgui.WindowXMLDialog): + + _user = None + _manual_login = False + + + def __init__(self, *args, **kwargs): + + xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) + + def set_server(self, server): + self.server = server + + def set_users(self, users): + self.users = users + + def is_user_selected(self): + return True if self._user else False + + def get_user(self): + return self._user + + def is_manual_login(self): + return self._manual_login + + + def onInit(self): + + self.list_ = self.getControl(LIST) + for user in self.users: + user_image = ("userflyoutdefault2.png" if not user.get('PrimaryImageTag') + else self._get_user_artwork(user['Id'], 'Primary')) + self.list_.addItem(self._add_listitem(user['Name'], user['Id'], user_image)) + + self.setFocus(self.list_) + + @classmethod + def _add_listitem(cls, label, user_id, user_image): + + item = xbmcgui.ListItem(label) + item.setProperty('id', user_id) + item.setArt({'Icon': user_image}) + + return item + + def onAction(self, action): + + if action in (ACTION_BACK, ACTION_PREVIOUS_MENU, ACTION_PARENT_DIR): + self.close() + + if action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK): + + if self.getFocusId() == LIST: + user = self.list_.getSelectedItem() + selected_id = user.getProperty('id') + log.info('User Id selected: %s', selected_id) + + for user in self.users: + if user['Id'] == selected_id: + self._user = user + break + + self.close() + + def onClick(self, control): + + if control == MANUAL: + self._manual_login = True + self.close() + + elif control == CANCEL: + self.close() + + def _get_user_artwork(self, user_id, art_type): + # Load user information set by UserClient + return "%s/emby/Users/%s/Images/%s?Format=original" % (self.server, user_id, art_type) diff --git a/resources/lib/downloadutils.py b/resources/lib/downloadutils.py index a74b634f..bdb9ad48 100644 --- a/resources/lib/downloadutils.py +++ b/resources/lib/downloadutils.py @@ -10,7 +10,7 @@ import xbmc import xbmcgui import clientinfo -from utils import window, settings +from utils import window, settings, language as lang ################################################################################################## @@ -40,11 +40,6 @@ class DownloadUtils(): self.__dict__ = self._shared_state - def setUsername(self, username): - # Reserved for userclient only - self.username = username - log.debug("Set username: %s" % username) - def setUserId(self, userId): # Reserved for userclient only self.userId = userId @@ -60,12 +55,10 @@ class DownloadUtils(): self.token = token log.debug("Set token: %s" % token) - def setSSL(self, ssl, sslclient): + def setSSL(self, ssl): # Reserved for userclient only self.sslverify = ssl - self.sslclient = sslclient - log.debug("Verify SSL host certificate: %s" % ssl) - log.debug("SSL client side certificate: %s" % sslclient) + log.debug("Verify SSL verify/certificate: %s" % ssl) def postCapabilities(self, deviceId): @@ -149,8 +142,6 @@ class DownloadUtils(): # If user enabled host certificate verification try: verify = self.sslverify - if self.sslclient is not None: - verify = self.sslclient except: log.info("Could not load SSL settings.") @@ -214,47 +205,31 @@ class DownloadUtils(): default_link = "" try: - if authenticate: + if self.s is not None: + session = self.s + else: + # request session does not exists + # Get user information + self.userId = window('emby_currUser') + self.server = window('emby_server%s' % self.userId) + self.token = window('emby_accessToken%s' % self.userId) + verifyssl = False - if self.s is not None: - session = self.s - else: - # request session does not exists - # Get user information - self.userId = window('emby_currUser') - self.server = window('emby_server%s' % self.userId) - self.token = window('emby_accessToken%s' % self.userId) - verifyssl = False - - # IF user enables ssl verification - if settings('sslverify') == "true": - verifyssl = True - if settings('sslcert') != "None": - verifyssl = settings('sslcert') - - kwargs.update({ - 'verify': verifyssl, - 'headers': self.getHeader() - }) - - # Replace for the real values - url = url.replace("{server}", self.server) - url = url.replace("{UserId}", self.userId) - - else: # User is not authenticated - # If user enables ssl verification - try: - verifyssl = self.sslverify - if self.sslclient is not None: - verifyssl = self.sslclient - except AttributeError: - verifyssl = False + # IF user enables ssl verification + if settings('sslverify') == "true": + verifyssl = True + if settings('sslcert') != "None": + verifyssl = settings('sslcert') kwargs.update({ 'verify': verifyssl, - 'headers': self.getHeader(authenticate=False) + 'headers': self.getHeader(authenticate) }) + # Replace for the real values + url = url.replace("{server}", self.server) + url = url.replace("{UserId}", self.userId) + ##### PREPARE REQUEST ##### kwargs.update({ 'url': url, @@ -310,13 +285,15 @@ class DownloadUtils(): # Emby server errors if r.headers['X-Application-Error-Code'] == "ParentalControl": # Parental control - access restricted + if status != "restricted": + xbmcgui.Dialog().notification( + heading=lang(29999), + message="Access restricted.", + icon=xbmcgui.NOTIFICATION_ERROR, + time=5000) + window('emby_serverStatus', value="restricted") - xbmcgui.Dialog().notification( - heading="Emby server", - message="Access restricted.", - icon=xbmcgui.NOTIFICATION_ERROR, - time=5000) - return False + raise Warning('restricted') elif r.headers['X-Application-Error-Code'] == "UnauthorizedAccessException": # User tried to do something his emby account doesn't allow @@ -330,7 +307,7 @@ class DownloadUtils(): heading="Error connecting", message="Unauthorized.", icon=xbmcgui.NOTIFICATION_ERROR) - return 401 + raise Warning('401') elif r.status_code in (301, 302): # Redirects diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index e532e5e2..6074280c 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -17,6 +17,7 @@ import xbmcplugin import artwork import utils import clientinfo +import connectmanager import downloadutils import librarysync import read_embyserver as embyserver @@ -102,7 +103,6 @@ def doMainListing(): addDirectoryItem(lang(33052), "plugin://plugin.video.emby/?mode=browsecontent&type=recordings&folderid=root") - # some extra entries for settings and stuff. TODO --> localize the labels addDirectoryItem(lang(30517), "plugin://plugin.video.emby/?mode=passwords") addDirectoryItem(lang(33053), "plugin://plugin.video.emby/?mode=settings") addDirectoryItem(lang(33054), "plugin://plugin.video.emby/?mode=adduser") @@ -115,6 +115,26 @@ def doMainListing(): xbmcplugin.endOfDirectory(int(sys.argv[1])) +def emby_connect(): + # Login user to emby connect + connect = connectmanager.ConnectManager() + try: + connectUser = connect.login_connect() + except RuntimeError: + return + else: + user = connectUser['User'] + token = connectUser['AccessToken'] + username = user['Name'] + icon = user.get('ImageUrl') or "special://home/addons/plugin.video.emby/icon.png" + xbmcgui.Dialog().notification(heading=lang(29999), + message="%s %s" % (lang(33000), username.decode('utf-8')), + icon=icon, + time=1500, + sound=False) + + settings('connectUsername', value=username) + ##### Generate a new deviceId def resetDeviceId(): diff --git a/resources/lib/initialsetup.py b/resources/lib/initialsetup.py index 032affb1..f59dc47b 100644 --- a/resources/lib/initialsetup.py +++ b/resources/lib/initialsetup.py @@ -2,174 +2,125 @@ ################################################################################################# -import json import logging -import socket import xbmc import xbmcgui -import xbmcaddon import clientinfo -import downloadutils +import connectmanager +import connect.connectionmanager as connectionmanager import userclient from utils import settings, language as lang, passwordsXML ################################################################################################# log = logging.getLogger("EMBY."+__name__) +STATE = connectionmanager.ConnectionState ################################################################################################# -class InitialSetup(): +class InitialSetup(object): def __init__(self): - self.addonId = clientinfo.ClientInfo().getAddonId() - self.doUtils = downloadutils.DownloadUtils().downloadUrl - self.userClient = userclient.UserClient() + self.addon_id = clientinfo.ClientInfo().getAddonId() + self.user_client = userclient.UserClient() + self.connectmanager = connectmanager.ConnectManager() def setup(self): # Check server, user, direct paths, music, direct stream if not direct path. - addonId = self.addonId + addon_id = self.addon_id dialog = xbmcgui.Dialog() ##### SERVER INFO ##### - + log.debug("Initial setup called.") - server = self.userClient.getServer() - if server: - log.debug("Server is already set.") + ###$ Begin transition phase $### + if settings('server') == "": + current_server = self.user_client.get_server() + self.connectmanager.get_server(current_server) + self.user_client.get_userid() + self.user_client.get_token() + ###$ End transition phase $### + + current_state = self.connectmanager.get_state() + if current_state['State'] == STATE['SignedIn']: + server = current_state['Servers'][0] + server_address = self.connectmanager.get_address(server) + self._set_server(server_address, server['Name']) + self._set_user(server['UserId'], server['AccessToken']) return - - log.debug("Looking for server...") - server = self.getServerDetails() - log.debug("Found: %s" % server) + try: - prefix, ip, port = server.replace("/", "").split(":") - except Exception: # Failed to retrieve server information - log.error("getServerDetails failed.") - xbmc.executebuiltin('Addon.OpenSettings(%s)' % addonId) - return - else: - server_confirm = dialog.yesno( - heading=lang(29999), - line1=lang(33034), - line2="%s %s" % (lang(30169), server)) - if server_confirm: - # Correct server found - log.info("Server is selected. Saving the information.") - settings('ipaddress', value=ip) - settings('port', value=port) + server = self.connectmanager.select_servers() + log.info("Server: %s", server) - if prefix == "https": - settings('https', value="true") + except RuntimeError as error: + log.exception(error) + xbmc.executebuiltin('Addon.OpenSettings(%s)' % addon_id) + return + + else: + server_address = self.connectmanager.get_address(server) + self._set_server(server_address, server['Name']) + + if not server.get('AccessToken') and not server.get('UserId'): + try: + user = self.connectmanager.login(server) + log.info("User authenticated: %s", user) + except RuntimeError as error: + log.exception(error) + xbmc.executebuiltin('Addon.OpenSettings(%s)' % addon_id) + return + settings('username', value=user['User']['Name']) + self._set_user(user['User']['Id'], user['AccessToken']) else: - # User selected no or cancelled the dialog - log.info("No server selected.") - xbmc.executebuiltin('Addon.OpenSettings(%s)' % addonId) - return - - ##### USER INFO ##### - - log.info("Getting user list.") - - result = self.doUtils("%s/emby/Users/Public?format=json" % server, authenticate=False) - if result == "": - log.info("Unable to connect to %s" % server) - return - - log.debug("Response: %s" % result) - # Process the list of users - usernames = [] - users_hasPassword = [] - - for user in result: - # Username - name = user['Name'] - usernames.append(name) - # Password - if user['HasPassword']: - name = "%s (secure)" % name - users_hasPassword.append(name) - - log.info("Presenting user list: %s" % users_hasPassword) - user_select = dialog.select(lang(30200), users_hasPassword) - if user_select > -1: - selected_user = usernames[user_select] - log.info("Selected user: %s" % selected_user) - settings('username', value=selected_user) - else: - log.info("No user selected.") - xbmc.executebuiltin('Addon.OpenSettings(%s)' % addonId) - return + user = self.connectmanager.get_state() + settings('connectUsername', value=user['ConnectUser']['Name']) + self._set_user(server['UserId'], server['AccessToken']) ##### ADDITIONAL PROMPTS ##### - directPaths = dialog.yesno( - heading=lang(30511), - line1=lang(33035), - nolabel=lang(33036), - yeslabel=lang(33037)) - if directPaths: + direct_paths = dialog.yesno(heading=lang(30511), + line1=lang(33035), + nolabel=lang(33036), + yeslabel=lang(33037)) + if direct_paths: log.info("User opted to use direct paths.") settings('useDirectPaths', value="1") # ask for credentials - credentials = dialog.yesno( - heading=lang(30517), - line1= lang(33038)) + credentials = dialog.yesno(heading=lang(30517), line1=lang(33038)) if credentials: log.info("Presenting network credentials dialog.") passwordsXML() - - musicDisabled = dialog.yesno( - heading=lang(29999), - line1=lang(33039)) - if musicDisabled: + + music_disabled = dialog.yesno(heading=lang(29999), line1=lang(33039)) + if music_disabled: log.info("User opted to disable Emby music library.") settings('enableMusic', value="false") else: # Only prompt if the user didn't select direct paths for videos - if not directPaths: - musicAccess = dialog.yesno( - heading=lang(29999), - line1=lang(33040)) - if musicAccess: + if not direct_paths: + music_access = dialog.yesno(heading=lang(29999), line1=lang(33040)) + if music_access: log.info("User opted to direct stream music.") settings('streamMusic', value="true") - - def getServerDetails(self): - log.info("Getting Server Details from Network") - - MULTI_GROUP = ("", 7359) - MESSAGE = "who is EmbyServer?" - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(6.0) + @classmethod + def _set_server(cls, server, name): - 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) - sock.sendto(MESSAGE, MULTI_GROUP) - - try: - data, addr = sock.recvfrom(1024) # buffer size is 1024 bytes - log.info("Received Response: %s" % data) - except Exception: - log.error("No UDP Response") - return None - else: - # Get the address - data = json.loads(data) - return data['Address'] \ No newline at end of file + settings('serverName', value=name) + settings('server', value=server) + log.info("Saved server information: %s", server) + + @classmethod + def _set_user(cls, user_id, token): + + settings('userId', value=user_id) + settings('token', value=token) diff --git a/resources/lib/itemtypes.py b/resources/lib/itemtypes.py index 73dc4c59..18d7b9f3 100644 --- a/resources/lib/itemtypes.py +++ b/resources/lib/itemtypes.py @@ -1207,6 +1207,10 @@ class TVShows(Items): artwork = self.artwork API = api.API(item) + if item.get('LocationType') == "Virtual": # TODO: Filter via api instead + log.info("Skipping virtual episode: %s", item['Name']) + return + # If the item already exist in the local Kodi DB we'll perform a full item update # If the item doesn't exist, we'll add it to the database update_item = True diff --git a/resources/lib/librarysync.py b/resources/lib/librarysync.py index b90a671b..ad5c6b82 100644 --- a/resources/lib/librarysync.py +++ b/resources/lib/librarysync.py @@ -905,6 +905,9 @@ class LibrarySync(threading.Thread): try: self.run_internal() + except Warning as e: + if "restricted" in e: + pass except Exception as e: window('emby_dbScan', clear=True) log.exception(e) diff --git a/resources/lib/read_embyserver.py b/resources/lib/read_embyserver.py index 091e1730..448a9b59 100644 --- a/resources/lib/read_embyserver.py +++ b/resources/lib/read_embyserver.py @@ -3,6 +3,7 @@ ################################################################################################# import logging +import hashlib import xbmc @@ -571,4 +572,20 @@ class Read_EmbyServer(): def deleteItem(self, itemid): url = "{server}/emby/Items/%s?format=json" % itemid - self.doUtils(url, action_type="DELETE") \ No newline at end of file + self.doUtils(url, action_type="DELETE") + + def getUsers(self, server): + + url = "%s/emby/Users/Public?format=json" % server + users = self.doUtils(url, authenticate=False) + + return users or [] + + def loginUser(self, server, username, password=None): + + password = password or "" + url = "%s/emby/Users/AuthenticateByName?format=json" % server + data = {'username': username, 'password': hashlib.sha1(password).hexdigest()} + user = self.doUtils(url, postBody=data, action_type="POST", authenticate=False) + + return user \ No newline at end of file diff --git a/resources/lib/userclient.py b/resources/lib/userclient.py index 37282efb..b5f8534b 100644 --- a/resources/lib/userclient.py +++ b/resources/lib/userclient.py @@ -2,18 +2,16 @@ ################################################################################################## -import hashlib import logging import threading import xbmc import xbmcgui -import xbmcaddon -import xbmcvfs import artwork -import clientinfo +import connectmanager import downloadutils +import read_embyserver as embyserver from utils import window, settings, language as lang ################################################################################################## @@ -22,426 +20,276 @@ log = logging.getLogger("EMBY."+__name__) ################################################################################################## - class UserClient(threading.Thread): - # Borg - multiple instances, shared state - _shared_state = {} + _shared_state = {} # Borg - stop_thread = False - auth = True - retry = 0 + _stop_thread = False + _user = None + _server = None - currUser = None - currUserId = None - currServer = None - currToken = None - HasAccess = True - AdditionalUser = [] - - userSettings = None + _auth = True + _has_access = True def __init__(self): self.__dict__ = self._shared_state - self.addon = xbmcaddon.Addon() - self.doUtils = downloadutils.DownloadUtils() + self.doutils = downloadutils.DownloadUtils() + self.download = self.doutils.downloadUrl + self.emby = embyserver.Read_EmbyServer() threading.Thread.__init__(self) + @classmethod + def get_username(cls): + return settings('username') or settings('connectUsername') or None - def getAdditionalUsers(self): + def get_user(self, data=None): - additionalUsers = settings('additionalUsers') + if data is not None: + self._user = data + self._set_user_server() - if additionalUsers: - self.AdditionalUser = additionalUsers.split(',') + return self._user - def getUsername(self): + def get_server_details(self): + return self._server - username = settings('username') + @classmethod + def get_server(cls): - if not username: - log.debug("No username saved.") - return "" - - return username - - def getLogLevel(self): - - try: - logLevel = int(settings('logLevel')) - except ValueError: - logLevel = 0 - - return logLevel - - def getUserId(self): - - username = self.getUsername() - w_userId = window('emby_currUser') - s_userId = settings('userId%s' % username) - - # Verify the window property - if w_userId: - if not s_userId: - # Save access token if it's missing from settings - settings('userId%s' % username, value=w_userId) - log.debug("Returning userId from WINDOW for username: %s UserId: %s" - % (username, w_userId)) - return w_userId - # Verify the settings - elif s_userId: - log.debug("Returning userId from SETTINGS for username: %s userId: %s" - % (username, s_userId)) - return s_userId - # No userId found - else: - log.info("No userId saved for username: %s." % username) - - def getServer(self, prefix=True): - - alternate = settings('altip') == "true" - if alternate: - # Alternate host - HTTPS = settings('secondhttps') == "true" - host = settings('secondipaddress') - port = settings('secondport') - else: - # Original host - HTTPS = settings('https') == "true" + ###$ Begin transition phase $### + if settings('server') == "": + http = "https" if settings('https') == "true" else "http" host = settings('ipaddress') port = settings('port') - server = host + ":" + port + if host and port: + settings('server', value="%s://%s:%s" % (http, host, port)) + ###$ End transition phase $### - if not host: - log.debug("No server information saved.") - return False + return settings('server') or None - # If https is true - if prefix and HTTPS: - server = "https://%s" % server - return server - # If https is false - elif prefix and not HTTPS: - server = "http://%s" % server - return server - # If only the host:port is required - elif not prefix: - return server + def verify_server(self): - def getToken(self): - - username = self.getUsername() - userId = self.getUserId() - w_token = window('emby_accessToken%s' % userId) - s_token = settings('accessToken') - - # Verify the window property - if w_token: - if not s_token: - # Save access token if it's missing from settings - settings('accessToken', value=w_token) - log.debug("Returning accessToken from WINDOW for username: %s accessToken: %s" - % (username, w_token)) - return w_token - # Verify the settings - elif s_token: - log.debug("Returning accessToken from SETTINGS for username: %s accessToken: %s" - % (username, s_token)) - window('emby_accessToken%s' % username, value=s_token) - return s_token - else: - log.info("No token found.") - return "" - - def getSSLverify(self): - # Verify host certificate - s_sslverify = settings('sslverify') - if settings('altip') == "true": - s_sslverify = settings('secondsslverify') - - if s_sslverify == "true": + url = "%s/emby/Users/Public?format=json" % self.get_server() + result = self.download(url, authenticate=False) + if result != "": # Specific verification, due to possibility of returning empty dict return True - else: - return False - - def getSSL(self): - # Client side certificate - s_cert = settings('sslcert') - if settings('altip') == "true": - s_cert = settings('secondsslcert') - - if s_cert == "None": - return None - else: - return s_cert - - def setUserPref(self): - - doUtils = self.doUtils.downloadUrl - - result = doUtils("{server}/emby/Users/{UserId}?format=json") - self.userSettings = result - # Set user image for skin display - if result.get('PrimaryImageTag'): - window('EmbyUserImage', value=artwork.Artwork().getUserArtwork(result['Id'], 'Primary')) - - # Set resume point max - result = doUtils("{server}/emby/System/Configuration?format=json") - settings('markPlayed', value=str(result['MaxResumePct'])) - - def getPublicUsers(self): - # Get public Users - url = "%s/emby/Users/Public?format=json" % self.getServer() - result = self.doUtils.downloadUrl(url, authenticate=False) - if result != "": - return result else: # Server connection failed return False + @classmethod + def get_ssl(cls): + """ + Returns boolean value or path to certificate + True: Verify ssl + False: Don't verify connection + """ + certificate = settings('sslcert') + if certificate != "None": + return certificate - def hasAccess(self): - # hasAccess is verified in service.py - result = self.doUtils.downloadUrl("{server}/emby/Users?format=json") + return True if settings('sslverify') == "true" else False - if result == False: - # Access is restricted, set in downloadutils.py via exception - log.info("Access is restricted.") - self.HasAccess = False + def get_access(self): - elif window('emby_online') != "true": - # Server connection failed - pass + if not self._has_access: + self._set_access() - elif window('emby_serverStatus') == "restricted": - log.info("Access is granted.") - self.HasAccess = True - window('emby_serverStatus', clear=True) - xbmcgui.Dialog().notification(lang(29999), lang(33007)) + return self._has_access - def loadCurrUser(self, authenticated=False): + def _set_access(self): - doUtils = self.doUtils - username = self.getUsername() - userId = self.getUserId() + try: + self.download("{server}/emby/Users?format=json") + except Warning as error: + if self._has_access and "restricted" in error: + self._has_access = False + log.info("Access is restricted") + else: + if not self._has_access: + self._has_access = True + window('emby_serverStatus', clear=True) + log.info("Access is granted") + xbmcgui.Dialog().notification(lang(29999), lang(33007)) - # Only to be used if token exists - self.currUserId = userId - self.currServer = self.getServer() - self.currToken = self.getToken() - self.ssl = self.getSSLverify() - self.sslcert = self.getSSL() + @classmethod + def get_userid(cls): - # Test the validity of current token - if authenticated == False: - url = "%s/emby/Users/%s?format=json" % (self.currServer, userId) - window('emby_currUser', value=userId) - window('emby_accessToken%s' % userId, value=self.currToken) - result = doUtils.downloadUrl(url) + ###$ Begin transition phase $### + if settings('userId') == "": + settings('userId', value=settings('userId%s' % settings('username'))) + ###$ End transition phase $### - if result == 401: - # Token is no longer valid - self.resetClient() - return False + return settings('userId') or None - # Set to windows property - window('emby_currUser', value=userId) - window('emby_accessToken%s' % userId, value=self.currToken) - window('emby_server%s' % userId, value=self.currServer) - window('emby_server_%s' % userId, value=self.getServer(prefix=False)) + @classmethod + def get_token(cls): - # Set DownloadUtils values - doUtils.setUsername(username) - doUtils.setUserId(self.currUserId) - doUtils.setServer(self.currServer) - doUtils.setToken(self.currToken) - doUtils.setSSL(self.ssl, self.sslcert) - # parental control - let's verify if access is restricted - self.hasAccess() - # Start DownloadUtils session - doUtils.startSession() - self.getAdditionalUsers() - # Set user preferences in settings - self.currUser = username - self.setUserPref() + ###$ Begin transition phase $### + if settings('token') == "": + settings('token', value=settings('accessToken')) + ###$ End transition phase $### + return settings('token') or None - def authenticate(self): + def _set_user_server(self): - dialog = xbmcgui.Dialog() + self._server = self.download("{server}/emby/System/Configuration?format=json") + settings('markPlayed', value=str(self._server['MaxResumePct'])) - # Get /profile/addon_data - addondir = xbmc.translatePath(self.addon.getAddonInfo('profile')).decode('utf-8') - hasSettings = xbmcvfs.exists("%ssettings.xml" % addondir) + self._user = self.download("{server}/emby/Users/{UserId}?format=json") + if "PrimaryImageTag" in self._user: + window('EmbyUserImage', + value=artwork.Artwork().getUserArtwork(self._user['Id'], 'Primary')) - username = self.getUsername() - server = self.getServer() + def _authenticate(self): - # If there's no settings.xml - if not hasSettings: - log.info("No settings.xml found.") - self.auth = False - return - # If no user information - elif not server or not username: - log.info("Missing server information.") - self.auth = False - return - # If there's a token, load the user - elif self.getToken(): - result = self.loadCurrUser() + if not self.get_server() or not self.get_username(): + log.info('missing server or user information') + self._auth = False - if result == False: - pass + elif self.get_token(): + try: + self._load_user() + except Warning: + log.info("token is invalid") else: - log.info("Current user: %s" % self.currUser) - log.info("Current userId: %s" % self.currUserId) - log.debug("Current accessToken: %s" % self.currToken) + log.info("current user: %s", self.get_username()) + log.info("current userid: %s", self.get_userid()) + log.debug("current token: %s", self.get_token()) return ##### AUTHENTICATE USER ##### + server = self.get_server() + username = self.get_username().decode('utf-8') + users = self.emby.getUsers(server) + user_found = None - users = self.getPublicUsers() - password = "" - - # Find user in list for user in users: - name = user['Name'] - - if username.decode('utf-8') in name: - # If user has password - if user['HasPassword'] == True: - password = dialog.input( - heading="%s %s" % (lang(33008), username.decode('utf-8')), - option=xbmcgui.ALPHANUM_HIDE_INPUT) - # If password dialog is cancelled - if not password: - log.warn("No password entered.") - window('emby_serverStatus', value="Stop") - self.auth = False - return + if username == user['Name']: + user_found = user break - else: - # Manual login, user is hidden - password = dialog.input( - heading="%s %s" % (lang(33008), username.decode('utf-8')), - option=xbmcgui.ALPHANUM_HIDE_INPUT) - sha1 = hashlib.sha1(password) - sha1 = sha1.hexdigest() - - # Authenticate username and password - data = {'username': username, 'password': sha1} - log.debug(data) - - url = "%s/emby/Users/AuthenticateByName?format=json" % server - result = self.doUtils.downloadUrl(url, postBody=data, action_type="POST", authenticate=False) - try: - log.info("Auth response: %s" % result) - accessToken = result['AccessToken'] - - except (KeyError, TypeError): - log.info("Failed to retrieve the api key.") - accessToken = None - - if accessToken is not None: - self.currUser = username - dialog.notification(lang(29999), - "%s %s!" % (lang(33000), self.currUser.decode('utf-8'))) - settings('accessToken', value=accessToken) - settings('userId%s' % username, value=result['User']['Id']) - log.info("User Authenticated: %s" % accessToken) - self.loadCurrUser(authenticated=True) - window('emby_serverStatus', clear=True) - self.retry = 0 + user = self.connectmanager.login_manual(server, user_found) + except RuntimeError: + window('emby_serverStatus', value="stop") + self._auth = False + return else: - log.error("User authentication failed.") - settings('accessToken', value="") - settings('userId%s' % username, value="") - dialog.ok(lang(33001), lang(33009)) + log.info("user: %s", user) + settings('username', value=user['User']['Name']) + settings('token', value=user['AccessToken']) + settings('userId', value=user['User']['Id']) + xbmcgui.Dialog().notification(lang(29999), + "%s %s!" % (lang(33000), username)) + self._load_user(authenticated=True) + window('emby_serverStatus', clear=True) - # Give two attempts at entering password - if self.retry == 2: - log.info("Too many retries. " - "You can retry by resetting attempts in the addon settings.") - window('emby_serverStatus', value="Stop") - dialog.ok(lang(33001), lang(33010)) + def _load_user(self, authenticated=False): - self.retry += 1 - self.auth = False + doutils = self.doutils - def resetClient(self): + userid = self.get_userid() + server = self.get_server() + token = self.get_token() - log.info("Reset UserClient authentication.") - if self.currToken is not None: - # In case of 401, removed saved token - settings('accessToken', value="") - window('emby_accessToken%s' % self.getUserId(), clear=True) - self.currToken = None - log.info("User token has been removed.") + # Set properties + window('emby_currUser', value=userid) + window('emby_server%s' % userid, value=server) + window('emby_accessToken%s' % userid, value=token) - self.auth = True - self.currUser = None + # Test the validity of the current token + if not authenticated: + try: + self.download("{server}/emby/Users/{UserId}?format=json") + except Warning as error: + if "401" in error: + # Token is not longer valid + raise + + # Set downloadutils.py values + doutils.setUserId(userid) + doutils.setServer(server) + doutils.setToken(token) + doutils.setSSL(self.get_ssl()) + + # Start downloadutils.py session + doutils.startSession() + + # Set _user and _server + # verify user access + try: + self._set_access() + self._set_user_server() + except Warning: # We don't need to raise any exceptions + pass + + def _reset_client(self): + + log.info("reset UserClient authentication") + + settings('accessToken', value="") + window('emby_accessToken', clear=True) + + log.info("user token revoked.") + + self._user = None + self.auth = None def run(self): monitor = xbmc.Monitor() + self.connectmanager = connectmanager.ConnectManager() + log.warn("----===## Starting UserClient ##===----") - while not monitor.abortRequested(): + while not self._stop_thread: status = window('emby_serverStatus') if status: # Verify the connection status to server if status == "restricted": # Parental control is restricting access - self.HasAccess = False + self._has_access = False elif status == "401": # Unauthorized access, revoke token - window('emby_serverStatus', value="Auth") - self.resetClient() + window('emby_serverStatus', value="auth") + self._reset_client() - if self.auth and (self.currUser is None): + if self._auth and self._user is None: # Try to authenticate user status = window('emby_serverStatus') - if not status or status == "Auth": + if not status or status == "auth": # Set auth flag because we no longer need # to authenticate the user - self.auth = False - self.authenticate() + self._auth = False + self._authenticate() - - if not self.auth and (self.currUser is None): + if not self._auth and self._user is None: # If authenticate failed. - server = self.getServer() - username = self.getUsername() + server = self.get_server() + username = self.get_username() status = window('emby_serverStatus') # The status Stop is for when user cancelled password dialog. - if server and username and status != "Stop": + if server and username and status != "stop": # Only if there's information found to login - log.debug("Server found: %s" % server) - log.debug("Username found: %s" % username) - self.auth = True - - - if self.stop_thread == True: - # If stopping the client didn't work - break + log.info("Server found: %s", server) + log.info("Username found: %s", username) + self._auth = True if monitor.waitForAbort(1): # Abort was requested while waiting. We should exit break - self.doUtils.stopSession() + self.doutils.stopSession() log.warn("##===---- UserClient Stopped ----===##") - def stopClient(self): - # When emby for kodi terminates - self.stop_thread = True \ No newline at end of file + def stop_client(self): + self._stop_thread = True diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 9d38e710..b1dfac81 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -408,12 +408,14 @@ def reset(): # Remove emby info resp = dialog.yesno(language(29999), language(33087)) if resp: + import connectmanager # Delete the settings addon = xbmcaddon.Addon() addondir = xbmc.translatePath(addon.getAddonInfo('profile')).decode('utf-8') dataPath = "%ssettings.xml" % addondir xbmcvfs.delete(dataPath) log.info("Deleting: settings.xml") + connectmanager.ConnectManager().clear_data() dialog.ok(heading=language(29999), line1=language(33088)) xbmc.executebuiltin('RestartApp') diff --git a/resources/lib/websocket_client.py b/resources/lib/websocket_client.py index bc158338..c79e81d0 100644 --- a/resources/lib/websocket_client.py +++ b/resources/lib/websocket_client.py @@ -251,7 +251,7 @@ class WebSocket_Client(threading.Thread): elif messageType == "UserConfigurationUpdated": # Update user data set in userclient - userclient.UserClient().userSettings = data + userclient.UserClient().get_user(data) self.librarySync.refresh_views = True def on_close(self, ws): diff --git a/resources/settings.xml b/resources/settings.xml index ac6ddd0f..b29f8e8d 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -1,27 +1,24 @@ - - - - - - - - - - - - - + + + + + + + + + + + - + + + - - - - + diff --git a/resources/skins/default/1080i/script-emby-connect-login-manual.xml b/resources/skins/default/1080i/script-emby-connect-login-manual.xml new file mode 100644 index 00000000..57e4a88a --- /dev/null +++ b/resources/skins/default/1080i/script-emby-connect-login-manual.xml @@ -0,0 +1,145 @@ + + + 200 + 0 + dialogeffect + + + Background fade + 100% + 100% + emby-bg-fade.png + + + + 600 + 35% + 20% + + Background box + white.png + 600 + 480 + + + + 485 + False + + Error box + white.png + 100% + 50 + + + + Error message + white + font10 + center + center + 50 + + + + + Emby logo + logo-white.png + keep + 120 + 49 + 30 + 25 + + + + 500 + 50 + + Please sign in + + white + font12 + top + center + 100% + 100 + + + + 150 + + Username + + ffa6a6a6 + font10 + top + + + + separator + 102% + 0.5 + 66 + -10 + emby-separator.png + + + + + Password + 225 + + Password label + + ffa6a6a6 + font10 + top + + + + separator + 102% + 0.5 + 66 + -10 + emby-separator.png + + + + + Buttons + 335 + + Sign in + box.png + box.png + + font10 + ffa6a6a6 + white + center + 100% + 50 + 201 + + + + Cancel + box.png + box.png + + font10 + ffa6a6a6 + white + center + 100% + 50 + 55 + 200 + + + + + + \ No newline at end of file diff --git a/resources/skins/default/1080i/script-emby-connect-login.xml b/resources/skins/default/1080i/script-emby-connect-login.xml index b5b01632..cb69387c 100644 --- a/resources/skins/default/1080i/script-emby-connect-login.xml +++ b/resources/skins/default/1080i/script-emby-connect-login.xml @@ -1,6 +1,6 @@ - 200 + 200 0 dialogeffect @@ -13,21 +13,41 @@ 600 - 33% + 35% 15% Background box - box.png + white.png 600 700 + + + 705 + False + + Error box + white.png + 100% + 50 + + + + Error message + white + font10 + center + center + 50 + + Emby logo logo-white.png 160 49 - 20 + 30 25 @@ -47,7 +67,7 @@ 190 Username email - + ffa6a6a6 font10 top @@ -59,7 +79,7 @@ 0.5 66 -10 - separator.png + emby-separator.png @@ -80,7 +100,7 @@ 0.5 66 -10 - separator.png + emby-separator.png @@ -89,10 +109,12 @@ 385 Sign in - box.png - box.png + box.png + box.png font10 + ffa6a6a6 + white center 100% 50 @@ -100,11 +122,13 @@ - Later + Cancel box.png - box.png + box.png font10 + ffa6a6a6 + white center 100% 50 @@ -124,30 +148,32 @@ true top 340 + 100% - - qrcode - qrcode_disclaimer.png - 140 - 140 - 10 - 360 - + + + Scan me + + font12 + ff0b8628 + top + 200 + 120 + 230 + - - Scan me - - font12 - green - right - top - 120 - 160 + + qrcode + qrcode_disclaimer.png + 140 + 140 + 10 + 360 + - \ No newline at end of file diff --git a/resources/skins/default/1080i/script-emby-connect-server-manual.xml b/resources/skins/default/1080i/script-emby-connect-server-manual.xml new file mode 100644 index 00000000..27e50037 --- /dev/null +++ b/resources/skins/default/1080i/script-emby-connect-server-manual.xml @@ -0,0 +1,154 @@ + + + 200 + 0 + dialogeffect + + + Background fade + 100% + 100% + emby-bg-fade.png + + + + 600 + 35% + 20% + + Background box + white.png + 600 + 525 + + + + 530 + False + + Error box + white.png + 100% + 50 + + + + Error message + white + font10 + center + center + 50 + + + + + Emby logo + logo-white.png + keep + 120 + 49 + 30 + 25 + + + + 500 + 50 + + Connect to server + + white + font12 + top + center + 100% + 100 + + + + 150 + + Host + + ffa6a6a6 + font10 + top + + + + separator + 102% + 0.5 + 66 + -10 + emby-separator.png + + + + Host example + + ff464646 + font10 + top + 70 + + + + + Port + 275 + + Port label + + ffa6a6a6 + font10 + top + + + + separator + 102% + 0.5 + 66 + -10 + emby-separator.png + + + + + Buttons + 380 + + Connect + box.png + box.png + + font10 + ffa6a6a6 + white + center + 100% + 50 + 201 + + + + Cancel + box.png + box.png + + font10 + ffa6a6a6 + white + center + 100% + 50 + 55 + 200 + + + + + + \ No newline at end of file diff --git a/resources/skins/default/1080i/script-emby-connect-server.xml b/resources/skins/default/1080i/script-emby-connect-server.xml new file mode 100644 index 00000000..01fc9141 --- /dev/null +++ b/resources/skins/default/1080i/script-emby-connect-server.xml @@ -0,0 +1,280 @@ + + + 155 + 0 + dialogeffect + + + Background fade + 100% + 100% + emby-bg-fade.png + + + + 450 + 38% + 15% + + Background box + white.png + 450 + 710 + + + + Emby logo + logo-white.png + keep + 120 + 49 + 30 + 25 + + + + User info + 70 + 350 + 50 + + User image + userflyoutdefault.png + keep + center + 100% + 70 + 40 + + + + Busy animation + center + 23 + 100% + 105 + False + fading_circle.png + keep + conditional + + + + Welcome user + white + font12 + center + top + 120 + 100% + 50 + + + + separator + 102% + 0.5 + 165 + -10 + emby-separator.png + + + + Select server + ffa6a6a6 + + font10 + center + top + 170 + 100% + 50 + + + + + 290 + 100% + 184 + + Connect servers + 0 + 100% + 100% + 10 + 55 + 155 + 205 + 206 + 205 + 206 + 155 + 60 + 250 + + + 45 + 45 + + Network + keep + network.png + StringCompare(ListItem.Property(server_type),network) + + + Wifi + keep + wifi.png + StringCompare(ListItem.Property(server_type),wifi) + + + + + 300 + 40 + 55 + font10 + center + ff838383 + ListItem.Label + + + + + 45 + 45 + + Network + keep + network.png + StringCompare(ListItem.Property(server_type),network) + + + Wifi + keep + wifi.png + StringCompare(ListItem.Property(server_type),wifi) + + + + + 300 + 40 + 55 + font10 + center + white + ListItem.Label + Control.HasFocus(155) + + + 300 + 40 + 55 + font10 + center + ff838383 + ListItem.Label + !Control.HasFocus(155) + + + + + + 395 + 10 + 5 + 100% + 155 + 60 + 60 + box.png + box.png + box.png + false + + + + 100% + 220 + + 45 + 150 + + True + Sign in Connect + box.png + box.png + + font10 + ffa6a6a6 + white + center + 350 + 50 + 50 + 155 + 206 + + + + Manually add server + box.png + box.png + + font10 + ffa6a6a6 + white + center + 55 + 350 + 50 + 50 + 205 + 155 + 201 + + + + Cancel + box.png + box.png + + font10 + ffa6a6a6 + white + center + 110 + 350 + 50 + 50 + 206 + + + + + 100% + False + + Message box + white.png + 100% + 50 + 20 + + + + Message + white + font10 + center + center + 50 + 20 + + + + + + + \ No newline at end of file diff --git a/resources/skins/default/1080i/script-emby-connect-users.xml b/resources/skins/default/1080i/script-emby-connect-users.xml new file mode 100644 index 00000000..0acd2b32 --- /dev/null +++ b/resources/skins/default/1080i/script-emby-connect-users.xml @@ -0,0 +1,198 @@ + + + 155 + 0 + dialogeffect + + + Background fade + 100% + 100% + emby-bg-fade.png + + + + 715 + 32% + 20% + + Background box + white.png + 100% + 525 + + + + Emby logo + logo-white.png + keep + 120 + 49 + 30 + 25 + + + + Please sign in + + white + font12 + top + center + 80 + 100% + + + + 100 + 620 + 245 + 50 + + Select User + 0 + 100% + 40 + 155 + 155 + 200 + 60 + horizontal + 250 + + + 150 + + User image + ff888888 + ListItem.Icon + keep + 100% + 150 + + + + Background label + white.png + 100% + 50 + 150 + + + + 100% + center + 50 + 150 + font10 + white + ListItem.Label + + + + + + 150 + + User image + ListItem.Icon + keep + 100% + 150 + Control.HasFocus(155) + + + User image + ff888888 + ListItem.Icon + keep + 100% + 150 + !Control.HasFocus(155) + + + + Background label + white.png + 100% + 50 + 150 + Control.HasFocus(155) + + + Background label + white.png + 100% + 50 + 150 + !Control.HasFocus(155) + + + + 100% + center + 50 + 150 + font10 + white + ListItem.Label + + + + + + + 100% + 615 + 5 + 155 + 60 + 60 + box.png + box.png + box.png + false + horizontal + + + + 615 + 325 + 100% + + + Manual Login button + box.png + box.png + + center + 100% + 50 + 35 + font10 + ffa6a6a6 + white + 201 + 155 + + + + Cancel + box.png + box.png + + font10 + ffa6a6a6 + white + center + 100% + 50 + 90 + 200 + + + + + + + \ No newline at end of file diff --git a/resources/skins/default/media/separator.png b/resources/skins/default/media/emby-separator.png similarity index 100% rename from resources/skins/default/media/separator.png rename to resources/skins/default/media/emby-separator.png diff --git a/resources/skins/default/media/fading_circle.png b/resources/skins/default/media/fading_circle.png new file mode 100644 index 0000000000000000000000000000000000000000..56aea399872fab57cd01439a481a8b9672bbba09 GIT binary patch literal 1906 zcmd6o`#;l-0>=?6*BZ}uW+}&YZFXE99-(r-%zeX_6^`4;otknBX|5Y`Np5wX2s2-c zF)ebP%WJS=&R)T8747w7YOeLnABKd;X(pKPp)ovajGN<>6N)*fT+cFdCh z`P7NylwQ2|`WTg1Cl8xre*b?}j}JNGxSs*p&Ba|rT=oEb`6QfV*sli3)f+#rWrg^3 zd)EhhRL^S-vvZ8zsxHe|Ccb@+Z%_O9xTq^pD!E`c6*?m#0>ar_TX@9HZoS3bDc6M! z3K4o@mP&NM1lQm}!UF-{j}{|_&*pb-fN_NjyVB8rnYo(RO>K}(9=m)}y(Td_c(XY-#4>YTnJih)rnBaOYq6OkB;6NqfXA?K-?e5A<1)_Z{ zh+`oiLOnmj_mX3>L;mjD-sAO9c=xEPw9#~Qj8M$Gju6rJ?P?Ql{7CoGiB?!2T8ce0 zEJY7Ah?510We`VGom-Mxo}y+qHAYjINlDHRUlac1g1F>c#pcC}ZI-$k{h^0$ z?T$7-NG??T*yg(w+0iVPDE ze$~NRFJ9v++7iK$*Rdt$vNOcskuOUVn2M#g2R%>0^`nQ2UGrNUNh?3tkvVJ&j!vee zRf(U;#(vH!d$91cbJ~>L9)ib++<{)UAaAZ*cvTW0Fmh69@Y!RfDN3?=*~TNY}Wht6od46_7JE++d=$^rAP3O?|k& zXuW}L*SeiLh5L{vy2bbHzYQ-nsXl4B_N)p}K7o#w_oI|*=D!>8wYVTYMKlfKxbqaD zm_afDG}cLdVLG@D>ZP5zCEm$wpEn8D4$AnVC*7@h_4L_2h`#zd_@bMCfT-H}pb_Ne zvcG$tziq}#H}}|A`1c+?n5>sShPLS)Own3&ij2ZZSK1~$;=AsMfpduG-nVZ;`lz;4 zh0rtfbyHA)o!ja6Sw&cgwD6Z?bqPcHI(6_K zZ-#l-oKpgHv}9-h6Rd%=@|GYVv_|~T`(O=M`Q;ivPSje{s)YA+E-{3L zr&lsst=a-T0-(}O^212Se@5XCRtW6v_ZpXh!A)`@xN~M~py&q>{+qx}o7aCnpk(l0 z(q~y;qHow=bpX(OO+n_7^&4YZ-J(aI>62)%e{6C>9qOizymC>}@U=(=yaKWvQ(l2TLEMFgFgc}>v{Lq6LNxjI>v4p9cKF=IL z3rnwma>dG;So}|uwR)(Ba&2qW`x{j#zL2xHm*^sRWjK+6R5Pr?5LQzHaUWp&TGV{M z$ESR$2)X=jS1Rk2Gs@Y^6OzL`Qllmj*dZ&uCMG5AdyiME7xCWp{>ncK5Re9wiA|Hq qecG%l$$rcK#oYGoj`_%ohmvs9fz?W#<+_*uO6M7H(C<@r09&c7wR#u*o?vo@l7O*Bul6@NAu*(1$0A#zEhQ#)Up~oh1 z40!?ZfFkD?!ax*Y(ijR|LpZjOiC{a|@dB9)US=uwkRY&ULiK$i(^s5de+!ubY>-1{ zVu@H05hK=IUwuAz`AmjTVA4Jpk_mhN6+(hnweIQ;K43;7VDyRbAY~@BBqpvT3zM`>?MA4_YrY99Dvk5dgcTNy z2dFN42BX?J83Cb^a%@zFN-QtY!)4-1OhA2XtID}^RXrP3)!DhKYLygLH{+#C){BUo zy3tkS+9mdd-0`lX7Ps!ErM} zOo@KD<0Uran0kKv+jelOKXrq%vP-mh-I~W6$@Tx|=)>`0x&L$vy3rL9i9)>u@gP^P zb3EqvFQ&1s*a{FD(0wQ!Y?sl%iPDPFIe>B;exN=0Z%K_#re(Xd<}=?$CsvxUQDcP(BpwW}uALYKpack=?EI~mV;x;Tbp+t zyY2G^!S%)Fx1y8Tt0uG8{JzopfA7ClN^jJnDpRj>v4vbWl@e!FzLFaGIPB1bPunW` zgpN&_&RVSz;c)iW(X|P!S?7)`MJUbcbc}eM8ZxgY;;{W*gN`=WFIyt5B9_iRBQLk+ zk?+&#Qqxwd7wN7sK6PSmQ)!5P{e+K!CM*}~9|Y0+>`pF?^UX|CSMN8t{6dg4?HIB+I z^+^@EGi5#VQ{f{&&IQ^n>(Mt`8tIT(Z6IaQ6DG2B<*^L@AgQ@M({vaAJd?3Fn2o9D z;<5kynQwY#_ZeCk%QMQQ&&V{HW_zol^8JcG9nSyXzUFq1I}))!=o8;*!|rUm_ggo7 cd3cV$IEZ%^lVPScFbObty85}Sb4q9e0Pz`5JOBUy literal 0 HcmV?d00001 diff --git a/resources/skins/default/media/userflyoutdefault.png b/resources/skins/default/media/userflyoutdefault.png new file mode 100644 index 0000000000000000000000000000000000000000..046676e597da515a8969467dad0fe42877b092fb GIT binary patch literal 1338 zcmai!do&XY0LGWRTB(ub6`Q7vD6utnbnk3zLndLSxYmk1Vj;#DceFKA&MCvCbW4__ zsd*Q&d1W3cbXVq;R}spMn0t+@|L-~Xcg}ad?|k3?AJ@whrv)?w0ssIl0v_uP0Bk$* zLz>&S8qZ=TZp#9^JbYZZT<%shFfafDfe8r-85tQ+C{#^NEhs3+-rk-@qY;TjU0q!# zCnpAj;p^)g9v)7mQn6Sp2m~S!2sSo0mX?-radBWUI4vy=jYfxthLXu-BodkCCgK19 zs)YnB+J`QmAK6hm0S2jgndCcMaeJH0QASra5Q*Z2Qs}s;ReOWGq|ySY!oL20Z18j5 zE!oyw9+HL0eTMd6T5AI{8MTAv>@Rl=ITUgQe3?)*VQ|*Ak=fwiIDud{S)Eao#$p%> z%k%e)hh-by(YDW-e?=YgJRm?~$uaPJK{l;=CEpTWF-WPe;&#g^ zZ0YB6lh|i`E}!Zn&4nZ;k9*1_OIYg-9XD89E|S3BI(Zp@)gU2HRGzZ|B{I zn~C%Tie#Ff!gC&jdiyNi9lYa_XhhRmOr3e`jMpfZYeO803ou?Kr{^^1 z3iX8dLCk`&-^WwPs)9ACA6ue(?^WQWhHP;%i98>6MZ}VuRri)&ZMJembT;1-+55ae zUSNF+PPw6qp{vvKF~Nt6N!gOD^J*&nO|TINcmX$&povd7Wk~28_nK&YDoND!t!`3G%P?%7*+fL9gR4#B6eS_SYeUCGuB8V48;D&(?Qr#6 zvDHWJTmfGtmOP?lzt-U;9$$Fe^15r+!0f1vv1r@_>D-(FKXloavWBY3Kg9dE!6VAx z0;#U47jvSxgKpEb`GMG}P5rY%7^Bj|Rm!pRS+@P}!`Y}jy{6;s8h9u36AHpXPf{u$ z*BC+-K2ZS390ZnwvVMG4-A{4GZ$vh#)$;S}NWUC``@PyRQRTFW&=BRVSg464t~WFicZ+)OJM zwmjXvsz3XI-w?xLbcTCZ=vOzK8%v9cz^-06j`eqFax{%{TG(or@o@9RI86=9Q(MSInGi6IzjRF zlPb$=`*W8;y}N5Dp>rmC(z7Xb(}DG@yY=1uu1Dt{7*%I>LWW9aZ}go-@K;{Wh-Yxb t_I&jyX1Bjc{UKX^>6>h@b~RsfW%+QqE;k7W`c_+_K9;a>t8_ab^`za literal 0 HcmV?d00001 diff --git a/resources/skins/default/media/userflyoutdefault2.png b/resources/skins/default/media/userflyoutdefault2.png new file mode 100644 index 0000000000000000000000000000000000000000..24d771eb7e9f8c1d50ce033f37c57e25892432d4 GIT binary patch literal 1700 zcmb`Hdo8`l$IUqth0aYKYPwT=Q;1^dB4y1InVpo%VW@8l@-(# z007EVHzzLuAbAM^0$Psf>;6nRPBCaqXF2ER=jZ0;7EgKcuOaU)#$xyH+X9#TONJ$J zNm-g&GA|X29v+W(u=Z4){54w+dC|QAW|kaNo;lrJT`>A8UY*bqM%kpZ`pcWkM5T$N z%?&KZ$H%^Kue%j5DbUH{#`YlquotOL4$Or9@z-Oq{S4xXVULy0Q-k-gw1K+=^Pijz z=t`-V9lNUPCT6PhC(R?o-B9J^%^?yj^Bz(XfrSLv&c7%EjQ>Z{jsp{umGuP}wx2{2 zp4Ki=7FAdzdN-EP3JaB3}tmgO}9N|BXN_wMSj+5orDa;j< zhIqV+`pMjIa4AQKF-E2B28VaTWW+-u5XA==BViywgCdbT|hoR=+4xMOnScNgf$`Vpq30?}a6ya4gY$(#@bQ2Lsib;5DQ~)s)y2g}aRf<;& zWSo?^&)EqZ59tGD{#`s~KQ=GZ%){w3wkz!WdBRf#S?kmvuzaOQX_d{w>Q4XBt+E(k zV9wSX_fqUym8j2kMYegraLNk+GsWU34=3=9pKnuA-(#f~BkPy#MQ7GwC_m_AxJ-u& z1wxrOF_fedlAtG32&&L&eM&y~nR8Kycy?HGu2h3H7@FNSL$?IDkk%)c-q}h3)lP@B zqK5wqppk^G<7Jv6!_!u{N6P{II62nRie?XA=cJ#pKgiSsU2&w!*3DnPbW(SwsIJWjVMhU!GW1bS<*+pLSt7TO?o!abGrGrxwn_}5rOB#a370u zg8Ad4pz=qgDZol z4sY)bFj=1ZRSU9d$IicRoXZ!n7@TeGCIoM^%w}KTvXq8o_v*Hwiq{k6P-G+JP5oAP zi^R}nFdi|NNUnvMJYMFNScPWIi9SC6p4KImmE1myLlXH}0YUmw%LMR* zzm@#Tf9NxUrX>d6e_wuSPm$Eq2Ik!em|9nOz~6S7nsos2ylW{ zSU(C}&*i+TG%{gkDKWxpZ?~W6g-kUlcjAMBt4!2bEr&;Jy`X+Y7iyPU8Ar)fawH%< z6g5RMa%fo{qgWF@m6{YkqX@T|L*086G2DXmqMSx|9ptL3_)5Jho*NIPH7TTmY|X5U z;2?G6$*k|i$EXC!S4<3k2k^Ndny9}F!hKqIK57v+LuSc88SM6^0=st`L+-N(+F@>P z<(AUJ{AU~tiV~~k~J9Z*hFTw=D^h&sM1K2{cB@4xt&{cA9cDK? z%=fJvWuF8e&hD6wA^<75ee@-oxz^I*!Y~mQHFS)yYw`Z3F~SKnNAk;jzRazjGbXCR!nq+@yk%-dWS1YmGcP5cd=vIfx7V&fV}u-*sy!z_Aw6 zfV)T>f{OSLlB{CA4G{q3sP>fOmtKuWYaCIk8({=M|A5HYSWCvHTYppK_7%uKctCZg KJ5@Qdj{OO+*$OQH literal 0 HcmV?d00001 diff --git a/resources/skins/default/media/wifi.png b/resources/skins/default/media/wifi.png new file mode 100644 index 0000000000000000000000000000000000000000..2a646c5599262ee182823c73a615d5210f617895 GIT binary patch literal 1095 zcmV-N1i1T&P)h`|Vkz8*+h1fk~|PiG8uq!4(g4q}HIS#=ADzy+Av2bkIlhrbk4 zZxTme8%0?ZK2a54fEZVE5P7H&S$Y^?fevh!4QrMIsr3K<{{yr4BB}J(;rOWW{`dU< z*Yf=XsrUE)|Df^y|NQ;;_5APj{MX+1GO6_6`TmgV{W8w`1G)G@v-Icu|G@J8o9g|D zu<8837i z_lV2)BEI*i%l44O^?AAUX}R>*`Twx={(0v8-|_qd!TPw~`AN(9-{kmd!S-3R^pu)n z{{R309duGoQvk!4OnHZg5Vas|YD_MNr=^E3AP^8fE^IzLTwDqywQ&Fd0@O)FK~!ko z?U`v)+E5gRbFzSFSQHVpyN*+qGH{xOZWBv|CDwZ( zmY18vNJ&Xak&(??mhG5Crej-HJ{!Mb(@JxzI#m_t!~RLhCShu|vOLj)(yVUx+@GMHiCzrQ|3c3FW3pN+Kpo z5%;iub>8r5k9glMZoAP2%o1U|dAC!WpmX$AgkjB`@O%5U5Dq?p%vY_}4J)7q*K18C zsqeOG(|NHaldOeEY+0q=*Zg>1;~ytp;D@h1ib-tsYMr%weg5%VJTd&)_f10kKD1sq zQks4KZ;!f@6W1I6`dBqusuS(l9xLvJjNw@?yfHbnB|%C1kpIHOScFO}C}9itRnA_I z9SV|ykXo+0+=an;W5@G4jf)Sw>)%i1Q*1*CTYhaleHl!a&Ug~)U!}vKP!7M(k9|u4`vZA~R#VZn