From 1d6517d1975968c4dfee2320517f463aaf2340d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kope=C4=87?= Date: Sun, 5 Apr 2026 21:01:53 +0200 Subject: [PATCH] Add Quick Connect support (#1117) --- jellyfin_kodi/connect.py | 72 +++++++++++++++++-- jellyfin_kodi/dialogs/usersconnect.py | 9 +++ jellyfin_kodi/helper/translate.py | 5 ++ jellyfin_kodi/jellyfin/api.py | 50 +++++++++++++ jellyfin_kodi/jellyfin/connection_manager.py | 68 +++++++++++------- .../resource.language.en_gb/strings.po | 20 ++++++ .../1080i/script-jellyfin-connect-users.xml | 24 ++++++- 7 files changed, 217 insertions(+), 31 deletions(-) diff --git a/jellyfin_kodi/connect.py b/jellyfin_kodi/connect.py index 6dac1450..46a480dc 100644 --- a/jellyfin_kodi/connect.py +++ b/jellyfin_kodi/connect.py @@ -5,11 +5,12 @@ from __future__ import division, absolute_import, print_function, unicode_litera import xbmc import xbmcaddon +import xbmcgui from . import client from .database import get_credentials, save_credentials from .dialogs import ServerConnect, UsersConnect, LoginManual, ServerManual -from .helper import settings, addon_id, event, api, window, LazyLogger +from .helper import settings, addon_id, event, api, window, LazyLogger, translate from .jellyfin import Jellyfin from .jellyfin.connection_manager import CONNECTION_STATE from .helper.exceptions import HTTPException @@ -230,9 +231,19 @@ class Connect(object): ] if not users: - try: - return self.login_manual() - except RuntimeError: + options = [translate(30540), translate(30618)] # "Manual login", "Quick Connect" + idx = xbmcgui.Dialog().select(translate(30612), options) + if idx == 1: # Quick Connect + try: + return self.login_quick_connect() + except RuntimeError: + raise RuntimeError("No user selected") + elif idx == 0: # Manual login + try: + return self.login_manual() + except RuntimeError: + raise RuntimeError("No user selected") + else: raise RuntimeError("No user selected") dialog = UsersConnect("script-jellyfin-connect-users.xml", *XML_PATH) @@ -252,6 +263,12 @@ class Connect(object): else: return self.connect_manager.login(server, username) + elif dialog.is_quick_connect(): + try: + return self.login_quick_connect() + except RuntimeError: + pass + elif dialog.is_manual_login(): try: return self.login_manual() @@ -262,6 +279,53 @@ class Connect(object): return self.login() + def login_quick_connect(self): + """Show Quick Connect code and wait for authorization via polling.""" + server = self.connect_manager.get_server_info(self.connect_manager.server_id)[ + "address" + ] + result = self.connect_manager.API.quick_connect_initiate(server) + + if not result or "Code" not in result: + xbmcgui.Dialog().ok(translate(30618), translate(30621)) + raise RuntimeError("Quick Connect not available") + + code = result["Code"] + secret = result["Secret"] + + progress = xbmcgui.DialogProgress() + progress.create( + translate(30618), + "%s [B]%s[/B]" % (translate(30619), code), + ) + + max_polls = 60 + for i in range(max_polls): + if progress.iscanceled(): + progress.close() + raise RuntimeError("Quick Connect cancelled") + + xbmc.sleep(5000) + + if progress.iscanceled(): + progress.close() + raise RuntimeError("Quick Connect cancelled") + + status = self.connect_manager.API.quick_connect_connect(server, secret) + + if status.get("Authenticated"): + progress.close() + auth = self.connect_manager.login_quick_connect(server, secret) + if auth: + return auth + raise RuntimeError("Quick Connect authentication failed") + + progress.update(int((i + 1) * 100 / max_polls)) + + progress.close() + xbmcgui.Dialog().ok(translate(30618), translate(30622)) + raise RuntimeError("Quick Connect timed out") + def setup_login_manual(self): """Setup manual login by itself for default server.""" client = self.get_client() diff --git a/jellyfin_kodi/dialogs/usersconnect.py b/jellyfin_kodi/dialogs/usersconnect.py index 6df02d71..acc01a95 100644 --- a/jellyfin_kodi/dialogs/usersconnect.py +++ b/jellyfin_kodi/dialogs/usersconnect.py @@ -19,6 +19,7 @@ ACTION_MOUSE_LEFT_CLICK = 100 LIST = 155 MANUAL = 200 CANCEL = 201 +QUICK_CONNECT = 202 ################################################################################################## @@ -27,6 +28,7 @@ class UsersConnect(xbmcgui.WindowXMLDialog): _user = None _manual_login = False + _quick_connect = False def __init__(self, *args, **kwargs): @@ -47,6 +49,9 @@ class UsersConnect(xbmcgui.WindowXMLDialog): def is_manual_login(self): return self._manual_login + def is_quick_connect(self): + return self._quick_connect + def onInit(self): self.list_ = self.getControl(LIST) @@ -95,6 +100,10 @@ class UsersConnect(xbmcgui.WindowXMLDialog): self._manual_login = True self.close() + elif control == QUICK_CONNECT: + self._quick_connect = True + self.close() + elif control == CANCEL: self.close() diff --git a/jellyfin_kodi/helper/translate.py b/jellyfin_kodi/helper/translate.py index cd6dd037..b920d97e 100644 --- a/jellyfin_kodi/helper/translate.py +++ b/jellyfin_kodi/helper/translate.py @@ -41,6 +41,11 @@ STRINGS = { "cancel": 30606, "username": 30024, "password": 30602, + "quick_connect": 30618, + "quick_connect_code": 30619, + "quick_connect_waiting": 30620, + "quick_connect_unavailable": 30621, + "quick_connect_timeout": 30622, "gathering": 33021, "boxsets": 30185, "movies": 30302, diff --git a/jellyfin_kodi/jellyfin/api.py b/jellyfin_kodi/jellyfin/api.py index 3914cc1f..88087f7a 100644 --- a/jellyfin_kodi/jellyfin/api.py +++ b/jellyfin_kodi/jellyfin/api.py @@ -521,3 +521,53 @@ class API(object): except Exception as e: LOG.warning("Error fetching media segments: %s", e) return None + + def quick_connect_initiate(self, server_url): + """Initiate Quick Connect and return {Secret, Code, Authenticated}.""" + headers = self.get_default_headers() + headers.update({"Content-type": "application/json"}) + try: + response = self.send_request( + server_url, "QuickConnect/Initiate", method="post", + timeout=10, headers=headers, data=json.dumps({}) + ) + if response.status_code == 200: + return response.json() + LOG.error("Quick Connect initiate failed with status %s", response.status_code) + return {} + except Exception as e: + LOG.error("Quick Connect initiate error: %s", e) + return {} + + def quick_connect_connect(self, server_url, secret): + """Check Quick Connect authorization status.""" + headers = self.get_default_headers() + try: + response = self.send_request( + server_url, "QuickConnect/Connect?Secret=%s" % quote(secret), + method="get", timeout=10, headers=headers + ) + if response.status_code == 200: + return response.json() + LOG.error("Quick Connect check failed with status %s", response.status_code) + return {} + except Exception as e: + LOG.error("Quick Connect check error: %s", e) + return {} + + def quick_connect_authenticate(self, server_url, secret): + """Exchange a Quick Connect secret for an auth token.""" + headers = self.get_default_headers() + headers.update({"Content-type": "application/json"}) + try: + response = self.send_request( + server_url, "Users/AuthenticateWithQuickConnect", method="post", + timeout=10, headers=headers, data=json.dumps({"Secret": secret}) + ) + if response.status_code == 200: + return response.json() + LOG.error("Quick Connect authenticate failed with status %s", response.status_code) + return {} + except Exception as e: + LOG.error("Quick Connect authenticate error: %s", e) + return {} diff --git a/jellyfin_kodi/jellyfin/connection_manager.py b/jellyfin_kodi/jellyfin/connection_manager.py index 304f7148..1a2992fa 100644 --- a/jellyfin_kodi/jellyfin/connection_manager.py +++ b/jellyfin_kodi/jellyfin/connection_manager.py @@ -100,30 +100,7 @@ class ConnectionManager(object): LOG.info("Successfully 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: - 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 self._save_auth_credentials(data) def connect_to_address(self, address, options={}): @@ -357,6 +334,49 @@ class ConnectionManager(object): ), } + def login_quick_connect(self, server_url, secret): + """Exchange a Quick Connect secret for an authenticated session.""" + if not server_url: + raise AttributeError("server url cannot be empty") + if not secret: + raise AttributeError("secret cannot be empty") + + data = self.API.quick_connect_authenticate(server_url, secret) + + if not data: + LOG.info("Quick Connect authentication failed") + return {} + + LOG.info("Successfully logged in via Quick Connect as %s", data["User"]["Name"]) + return self._save_auth_credentials(data) + + def _save_auth_credentials(self, data): + """Persist auth token and user info after a successful login.""" + 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: + return {} + + 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(found_server, info) + + self.credentials.set_credentials(credentials) + + return data + def _update_server_info(self, server, system_info): if server is None or system_info is None: diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index f0212f87..62ddb511 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -437,6 +437,26 @@ msgctxt "#30617" msgid "Server or port cannot be empty" msgstr "Server or port cannot be empty" +msgctxt "#30618" +msgid "Quick Connect" +msgstr "Quick Connect" + +msgctxt "#30619" +msgid "Enter this code in the Jellyfin app or web interface:" +msgstr "Enter this code in the Jellyfin app or web interface:" + +msgctxt "#30620" +msgid "Waiting for authorization..." +msgstr "Waiting for authorization..." + +msgctxt "#30621" +msgid "Quick Connect is not available on this server" +msgstr "Quick Connect is not available on this server" + +msgctxt "#30622" +msgid "Quick Connect timed out. Please try again." +msgstr "Quick Connect timed out. Please try again." + msgctxt "#33000" msgid "Welcome" msgstr "Welcome" diff --git a/resources/skins/default/1080i/script-jellyfin-connect-users.xml b/resources/skins/default/1080i/script-jellyfin-connect-users.xml index 7594cd5d..86574eca 100644 --- a/resources/skins/default/1080i/script-jellyfin-connect-users.xml +++ b/resources/skins/default/1080i/script-jellyfin-connect-users.xml @@ -25,7 +25,7 @@ 50% 50% 920 - 605 + 670 -30 @@ -38,14 +38,14 @@ 100% - 605 + 670 dialogs/dialog_back.png 50% 10 908 - 580 + 645 vertical 0 @@ -192,6 +192,24 @@ 155 Conditional + + + 874 + 65 + font13 + ffe1e1e1 + white + ffe1e1e1 + 66000000 + 20 + center + center + buttons/shadow_smallbutton.png + buttons/shadow_smallbutton.png + no + 155 + Conditional + 874