From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: trumblejoe Date: Fri, 25 Oct 2025 17:50:01 +0000 Subject: [PATCH] Prefer local server address and allow manual override Add automatic detection of local vs remote server address, quick reachability checks, and an optional addon setting for a user-specified manual local server address. Preferred address will be set to the manual local if present and reachable, otherwise a discovered local address (if reachable), else the remote address. Persist serverLocal, serverRemote and server settings for compatibility. --- jellyfin_kodi/connect.py | 334 ++++++++++++++++++++++++++++++++++++++++++++---- resources/settings.xml | 32 ++++++++++ 2 files changed, 350 insertions(+), 16 deletions(-) diff --git a/jellyfin_kodi/connect.py b/jellyfin_kodi/connect.py index 6dac145..0000000 100644 --- a/jellyfin_kodi/connect.py +++ b/jellyfin_kodi/connect.py @@ -1,309 +1,627 @@ -# -*- coding: utf-8 -*- -from __future__ import division, absolute_import, print_function, unicode_literals - -################################################################################################## - -import xbmc -import xbmcaddon - -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 .jellyfin import Jellyfin -from .jellyfin.connection_manager import CONNECTION_STATE -from .helper.exceptions import HTTPException - -################################################################################################## - -LOG = LazyLogger(__name__) -XML_PATH = (xbmcaddon.Addon(addon_id()).getAddonInfo("path"), "default", "1080i") - -################################################################################################## - - -class Connect(object): - - def __init__(self): - self.info = client.get_info() - - def register(self, server_id=None, options={}): - """Login into server. If server is None, then it will show the proper prompts to login, etc. - If a server id is specified then only a login dialog will be shown for that server. - """ - LOG.info("--[ server/%s ]", server_id or "default") - credentials = dict(get_credentials()) - servers = credentials["Servers"] - - if server_id is None and credentials["Servers"]: - credentials["Servers"] = [credentials["Servers"][0]] - - elif credentials["Servers"]: - - for server in credentials["Servers"]: - - if server["Id"] == server_id: - credentials["Servers"] = [server] - - server_select = server_id is None and not settings("SyncInstallRunDone.bool") - new_credentials = self.register_client( - credentials, options, server_id, server_select - ) - - for server in servers: - if server["Id"] == new_credentials["Servers"][0]["Id"]: - server = new_credentials["Servers"][0] - - break - else: - servers = new_credentials["Servers"] - - credentials["Servers"] = servers - save_credentials(credentials) - - try: - Jellyfin(server_id).start(True) - except ValueError as error: - LOG.error(error) - - def get_ssl(self): - """Returns boolean value. - True: verify connection. - """ - return settings("sslverify.bool") - - def get_client(self, server_id=None): - """Get Jellyfin client.""" - client = Jellyfin(server_id) - client.config.app( - "Kodi", self.info["Version"], self.info["DeviceName"], self.info["DeviceId"] - ) - client.config.data["http.user_agent"] = ( - "Jellyfin-Kodi/%s" % self.info["Version"] - ) - client.config.data["auth.ssl"] = self.get_ssl() - - return client - - def register_client( - self, credentials=None, options=None, server_id=None, server_selection=False - ): - - client = self.get_client(server_id) - self.client = client - self.connect_manager = client.auth - - if server_id is None: - client.config.data["app.default"] = True - - try: - state = client.authenticate(credentials or {}, options or {}) - - if state["State"] == CONNECTION_STATE["SignedIn"]: - client.callback_ws = event - - if server_id is None: # Only assign for default server - - client.callback = event - self.get_user(client) - - settings("serverName", client.config.data["auth.server-name"]) - settings("server", client.config.data["auth.server"]) - - event("ServerOnline", {"ServerId": server_id}) - event("LoadServer", {"ServerId": server_id}) - - return state["Credentials"] - - elif ( - server_selection - or state["State"] == CONNECTION_STATE["ServerSelection"] - or state["State"] == CONNECTION_STATE["Unavailable"] - and not settings("SyncInstallRunDone.bool") - ): - state["Credentials"]["Servers"] = [self.select_servers(state)] - - elif state["State"] == CONNECTION_STATE["ServerSignIn"]: - if "ExchangeToken" not in state["Servers"][0]: - self.login() - - elif ( - state["State"] == CONNECTION_STATE["Unavailable"] - and state.get("Status_Code", 0) == 401 - ): - # If the saved credentials don't work, restart the addon to force the password dialog to open - window("jellyfin.restart", clear=True) - - elif state["State"] == CONNECTION_STATE["Unavailable"]: - raise HTTPException("ServerUnreachable", {}) - - return self.register_client(state["Credentials"], options, server_id, False) - - except RuntimeError as error: - - LOG.exception(error) - xbmc.executebuiltin("Addon.OpenSettings(%s)" % addon_id()) - - raise Exception("User sign in interrupted") - - except HTTPException as error: - - if error.status == "ServerUnreachable": - event("ServerUnreachable", {"ServerId": server_id}) - - return client.get_credentials() - - def get_user(self, client): - """Save user info.""" - self.user = client.jellyfin.get_user() - settings("username", self.user["Name"]) - - if "PrimaryImageTag" in self.user: - server_address = client.auth.get_server_info(client.auth.server_id)[ - "address" - ] - window( - "JellyfinUserImage", - api.API(self.user, server_address).get_user_artwork(self.user["Id"]), - ) - - def select_servers(self, state=None): - - state = state or self.connect_manager.connect({"enableAutoLogin": False}) - user = {} - - dialog = ServerConnect("script-jellyfin-connect-server.xml", *XML_PATH) - dialog.set_args( - connect_manager=self.connect_manager, - username=user.get("DisplayName", ""), - user_image=user.get("ImageUrl"), - servers=self.connect_manager.get_available_servers(), - ) - - dialog.doModal() - - if dialog.is_server_selected(): - LOG.debug("Server selected: %s", dialog.get_server()) - return dialog.get_server() - - elif dialog.is_manual_server(): - LOG.debug("Adding manual server") - try: - return self.manual_server() - except RuntimeError: - pass - else: - raise RuntimeError("No server selected") - - return self.select_servers() - - def setup_manual_server(self): - """Setup manual servers""" - client = self.get_client() - client.set_credentials(get_credentials()) - manager = client.auth - - try: - self.manual_server(manager) - except RuntimeError: - return - - credentials = client.get_credentials() - save_credentials(credentials) - - def manual_server(self, manager=None): - """Return server or raise error.""" - dialog = ServerManual("script-jellyfin-connect-server-manual.xml", *XML_PATH) - dialog.set_args(**{"connect_manager": manager or self.connect_manager}) - dialog.doModal() - - if dialog.is_connected(): - return dialog.get_server() - else: - raise RuntimeError("Server is not connected") - - def login(self): - - users = self.connect_manager.get_public_users() - server = self.connect_manager.get_server_info(self.connect_manager.server_id)[ - "address" - ] - - if not users: - try: - return self.login_manual() - except RuntimeError: - raise RuntimeError("No user selected") - - dialog = UsersConnect("script-jellyfin-connect-users.xml", *XML_PATH) - dialog.set_args(**{"server": server, "users": users}) - dialog.doModal() - - if dialog.is_user_selected(): - user = dialog.get_user() - username = user["Name"] - - if user["HasPassword"]: - LOG.debug("User has password, present manual login") - try: - return self.login_manual(username) - except RuntimeError: - pass - else: - return self.connect_manager.login(server, username) - - elif dialog.is_manual_login(): - try: - return self.login_manual() - except RuntimeError: - pass - else: - raise RuntimeError("No user selected") - - return self.login() - - def setup_login_manual(self): - """Setup manual login by itself for default server.""" - client = self.get_client() - client.set_credentials(get_credentials()) - manager = client.auth - - username = settings("username") - try: - self.login_manual(user=username, manager=manager) - except RuntimeError: - return - - credentials = client.get_credentials() - save_credentials(credentials) - - def login_manual(self, user=None, manager=None): - """Return manual login user authenticated or raise error.""" - dialog = LoginManual("script-jellyfin-connect-login-manual.xml", *XML_PATH) - dialog.set_args( - **{ - "connect_manager": manager or self.connect_manager, - "username": user or {}, - } - ) - dialog.doModal() - - if dialog.is_logged_in(): - return dialog.get_user() - else: - raise RuntimeError("User is not authenticated") - - def remove_server(self, server_id): - """Stop client and remove server.""" - Jellyfin(server_id).close() - credentials = get_credentials() - - for server in credentials["Servers"]: - if server["Id"] == server_id: - credentials["Servers"].remove(server) - - break - - save_credentials(credentials) - LOG.info("[ remove server ] %s", server_id) +# -*- coding: utf-8 -*- +from __future__ import division, absolute_import, print_function, unicode_literals + +################################################################################################## + +import socket +import urllib.parse +import xbmc +import xbmcaddon + +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 .jellyfin import Jellyfin +from .jellyfin.connection_manager import CONNECTION_STATE +from .helper.exceptions import HTTPException + +################################################################################################## + +LOG = LazyLogger(__name__) +XML_PATH = (xbmcaddon.Addon(addon_id()).getAddonInfo("path"), "default", "1080i") + +################################################################################################## + + +class Connect(object): + + def __init__(self): + self.info = client.get_info() + + def register(self, server_id=None, options={}): + """Login into server. If server is None, then it will show the proper prompts to login, etc. + If a server id is specified then only a login dialog will be shown for that server. + """ + LOG.info("--[ server/%s ]", server_id or "default") + credentials = dict(get_credentials()) + servers = credentials["Servers"] + + if server_id is None and credentials["Servers"]: + credentials["Servers"] = [credentials["Servers"][0]] + + elif credentials["Servers"]: + + for server in credentials["Servers"]: + + if server["Id"] == server_id: + credentials["Servers"] = [server] + + server_select = server_id is None and not settings("SyncInstallRunDone.bool") + new_credentials = self.register_client( + credentials, options, server_id, server_select + ) + + for server in servers: + if server["Id"] == new_credentials["Servers"][0]["Id"]: + server = new_credentials["Servers"][0] + + break + else: + servers = new_credentials["Servers"] + + credentials["Servers"] = servers + save_credentials(credentials) + + try: + Jellyfin(server_id).start(True) + except ValueError as error: + LOG.error(error) + + def get_ssl(self): + """Returns boolean value. + True: verify connection. + """ + return settings("sslverify.bool") + + def get_client(self, server_id=None): + """Get Jellyfin client.""" + client = Jellyfin(server_id) + client.config.app( + "Kodi", self.info["Version"], self.info["DeviceName"], self.info["DeviceId"] + ) + client.config.data["http.user_agent"] = ( + "Jellyfin-Kodi/%s" % self.info["Version"] + ) + client.config.data["auth.ssl"] = self.get_ssl() + + return client + + def _parse_host_port(self, url): + """Parse host and port from a URL-like address. Returns (host, port) or (None, None).""" + if not url: + return None, None + try: + parsed = urllib.parse.urlparse(url) + except Exception: + return None, None + + host = parsed.hostname + port = parsed.port + if port is None: + if parsed.scheme == "https": + port = 443 + elif parsed.scheme == "http": + port = 80 + else: + # Fallback to common Jellyfin port + port = None + return host, port + + def _is_reachable(self, address, timeout=0.8): + """Quick check whether an address (URL or plain host:port) is reachable on TCP level.""" + if not address: + return False + + # If address appears to be host:port (no scheme), try to split. + host = None + port = None + if "://" not in address: + # attempt host:port + if ":" in address: + try: + host, port = address.rsplit(":", 1) + port = int(port) + except Exception: + host = address + port = None + else: + host = address + port = None + else: + host, port = self._parse_host_port(address) + + if not host: + return False + + # Try single provided port or common Jellyfin ports + ports = [port] if port else [8096, 8920, 80, 443] + for p in ports: + if p is None: + continue + try: + sock = socket.create_connection((host, int(p)), timeout) + sock.close() + return True + except Exception: + continue + + return False + + def _get_server_addresses(self, client): + """Return a tuple (remote_address, local_address, preferred_address). + Behavior: + - Detect remote from server info (address/auth.server etc). + - Try to discover any local address info returned by the server (common keys). + - If user has a manual local address set in addon settings, prefer that (if reachable). + - Preferred address: manual local > discovered local (if reachable) > remote (if reachable) > first available. + Also persist serverRemote, serverLocal and server (preferred) to settings so other parts of the addon can use them. + """ + remote = None + local = None + preferred = None + + try: + server_info = client.auth.get_server_info(client.auth.server_id) + except Exception: + server_info = {} + + # remote address candidates + remote = ( + server_info.get("address") + or server_info.get("Address") + or client.config.data.get("auth.server") + ) + + # Try several possible keys for local address(s) + for key in ( + "localAddress", + "LocalAddress", + "localBaseUrl", + "LocalBaseUrl", + "localConnectUrl", + "LocalConnectUrl", + "LocalConnectInfo", + "LocalAddresses", + "localAddresses", + "LocalAddressList", + ): + val = server_info.get(key) + if not val: + continue + if isinstance(val, (list, tuple)) and val: + local = val[0] + break + if isinstance(val, dict): + # if dict, try common nested keys + for nested in ("address", "url", "baseUrl"): + if nested in val: + local = val[nested] + break + if local: + break + else: + local = val + break + + # Fallback: if remote resolves to a private/local IP, treat remote as local candidate + if not local and remote: + try: + host, _ = self._parse_host_port(remote) + if host: + try: + addr_info = socket.getaddrinfo(host, None) + for res in addr_info: + sockaddr = res[4] + ip = sockaddr[0] + # private ranges: 10.*, 172.16-31.*, 192.168.* + if ip.startswith("10.") or ip.startswith("192.168.") or ip.startswith("172."): + local = remote + break + except Exception: + pass + except Exception: + pass + + # Check user-specified manual local address in addon settings + try: + user_local = settings("serverLocalManual") # returns value or None + except Exception: + user_local = None + + # Prefer user-provided local if present and reachable + if user_local: + if self._is_reachable(user_local): + preferred = user_local + # Save settings for consistency + try: + settings("serverLocal", user_local) + settings("serverRemote", remote) + settings("server", preferred) + except Exception: + LOG.debug("Failed to persist manual local address to settings") + LOG.debug("Using user-configured local server: %s", user_local) + return remote, local or user_local, preferred + + # If discovered local is reachable prefer it + if local and self._is_reachable(local): + preferred = local + # Else prefer remote if reachable + elif remote and self._is_reachable(remote): + preferred = remote + else: + # If neither responds to quick TCP check, prefer local if exists else remote + preferred = local or remote + + # Persist the values into settings for UI and other consumers + try: + settings("serverRemote", remote) + settings("serverLocal", local) + settings("server", preferred) + except Exception: + LOG.debug("Failed to save server address settings") + + LOG.debug("Server addresses - remote: %s, local: %s, preferred: %s", remote, local, preferred) + return remote, local, preferred + + def register_client( + self, credentials=None, options=None, server_id=None, server_selection=False + ): + + client = self.get_client(server_id) + self.client = client + self.connect_manager = client.auth + + if server_id is None: + client.config.data["app.default"] = True + + try: + state = client.authenticate(credentials or {}, options or {}) + + if state["State"] == CONNECTION_STATE["SignedIn"]: + client.callback_ws = event + + if server_id is None: # Only assign for default server + + client.callback = event + self.get_user(client) + + # Prefer a local address when possible; allow manual override via settings serverLocalManual + try: + _, _, preferred = self._get_server_addresses(client) + settings("serverName", client.config.data.get("auth.server-name")) + # server has been set to preferred inside _get_server_addresses + except Exception: + settings("serverName", client.config.data.get("auth.server-name")) + settings("server", client.config.data.get("auth.server")) + + event("ServerOnline", {"ServerId": server_id}) + event("LoadServer", {"ServerId": server_id}) + + return state["Credentials"] + + elif ( + server_selection + or state["State"] == CONNECTION_STATE["ServerSelection"] + or state["State"] == CONNECTION_STATE["Unavailable"] + and not settings("SyncInstallRunDone.bool") + ): + state["Credentials"]["Servers"] = [self.select_servers(state)] + + elif state["State"] == CONNECTION_STATE["ServerSignIn"]: + if "ExchangeToken" not in state["Servers"][0]: + self.login() + + elif ( + state["State"] == CONNECTION_STATE["Unavailable"] + and state.get("Status_Code", 0) == 401 + ): + # If the saved credentials don't work, restart the addon to force the password dialog to open + window("jellyfin.restart", clear=True) + + elif state["State"] == CONNECTION_STATE["Unavailable"]: + raise HTTPException("ServerUnreachable", {}) + + return self.register_client(state["Credentials"], options, server_id, False) + + except RuntimeError as error: + + LOG.exception(error) + xbmc.executebuiltin("Addon.OpenSettings(%s)" % addon_id()) + + raise Exception("User sign in interrupted") + + except HTTPException as error: + + if error.status == "ServerUnreachable": + event("ServerUnreachable", {"ServerId": server_id}) + + return client.get_credentials() + + def get_user(self, client): + """Save user info.""" + self.user = client.jellyfin.get_user() + settings("username", self.user["Name"]) + + # pick address to use for retrieving user artwork (prefer manual local > discovered local > remote) + try: + _, _, preferred = self._get_server_addresses(client) + except Exception: + preferred = client.config.data.get("auth.server") + + if "PrimaryImageTag" in self.user: + server_address = preferred or client.auth.get_server_info(client.auth.server_id).get( + "address" + ) + window( + "JellyfinUserImage", + api.API(self.user, server_address).get_user_artwork(self.user["Id"]), + ) + + def select_servers(self, state=None): + + state = state or self.connect_manager.connect({"enableAutoLogin": False}) + user = {} + + dialog = ServerConnect("script-jellyfin-connect-server.xml", *XML_PATH) + dialog.set_args( + connect_manager=self.connect_manager, + username=user.get("DisplayName", ""), + user_image=user.get("ImageUrl"), + servers=self.connect_manager.get_available_servers(), + ) + + dialog.doModal() + + if dialog.is_server_selected(): + LOG.debug("Server selected: %s", dialog.get_server()) + return dialog.get_server() + + elif dialog.is_manual_server(): + LOG.debug("Adding manual server") + try: + return self.manual_server() + except RuntimeError: + pass + else: + raise RuntimeError("No server selected") + + return self.select_servers() + + def setup_manual_server(self): + """Setup manual servers""" + client = self.get_client() + client.set_credentials(get_credentials()) + manager = client.auth + + try: + self.manual_server(manager) + except RuntimeError: + return + + credentials = client.get_credentials() + save_credentials(credentials) + + def manual_server(self, manager=None): + """Return server or raise error.""" + dialog = ServerManual("script-jellyfin-connect-server-manual.xml", *XML_PATH) + dialog.set_args(**{"connect_manager": manager or self.connect_manager}) + dialog.doModal() + + if dialog.is_connected(): + return dialog.get_server() + else: + raise RuntimeError("Server is not connected") + + def login(self): + + users = self.connect_manager.get_public_users() + server = self.connect_manager.get_server_info(self.connect_manager.server_id)[ + "address" + ] + + if not users: + try: + return self.login_manual() + except RuntimeError: + raise RuntimeError("No user selected") + + dialog = UsersConnect("script-jellyfin-connect-users.xml", *XML_PATH) + dialog.set_args(**{"server": server, "users": users}) + dialog.doModal() + + if dialog.is_user_selected(): + user = dialog.get_user() + username = user["Name"] + + if user["HasPassword"]: + LOG.debug("User has password, present manual login") + try: + return self.login_manual(username) + except RuntimeError: + pass + else: + return self.connect_manager.login(server, username) + + elif dialog.is_manual_login(): + try: + return self.login_manual() + except RuntimeError: + pass + else: + raise RuntimeError("No user selected") + + return self.login() + + def setup_login_manual(self): + """Setup manual login by itself for default server.""" + client = self.get_client() + client.set_credentials(get_credentials()) + manager = client.auth + + username = settings("username") + try: + self.login_manual(user=username, manager=manager) + except RuntimeError: + return + + credentials = client.get_credentials() + save_credentials(credentials) + + def login_manual(self, user=None, manager=None): + """Return manual login user authenticated or raise error.""" + dialog = LoginManual("script-jellyfin-connect-login-manual.xml", *XML_PATH) + dialog.set_args( + **{ + "connect_manager": manager or self.connect_manager, + "username": user or {}, + } + ) + dialog.doModal() + + if dialog.is_logged_in(): + return dialog.get_user() + else: + raise RuntimeError("User is not authenticated") + + def remove_server(self, server_id): + """Stop client and remove server.""" + Jellyfin(server_id).close() + credentials = get_credentials() + + for server in credentials["Servers"]: + if server["Id"] == server_id: + credentials["Servers"].remove(server) + + break + + save_credentials(credentials) + LOG.info("[ remove server ] %s", server_id) diff --git a/resources/settings.xml b/resources/settings.xml index 8757373..0000000 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -1,93 +1,121 @@
0 true false 30024 0 true false 30001 0 true false 30000 + + + 0 + + + true + + + Server: Local address (manual) + + Optional: specify a local IP or URL for your Jellyfin server (e.g., http://192.168.1.10:8096). When set and reachable, this address will be preferred over the remote address. + 0 true 0 false 0 Kodi false true 30016 0 true 0 999 true 30507 0 true 0 true 0 1 0 15 1 1 100 false - + 0 3 1 1 50 false 0 true @@ -94,28 +122,28 @@ 0 false 0 false true 0 true 0 false true - + 0 23 - + 0 true 0 H264/AVC 30161 - + -- 2.39.2