diff --git a/.gitignore b/.gitignore index b344f958..c79bce8b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ machine_guid .idea/ .DS_Store .vscode/ +pyinstrument/ +pyinstrument_cext.so diff --git a/jellyfin_kodi/jellyfin/api.py b/jellyfin_kodi/jellyfin/api.py index ba43c4cd..049e6035 100644 --- a/jellyfin_kodi/jellyfin/api.py +++ b/jellyfin_kodi/jellyfin/api.py @@ -1,5 +1,10 @@ -# -*- coding: utf-8 -*- from __future__ import division, absolute_import, print_function, unicode_literals +import requests +import json +import logging +from helper.utils import settings + +LOG = logging.getLogger('JELLYFIN.' + __name__) def jellyfin_url(client, handler): @@ -35,6 +40,9 @@ class API(object): ''' def __init__(self, client, *args, **kwargs): self.client = client + self.config = client.config + self.default_timeout = 5 + def _http(self, action, url, request={}): request.update({'type': action, 'handler': url}) @@ -344,3 +352,97 @@ class API(object): return self._delete("Videos/ActiveEncodings", params={ 'DeviceId': device_id }) + + + ################################################################################################# + + # New API calls + + ################################################################################################# + def get_default_headers(self): + auth = "MediaBrowser " + auth += "Client=%s, " % self.config.data['app.name'].encode('utf-8') + auth += "Device=%s, " % self.config.data['app.device_name'].encode('utf-8') + auth += "DeviceId=%s, " % self.config.data['app.device_id'].encode('utf-8') + auth += "Version=%s" % self.config.data['app.version'].encode('utf-8') + + return { + "Accept": "application/json", + "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Application": "%s/%s" % (self.config.data['app.name'], self.config.data['app.version']), + "Accept-Charset": "UTF-8,*", + "Accept-encoding": "gzip", + "User-Agent": self.config.data['http.user_agent'] or "%s/%s" % (self.config.data['app.name'], self.config.data['app.version']), + "x-emby-authorization": auth + } + + def send_request(self, url, path, method=None, timeout=None, headers=None, data=None): + if not timeout: + LOG.debug("No timeout set, using default") + timeout = self.default_timeout + if not headers: + LOG.debug("No headers set, using default") + headers = self.get_default_headers() + if not method: + LOG.debug("No method set, using default") + method = "get" #Defaults to get request if none specified + + request_method = getattr(requests, method.lower()) + url = "%s/%s" % (url, path) + request_settings = { + "timeout": timeout, + "headers": headers, + "data": data + } + + if not settings('sslverify'): + request_settings["verify"] = False + + LOG.info("Sending %s request to %s" % (method, url)) + LOG.debug(request_settings) + + return request_method(url, **request_settings) + + + def login(self, server_url, username, password): + path = "Users/AuthenticateByName" + authData = { + "username": username, + "Pw": password or "" + } + + headers = self.get_default_headers() + headers.update({'Content-type': "application/json"}) + + try: + LOG.info("Trying to login to %s/%s as %s" % (server_url, path, username)) + response = self.send_request(server_url, path, method="post", headers=headers, data=json.dumps(authData)) + + if response.status_code == 200: + return response.json() + else: + LOG.error("Failed to login to server with status code: "+str(response.status_code)) + LOG.error(response.text) + LOG.debug(headers) + + return {} + except Exception as e: #Find exceptions for likely cases i.e, server timeout, etc + LOG.error(e) + + return {} + + def validate_authentication_token(self, server): + + url = "%s/%s" % (server['address'], "system/info") + authTokenHeader = { + 'X-MediaBrowser-Token': server['AccessToken'] + } + headers = self.get_default_headers() + headers.update(authTokenHeader) + + response = self.send_request(server['address'], "system/info", headers=headers) + return response.json() if response.status_code == 200 else {} + + def get_public_info(self, server_address): + response = self.send_request(server_address, "system/info/public") + return response.json() if response.status_code == 200 else {} \ No newline at end of file diff --git a/jellyfin_kodi/jellyfin/connection_manager.py b/jellyfin_kodi/jellyfin/connection_manager.py index 2b0e4780..e66a763f 100644 --- a/jellyfin_kodi/jellyfin/connection_manager.py +++ b/jellyfin_kodi/jellyfin/connection_manager.py @@ -8,12 +8,14 @@ import logging import socket import time from datetime import datetime -from distutils.version import LooseVersion +from operator import itemgetter import urllib3 from .credentials import Credentials from .http import HTTP # noqa: I201,I100 +from .api import API +import traceback ################################################################################################# @@ -29,11 +31,8 @@ CONNECTION_STATE = { class ConnectionManager(object): - min_server_version = "10.1.0" - server_version = min_server_version user = {} server_id = None - timeout = 10 def __init__(self, client): @@ -43,25 +42,14 @@ class ConnectionManager(object): self.config = client.config self.credentials = Credentials() - self.http = HTTP(client) + self.API = API(client) - def clear_data(self): - - LOG.info("connection manager clearing data") - - self.user = None - credentials = self.credentials.get_credentials() - credentials['Servers'] = list() - self.credentials.get_credentials(credentials) - - self.config.auth(None, None) - - def revoke_token(self): + def revoke_token(self): #Called once in http#L130 LOG.info("revoking token") self['server']['AccessToken'] = None - self.credentials.get_credentials(self.credentials.get_credentials()) + self.credentials.set_credentials(self.credentials.get()) self.config.data['auth.token'] = None @@ -70,7 +58,7 @@ class ConnectionManager(object): LOG.info("Begin getAvailableServers") # Clone the credentials - credentials = self.credentials.get_credentials() + credentials = self.credentials.get() found_servers = self._find_servers(self._server_discovery()) if not found_servers and not credentials['Servers']: # back out right away, no point in continuing @@ -78,44 +66,64 @@ class ConnectionManager(object): return list() servers = list(credentials['Servers']) - self._merge_servers(servers, found_servers) - 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) + #Merges servers we already knew with newly found ones + for found_server in found_servers: + try: + self.credentials.add_update_server(servers, found_server) + except KeyError: + continue + servers.sort(key=itemgetter('DateLastAccessed'), reverse=True) credentials['Servers'] = servers - self.credentials.get_credentials(credentials) + self.credentials.set(credentials) return servers - def login(self, server, username, password=None, clear=True, options={}): + def login(self, server_url, username, password=None): if not username: raise AttributeError("username cannot be empty") - if not server: - raise AttributeError("server cannot be empty") + if not server_url: + raise AttributeError("server url cannot be empty") - try: - request = { - 'type': "POST", - 'url': self.get_jellyfin_url(server, "Users/AuthenticateByName"), - 'json': { - 'Username': username, - 'Pw': password or "" - } - } + data = self.API.login(server_url, username, password) # returns empty dict on failure - result = self._request_url(request, False) - except Exception as error: # Failed to login - LOG.exception(error) - return False + if not data: + LOG.info("Failed to login as `"+username+"`") + return {} + + LOG.info("Succesfully logged in as %s" % (username)) + ## TODO Change when moving to database storage of server details + credentials = self.credentials.get() + + self.config.data['auth.user_id'] = data['User']['Id'] + self.config.data['auth.token'] = data['AccessToken'] + + for server in credentials['Servers']: + if server['Id'] == data['ServerId']: + found_server = server + break else: - self._on_authenticated(result, options) + return # No server found + + found_server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + found_server['UserId'] = data['User']['Id'] + found_server['AccessToken'] = data['AccessToken'] + + self.credentials.add_update_server(credentials['Servers'], found_server) + + info = { + 'Id': data['User']['Id'], + 'IsSignedInOffline': True + } + self.credentials.add_update_user(server, info) + + self.credentials.set_credentials(credentials) + + return data - return result def connect_to_address(self, address, options={}): @@ -125,7 +133,7 @@ class ConnectionManager(object): address = self._normalize_address(address) try: - public_info = self._try_connect(address, options=options) + public_info = self.API.get_public_info(address) LOG.info("connectToAddress %s succeeded", address) server = { 'address': address, @@ -146,26 +154,41 @@ class ConnectionManager(object): def connect_to_server(self, server, options={}): LOG.info("begin connectToServer") - timeout = self.timeout try: - result = self._try_connect(server['address'], timeout, options) + result = self.API.get_public_info(server.get('address')) + + if not result: + LOG.error("Failed to connect to server: %s" % server.get('address')) + return { 'State': CONNECTION_STATE['Unavailable'] } + LOG.info("calling onSuccessfulConnection with server %s", server.get('Name')) - credentials = self.credentials.get_credentials() + + credentials = self.credentials.get() return self._after_connect_validated(server, credentials, result, True, options) except Exception as e: - LOG.info("Failing server connection. ERROR msg: {}".format(e)) + LOG.error(traceback.format_exc()) + LOG.error("Failing server connection. ERROR msg: {}".format(e)) return { 'State': CONNECTION_STATE['Unavailable'] } def connect(self, options={}): LOG.info("Begin connect") - return self._connect_to_servers(self.get_available_servers(), options) - def jellyfin_user_id(self): - return self.get_server_info(self.server_id)['UserId'] + servers = self.get_available_servers() + LOG.info("connect has %s servers", len(servers)) - def jellyfin_token(self): + if not (len(servers)): #No servers provided + return { + 'State': ['ServerSelection'] + } + + result = self.connect_to_server(servers[0], options) + LOG.debug("resolving connect with result: %s", result) + + return result + + def jellyfin_token(self): ## Called once monitor.py#163 return self.get_server_info(self.server_id)['AccessToken'] def get_server_info(self, server_id): @@ -174,88 +197,15 @@ class ConnectionManager(object): LOG.info("server_id is empty") return {} - servers = self.credentials.get_credentials()['Servers'] + servers = self.credentials.get()['Servers'] for server in servers: if server['Id'] == server_id: return server - def get_public_users(self): + def get_public_users(self): ## Required in connect.py#L213 return self.client.jellyfin.get_public_users() - def get_jellyfin_url(self, base, handler): - return "%s/%s" % (base, handler) - - def _request_url(self, request, headers=True): - - request['timeout'] = request.get('timeout') or self.timeout - if headers: - self._get_headers(request) - - try: - return self.http.request(request) - except Exception as error: - LOG.exception(error) - raise - - def _add_app_info(self): - return "%s/%s" % (self.config.data['app.name'], self.config.data['app.version']) - - def _get_headers(self, request): - - headers = request.setdefault('headers', {}) - - if request.get('dataType') == "json": - headers['Accept'] = "application/json" - request.pop('dataType') - - headers['X-Application'] = self._add_app_info() - headers['Content-type'] = request.get( - 'contentType', - 'application/x-www-form-urlencoded; charset=UTF-8' - ) - - def _connect_to_servers(self, servers, options): - - LOG.info("Begin connectToServers, with %s servers", len(servers)) - result = {} - - if len(servers) == 1: - result = self.connect_to_server(servers[0], options) - LOG.debug("resolving connectToServers with result['State']: %s", result) - - return result - - first_server = self._get_last_used_server() - # See if we have any saved credentials and can auto sign in - if first_server is not None and first_server['DateLastAccessed'] != "2001-01-01T00:00:00Z": - result = self.connect_to_server(first_server, options) - - if result['State'] in (CONNECTION_STATE['SignedIn'], CONNECTION_STATE['Unavailable']): - return result - - # Return loaded credentials if exists - credentials = self.credentials.get_credentials() - - return { - 'Servers': servers, - 'State': result.get('State') or CONNECTION_STATE['ServerSelection'], - } - - def _try_connect(self, url, timeout=None, options={}): - - url = self.get_jellyfin_url(url, "system/info/public") - LOG.info("tryConnect url: %s", url) - - return self._request_url({ - 'type': "GET", - 'url': url, - 'dataType': "json", - 'timeout': timeout, - 'verify': options.get('ssl'), - 'retry': False - }) - def _server_discovery(self): MULTI_GROUP = ("", 7359) @@ -277,6 +227,7 @@ class ConnectionManager(object): try: sock.sendto(MESSAGE, MULTI_GROUP) except Exception as error: + LOG.exception(traceback.format_exc()) LOG.exception(error) return servers @@ -290,33 +241,10 @@ class ConnectionManager(object): return servers except Exception as e: + LOG.error(traceback.format_exc()) LOG.exception("Error trying to find servers: %s", e) return servers - def _get_last_used_server(self): - - servers = self.credentials.get_credentials()['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 _merge_servers(self, list1, list2): - - for i in range(0, len(list2), 1): - try: - self.credentials.add_update_server(list1, list2[i]) - except KeyError: - continue - - return list1 - def _find_servers(self, found_servers): servers = [] @@ -336,7 +264,7 @@ class ConnectionManager(object): return servers # TODO: Make IPv6 compatable - def _convert_endpoint_address_to_manual_address(self, info): + def _convert_endpoint_address_to_manual_address(self, info): # Called once ^^ right there if info.get('Address') and info.get('EndpointAddress'): address = info['EndpointAddress'].split(':')[0] @@ -373,14 +301,6 @@ class ConnectionManager(object): return url.url - def _save_user_info_into_credentials(self, server, user): - - info = { - 'Id': user['Id'], - 'IsSignedInOffline': True - } - self.credentials.add_update_user(server, info) - def _after_connect_validated(self, server, credentials, system_info, verify_authentication, options): if options.get('enableAutoLogin') is False: @@ -388,22 +308,24 @@ class ConnectionManager(object): self.config.data['auth.token'] = server.pop('AccessToken', None) elif verify_authentication and server.get('AccessToken'): - if self._validate_authentication(server, options) is not False: - + system_info = self.API.validate_authentication_token(server) + if system_info: + + self._update_server_info(server, system_info) self.config.data['auth.user_id'] = server['UserId'] self.config.data['auth.token'] = server['AccessToken'] + return self._after_connect_validated(server, credentials, system_info, False, options) + server['UserId'] = None + server['AccessToken'] = None return { 'State': CONNECTION_STATE['Unavailable'] } self._update_server_info(server, system_info) - self.server_version = system_info['Version'] - - if options.get('updateDateLastAccessed') is not False: - server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') self.credentials.add_update_server(credentials['Servers'], server) - self.credentials.get_credentials(credentials) + self.credentials.set(credentials) self.server_id = server['Id'] # Update configs @@ -419,28 +341,7 @@ class ConnectionManager(object): result['State'] = CONNECTION_STATE['SignedIn'] if server.get('AccessToken') else CONNECTION_STATE['ServerSignIn'] # Connected return result - - def _validate_authentication(self, server, options={}): - - try: - system_info = self._request_url({ - 'type': "GET", - 'url': self.get_jellyfin_url(server['address'], "System/Info"), - 'verify': options.get('ssl'), - 'dataType': "json", - 'headers': { - 'X-MediaBrowser-Token': server['AccessToken'] - } - }) - self._update_server_info(server, system_info) - except Exception as error: - LOG.exception(error) - - server['UserId'] = None - server['AccessToken'] = None - - return False - + def _update_server_info(self, server, system_info): if server is None or system_info is None: @@ -450,30 +351,4 @@ class ConnectionManager(object): server['Id'] = system_info['Id'] if system_info.get('address'): - server['address'] = system_info['address'] - - ## Finish updating server info - - def _on_authenticated(self, result, options={}): - - credentials = self.credentials.get_credentials() - - self.config.data['auth.user_id'] = result['User']['Id'] - self.config.data['auth.token'] = result['AccessToken'] - - for server in credentials['Servers']: - if server['Id'] == result['ServerId']: - found_server = server - break - else: - return # No server found - - if options.get('updateDateLastAccessed') is not False: - found_server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') - - found_server['UserId'] = result['User']['Id'] - found_server['AccessToken'] = result['AccessToken'] - - self.credentials.add_update_server(credentials['Servers'], found_server) - self._save_user_info_into_credentials(found_server, result['User']) - self.credentials.get_credentials(credentials) + server['address'] = system_info['address'] \ No newline at end of file