Add Quick Connect support (#1117)

This commit is contained in:
Michał Kopeć 2026-04-05 21:01:53 +02:00 committed by GitHub
commit 1d6517d197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 217 additions and 31 deletions

View file

@ -5,11 +5,12 @@ from __future__ import division, absolute_import, print_function, unicode_litera
import xbmc import xbmc
import xbmcaddon import xbmcaddon
import xbmcgui
from . import client from . import client
from .database import get_credentials, save_credentials from .database import get_credentials, save_credentials
from .dialogs import ServerConnect, UsersConnect, LoginManual, ServerManual 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 import Jellyfin
from .jellyfin.connection_manager import CONNECTION_STATE from .jellyfin.connection_manager import CONNECTION_STATE
from .helper.exceptions import HTTPException from .helper.exceptions import HTTPException
@ -230,9 +231,19 @@ class Connect(object):
] ]
if not users: if not users:
try: options = [translate(30540), translate(30618)] # "Manual login", "Quick Connect"
return self.login_manual() idx = xbmcgui.Dialog().select(translate(30612), options)
except RuntimeError: 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") raise RuntimeError("No user selected")
dialog = UsersConnect("script-jellyfin-connect-users.xml", *XML_PATH) dialog = UsersConnect("script-jellyfin-connect-users.xml", *XML_PATH)
@ -252,6 +263,12 @@ class Connect(object):
else: else:
return self.connect_manager.login(server, username) 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(): elif dialog.is_manual_login():
try: try:
return self.login_manual() return self.login_manual()
@ -262,6 +279,53 @@ class Connect(object):
return self.login() 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): def setup_login_manual(self):
"""Setup manual login by itself for default server.""" """Setup manual login by itself for default server."""
client = self.get_client() client = self.get_client()

View file

@ -19,6 +19,7 @@ ACTION_MOUSE_LEFT_CLICK = 100
LIST = 155 LIST = 155
MANUAL = 200 MANUAL = 200
CANCEL = 201 CANCEL = 201
QUICK_CONNECT = 202
################################################################################################## ##################################################################################################
@ -27,6 +28,7 @@ class UsersConnect(xbmcgui.WindowXMLDialog):
_user = None _user = None
_manual_login = False _manual_login = False
_quick_connect = False
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -47,6 +49,9 @@ class UsersConnect(xbmcgui.WindowXMLDialog):
def is_manual_login(self): def is_manual_login(self):
return self._manual_login return self._manual_login
def is_quick_connect(self):
return self._quick_connect
def onInit(self): def onInit(self):
self.list_ = self.getControl(LIST) self.list_ = self.getControl(LIST)
@ -95,6 +100,10 @@ class UsersConnect(xbmcgui.WindowXMLDialog):
self._manual_login = True self._manual_login = True
self.close() self.close()
elif control == QUICK_CONNECT:
self._quick_connect = True
self.close()
elif control == CANCEL: elif control == CANCEL:
self.close() self.close()

View file

@ -41,6 +41,11 @@ STRINGS = {
"cancel": 30606, "cancel": 30606,
"username": 30024, "username": 30024,
"password": 30602, "password": 30602,
"quick_connect": 30618,
"quick_connect_code": 30619,
"quick_connect_waiting": 30620,
"quick_connect_unavailable": 30621,
"quick_connect_timeout": 30622,
"gathering": 33021, "gathering": 33021,
"boxsets": 30185, "boxsets": 30185,
"movies": 30302, "movies": 30302,

View file

@ -521,3 +521,53 @@ class API(object):
except Exception as e: except Exception as e:
LOG.warning("Error fetching media segments: %s", e) LOG.warning("Error fetching media segments: %s", e)
return None 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 {}

View file

@ -100,30 +100,7 @@ class ConnectionManager(object):
LOG.info("Successfully logged in as %s" % (username)) LOG.info("Successfully logged in as %s" % (username))
# TODO Change when moving to database storage of server details # TODO Change when moving to database storage of server details
credentials = self.credentials.get() return self._save_auth_credentials(data)
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
def connect_to_address(self, address, options={}): 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): def _update_server_info(self, server, system_info):
if server is None or system_info is None: if server is None or system_info is None:

View file

@ -437,6 +437,26 @@ msgctxt "#30617"
msgid "Server or port cannot be empty" msgid "Server or port cannot be empty"
msgstr "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" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
msgstr "Welcome" msgstr "Welcome"

View file

@ -25,7 +25,7 @@
<centerleft>50%</centerleft> <centerleft>50%</centerleft>
<centertop>50%</centertop> <centertop>50%</centertop>
<width>920</width> <width>920</width>
<height>605</height> <height>670</height>
<control type="group"> <control type="group">
<top>-30</top> <top>-30</top>
<control type="image"> <control type="image">
@ -38,14 +38,14 @@
</control> </control>
<control type="image"> <control type="image">
<width>100%</width> <width>100%</width>
<height>605</height> <height>670</height>
<texture colordiffuse="ff222326" border="10">dialogs/dialog_back.png</texture> <texture colordiffuse="ff222326" border="10">dialogs/dialog_back.png</texture>
</control> </control>
<control type="group"> <control type="group">
<centerleft>50%</centerleft> <centerleft>50%</centerleft>
<top>10</top> <top>10</top>
<width>908</width> <width>908</width>
<height>580</height> <height>645</height>
<control type="grouplist" id="100"> <control type="grouplist" id="100">
<orientation>vertical</orientation> <orientation>vertical</orientation>
<itemgap>0</itemgap> <itemgap>0</itemgap>
@ -192,6 +192,24 @@
<onup>155</onup> <onup>155</onup>
<animation effect="slide" time="0" end="17,0" condition="true">Conditional</animation> <animation effect="slide" time="0" end="17,0" condition="true">Conditional</animation>
</control> </control>
<control type="button" id="202">
<label>[B]$ADDON[plugin.video.jellyfin 30618][/B]</label>
<width>874</width>
<height>65</height>
<font>font13</font>
<textcolor>ffe1e1e1</textcolor>
<focusedcolor>white</focusedcolor>
<selectedcolor>ffe1e1e1</selectedcolor>
<shadowcolor>66000000</shadowcolor>
<textoffsetx>20</textoffsetx>
<aligny>center</aligny>
<align>center</align>
<texturefocus border="10" colordiffuse="FF00A4DC">buttons/shadow_smallbutton.png</texturefocus>
<texturenofocus border="10" colordiffuse="ff464646">buttons/shadow_smallbutton.png</texturenofocus>
<pulseonselect>no</pulseonselect>
<onup>155</onup>
<animation effect="slide" time="0" end="17,0" condition="true">Conditional</animation>
</control>
<control type="button" id="201"> <control type="button" id="201">
<label>[B]$ADDON[plugin.video.jellyfin 30606][/B]</label> <label>[B]$ADDON[plugin.video.jellyfin 30606][/B]</label>
<width>874</width> <width>874</width>