mirror of
https://github.com/jellyfin/jellyfin-kodi.git
synced 2026-04-27 14:00:34 +00:00
Add Quick Connect support (#1117)
This commit is contained in:
parent
0bf363c65f
commit
1d6517d197
7 changed files with 217 additions and 31 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue