From e3db54940d7057db29653e174a81dd63ead2c291 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 31 May 2026 10:48:26 +0200 Subject: [PATCH] feat(jellyfin): add local server discovery close #1188 --- lib/i18n/bg.i18n.json | 2 + lib/i18n/da.i18n.json | 2 + lib/i18n/de.i18n.json | 2 + lib/i18n/en.i18n.json | 2 + lib/i18n/es.i18n.json | 2 + lib/i18n/fr.i18n.json | 2 + lib/i18n/it.i18n.json | 2 + lib/i18n/ja.i18n.json | 2 + lib/i18n/ko.i18n.json | 2 + lib/i18n/nb.i18n.json | 2 + lib/i18n/nl.i18n.json | 2 + lib/i18n/pl.i18n.json | 2 + lib/i18n/pt.i18n.json | 2 + lib/i18n/ru.i18n.json | 2 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_bg.g.dart | 4 + lib/i18n/strings_da.g.dart | 4 + lib/i18n/strings_de.g.dart | 4 + lib/i18n/strings_en.g.dart | 8 + lib/i18n/strings_es.g.dart | 4 + lib/i18n/strings_fr.g.dart | 4 + lib/i18n/strings_it.g.dart | 4 + lib/i18n/strings_ja.g.dart | 4 + lib/i18n/strings_ko.g.dart | 4 + lib/i18n/strings_nb.g.dart | 4 + lib/i18n/strings_nl.g.dart | 4 + lib/i18n/strings_pl.g.dart | 4 + lib/i18n/strings_pt.g.dart | 4 + lib/i18n/strings_ru.g.dart | 4 + lib/i18n/strings_sv.g.dart | 4 + lib/i18n/strings_zh.g.dart | 4 + lib/i18n/sv.i18n.json | 2 + lib/i18n/zh.i18n.json | 2 + lib/screens/settings/add_jellyfin_screen.dart | 165 ++++++++++++++++-- .../edit_jellyfin_connection_screen.dart | 7 +- lib/services/jellyfin_auth_service.dart | 10 +- lib/services/jellyfin_endpoint_discovery.dart | 121 ++++++++++++- .../jellyfin_lan_discovery_service.dart | 86 +++++++++ .../settings/add_jellyfin_screen_test.dart | 93 +++++++++- .../jellyfin_endpoint_discovery_test.dart | 105 +++++++++++ .../jellyfin_lan_discovery_service_test.dart | 35 ++++ 41 files changed, 698 insertions(+), 26 deletions(-) create mode 100644 lib/services/jellyfin_lan_discovery_service.dart create mode 100644 test/services/jellyfin_lan_discovery_service_test.dart diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 1b6f977a..76674693 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "URL адрес на сървъра", "serverUrls": "URL адреси на сървъра", "findServer": "Намери сървър", + "searchingLocalServers": "Търсене на локални Jellyfin сървъри...", + "localServers": "Локални Jellyfin сървъри", "username": "Потребителско име", "password": "Парола", "signIn": "Вход", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 025e1c4d..d470c590 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "Server-URL", "serverUrls": "Server-URL'er", "findServer": "Find server", + "searchingLocalServers": "Søger efter lokale Jellyfin-servere...", + "localServers": "Lokale Jellyfin-servere", "username": "Brugernavn", "password": "Adgangskode", "signIn": "Log ind", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 2c561f72..c7838d6a 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "Server-URL", "serverUrls": "Server-URLs", "findServer": "Server finden", + "searchingLocalServers": "Suche nach lokalen Jellyfin-Servern...", + "localServers": "Lokale Jellyfin-Server", "username": "Benutzername", "password": "Passwort", "signIn": "Anmelden", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 8b3a5184..80e2175f 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "Server URL", "serverUrls": "Server URLs", "findServer": "Find server", + "searchingLocalServers": "Looking for local Jellyfin servers...", + "localServers": "Local Jellyfin servers", "username": "Username", "password": "Password", "signIn": "Sign in", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 8813e7a5..2c3b2522 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "URL del servidor", "serverUrls": "URL del servidor", "findServer": "Buscar servidor", + "searchingLocalServers": "Buscando servidores Jellyfin locales...", + "localServers": "Servidores Jellyfin locales", "username": "Usuario", "password": "Contraseña", "signIn": "Iniciar sesión", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 7275db7f..c89f4c36 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "URL du serveur", "serverUrls": "URL du serveur", "findServer": "Rechercher un serveur", + "searchingLocalServers": "Recherche de serveurs Jellyfin locaux...", + "localServers": "Serveurs Jellyfin locaux", "username": "Nom d'utilisateur", "password": "Mot de passe", "signIn": "Se connecter", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 25bb646a..1a2e9792 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "URL del server", "serverUrls": "URL del server", "findServer": "Trova server", + "searchingLocalServers": "Ricerca server Jellyfin locali...", + "localServers": "Server Jellyfin locali", "username": "Nome utente", "password": "Password", "signIn": "Accedi", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 3ee40014..fe4b8884 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "サーバーURL", "serverUrls": "サーバーURL", "findServer": "サーバーを検索", + "searchingLocalServers": "ローカル Jellyfin サーバーを検索中...", + "localServers": "ローカル Jellyfin サーバー", "username": "ユーザー名", "password": "パスワード", "signIn": "サインイン", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 12505ed7..55f866ce 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "서버 URL", "serverUrls": "서버 URL", "findServer": "서버 찾기", + "searchingLocalServers": "로컬 Jellyfin 서버 검색 중...", + "localServers": "로컬 Jellyfin 서버", "username": "사용자 이름", "password": "비밀번호", "signIn": "로그인", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 10b25d8f..071d56b0 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "Server-URL", "serverUrls": "Server-URL-er", "findServer": "Finn server", + "searchingLocalServers": "Søker etter lokale Jellyfin-servere...", + "localServers": "Lokale Jellyfin-servere", "username": "Brukernavn", "password": "Passord", "signIn": "Logg på", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index bac132a4..7f43c8b7 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "Server-URL", "serverUrls": "Server-URL's", "findServer": "Server zoeken", + "searchingLocalServers": "Lokale Jellyfin-servers zoeken...", + "localServers": "Lokale Jellyfin-servers", "username": "Gebruikersnaam", "password": "Wachtwoord", "signIn": "Inloggen", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 0890f5dd..33e1a52a 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "URL serwera", "serverUrls": "URL-e serwera", "findServer": "Znajdź serwer", + "searchingLocalServers": "Szukanie lokalnych serwerów Jellyfin...", + "localServers": "Lokalne serwery Jellyfin", "username": "Nazwa użytkownika", "password": "Hasło", "signIn": "Zaloguj się", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index a6aab8b4..f7f244bd 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "URL do servidor", "serverUrls": "URLs do servidor", "findServer": "Encontrar servidor", + "searchingLocalServers": "A procurar servidores Jellyfin locais...", + "localServers": "Servidores Jellyfin locais", "username": "Usuário", "password": "Senha", "signIn": "Entrar", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 60df86c9..26b5ff0e 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "URL сервера", "serverUrls": "URL сервера", "findServer": "Найти сервер", + "searchingLocalServers": "Поиск локальных серверов Jellyfin...", + "localServers": "Локальные серверы Jellyfin", "username": "Имя пользователя", "password": "Пароль", "signIn": "Войти", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index e9cfc1b9..da6626e3 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 19040 (1190 per locale) +/// Strings: 19072 (1192 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 53814413..dedd062e 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerBg extends TranslationsAddServerEn { @override String get serverUrl => 'URL адрес на сървъра'; @override String get serverUrls => 'URL адреси на сървъра'; @override String get findServer => 'Намери сървър'; + @override String get searchingLocalServers => 'Търсене на локални Jellyfin сървъри...'; + @override String get localServers => 'Локални Jellyfin сървъри'; @override String get username => 'Потребителско име'; @override String get password => 'Парола'; @override String get signIn => 'Вход'; @@ -2985,6 +2987,8 @@ extension on TranslationsBg { 'addServer.serverUrl' => 'URL адрес на сървъра', 'addServer.serverUrls' => 'URL адреси на сървъра', 'addServer.findServer' => 'Намери сървър', + 'addServer.searchingLocalServers' => 'Търсене на локални Jellyfin сървъри...', + 'addServer.localServers' => 'Локални Jellyfin сървъри', 'addServer.username' => 'Потребителско име', 'addServer.password' => 'Парола', 'addServer.signIn' => 'Вход', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 7af178a2..88fee195 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerDa extends TranslationsAddServerEn { @override String get serverUrl => 'Server-URL'; @override String get serverUrls => 'Server-URL\'er'; @override String get findServer => 'Find server'; + @override String get searchingLocalServers => 'Søger efter lokale Jellyfin-servere...'; + @override String get localServers => 'Lokale Jellyfin-servere'; @override String get username => 'Brugernavn'; @override String get password => 'Adgangskode'; @override String get signIn => 'Log ind'; @@ -2985,6 +2987,8 @@ extension on TranslationsDa { 'addServer.serverUrl' => 'Server-URL', 'addServer.serverUrls' => 'Server-URL\'er', 'addServer.findServer' => 'Find server', + 'addServer.searchingLocalServers' => 'Søger efter lokale Jellyfin-servere...', + 'addServer.localServers' => 'Lokale Jellyfin-servere', 'addServer.username' => 'Brugernavn', 'addServer.password' => 'Adgangskode', 'addServer.signIn' => 'Log ind', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index e7ef4bb5..e84f5f3c 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerDe extends TranslationsAddServerEn { @override String get serverUrl => 'Server-URL'; @override String get serverUrls => 'Server-URLs'; @override String get findServer => 'Server finden'; + @override String get searchingLocalServers => 'Suche nach lokalen Jellyfin-Servern...'; + @override String get localServers => 'Lokale Jellyfin-Server'; @override String get username => 'Benutzername'; @override String get password => 'Passwort'; @override String get signIn => 'Anmelden'; @@ -2985,6 +2987,8 @@ extension on TranslationsDe { 'addServer.serverUrl' => 'Server-URL', 'addServer.serverUrls' => 'Server-URLs', 'addServer.findServer' => 'Server finden', + 'addServer.searchingLocalServers' => 'Suche nach lokalen Jellyfin-Servern...', + 'addServer.localServers' => 'Lokale Jellyfin-Server', 'addServer.username' => 'Benutzername', 'addServer.password' => 'Passwort', 'addServer.signIn' => 'Anmelden', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index a9c5a277..e8a03679 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -3560,6 +3560,12 @@ class TranslationsAddServerEn { /// en: 'Find server' String get findServer => 'Find server'; + /// en: 'Looking for local Jellyfin servers...' + String get searchingLocalServers => 'Looking for local Jellyfin servers...'; + + /// en: 'Local Jellyfin servers' + String get localServers => 'Local Jellyfin servers'; + /// en: 'Username' String get username => 'Username'; @@ -5371,6 +5377,8 @@ extension on Translations { 'addServer.serverUrl' => 'Server URL', 'addServer.serverUrls' => 'Server URLs', 'addServer.findServer' => 'Find server', + 'addServer.searchingLocalServers' => 'Looking for local Jellyfin servers...', + 'addServer.localServers' => 'Local Jellyfin servers', 'addServer.username' => 'Username', 'addServer.password' => 'Password', 'addServer.signIn' => 'Sign in', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 47bd4cc3..96ef6af2 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerEs extends TranslationsAddServerEn { @override String get serverUrl => 'URL del servidor'; @override String get serverUrls => 'URL del servidor'; @override String get findServer => 'Buscar servidor'; + @override String get searchingLocalServers => 'Buscando servidores Jellyfin locales...'; + @override String get localServers => 'Servidores Jellyfin locales'; @override String get username => 'Usuario'; @override String get password => 'Contraseña'; @override String get signIn => 'Iniciar sesión'; @@ -2985,6 +2987,8 @@ extension on TranslationsEs { 'addServer.serverUrl' => 'URL del servidor', 'addServer.serverUrls' => 'URL del servidor', 'addServer.findServer' => 'Buscar servidor', + 'addServer.searchingLocalServers' => 'Buscando servidores Jellyfin locales...', + 'addServer.localServers' => 'Servidores Jellyfin locales', 'addServer.username' => 'Usuario', 'addServer.password' => 'Contraseña', 'addServer.signIn' => 'Iniciar sesión', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index a2539edd..78ca4afa 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerFr extends TranslationsAddServerEn { @override String get serverUrl => 'URL du serveur'; @override String get serverUrls => 'URL du serveur'; @override String get findServer => 'Rechercher un serveur'; + @override String get searchingLocalServers => 'Recherche de serveurs Jellyfin locaux...'; + @override String get localServers => 'Serveurs Jellyfin locaux'; @override String get username => 'Nom d\'utilisateur'; @override String get password => 'Mot de passe'; @override String get signIn => 'Se connecter'; @@ -2985,6 +2987,8 @@ extension on TranslationsFr { 'addServer.serverUrl' => 'URL du serveur', 'addServer.serverUrls' => 'URL du serveur', 'addServer.findServer' => 'Rechercher un serveur', + 'addServer.searchingLocalServers' => 'Recherche de serveurs Jellyfin locaux...', + 'addServer.localServers' => 'Serveurs Jellyfin locaux', 'addServer.username' => 'Nom d\'utilisateur', 'addServer.password' => 'Mot de passe', 'addServer.signIn' => 'Se connecter', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 5750eb32..1eeb54af 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerIt extends TranslationsAddServerEn { @override String get serverUrl => 'URL del server'; @override String get serverUrls => 'URL del server'; @override String get findServer => 'Trova server'; + @override String get searchingLocalServers => 'Ricerca server Jellyfin locali...'; + @override String get localServers => 'Server Jellyfin locali'; @override String get username => 'Nome utente'; @override String get password => 'Password'; @override String get signIn => 'Accedi'; @@ -2985,6 +2987,8 @@ extension on TranslationsIt { 'addServer.serverUrl' => 'URL del server', 'addServer.serverUrls' => 'URL del server', 'addServer.findServer' => 'Trova server', + 'addServer.searchingLocalServers' => 'Ricerca server Jellyfin locali...', + 'addServer.localServers' => 'Server Jellyfin locali', 'addServer.username' => 'Nome utente', 'addServer.password' => 'Password', 'addServer.signIn' => 'Accedi', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 9c1e3f82..889b81bf 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerJa extends TranslationsAddServerEn { @override String get serverUrl => 'サーバーURL'; @override String get serverUrls => 'サーバーURL'; @override String get findServer => 'サーバーを検索'; + @override String get searchingLocalServers => 'ローカル Jellyfin サーバーを検索中...'; + @override String get localServers => 'ローカル Jellyfin サーバー'; @override String get username => 'ユーザー名'; @override String get password => 'パスワード'; @override String get signIn => 'サインイン'; @@ -2985,6 +2987,8 @@ extension on TranslationsJa { 'addServer.serverUrl' => 'サーバーURL', 'addServer.serverUrls' => 'サーバーURL', 'addServer.findServer' => 'サーバーを検索', + 'addServer.searchingLocalServers' => 'ローカル Jellyfin サーバーを検索中...', + 'addServer.localServers' => 'ローカル Jellyfin サーバー', 'addServer.username' => 'ユーザー名', 'addServer.password' => 'パスワード', 'addServer.signIn' => 'サインイン', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index b5509460..6bbcd6d0 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerKo extends TranslationsAddServerEn { @override String get serverUrl => '서버 URL'; @override String get serverUrls => '서버 URL'; @override String get findServer => '서버 찾기'; + @override String get searchingLocalServers => '로컬 Jellyfin 서버 검색 중...'; + @override String get localServers => '로컬 Jellyfin 서버'; @override String get username => '사용자 이름'; @override String get password => '비밀번호'; @override String get signIn => '로그인'; @@ -2985,6 +2987,8 @@ extension on TranslationsKo { 'addServer.serverUrl' => '서버 URL', 'addServer.serverUrls' => '서버 URL', 'addServer.findServer' => '서버 찾기', + 'addServer.searchingLocalServers' => '로컬 Jellyfin 서버 검색 중...', + 'addServer.localServers' => '로컬 Jellyfin 서버', 'addServer.username' => '사용자 이름', 'addServer.password' => '비밀번호', 'addServer.signIn' => '로그인', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index c59e2c50..10406a2e 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerNb extends TranslationsAddServerEn { @override String get serverUrl => 'Server-URL'; @override String get serverUrls => 'Server-URL-er'; @override String get findServer => 'Finn server'; + @override String get searchingLocalServers => 'Søker etter lokale Jellyfin-servere...'; + @override String get localServers => 'Lokale Jellyfin-servere'; @override String get username => 'Brukernavn'; @override String get password => 'Passord'; @override String get signIn => 'Logg på'; @@ -2985,6 +2987,8 @@ extension on TranslationsNb { 'addServer.serverUrl' => 'Server-URL', 'addServer.serverUrls' => 'Server-URL-er', 'addServer.findServer' => 'Finn server', + 'addServer.searchingLocalServers' => 'Søker etter lokale Jellyfin-servere...', + 'addServer.localServers' => 'Lokale Jellyfin-servere', 'addServer.username' => 'Brukernavn', 'addServer.password' => 'Passord', 'addServer.signIn' => 'Logg på', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index f29c5562..218a775c 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerNl extends TranslationsAddServerEn { @override String get serverUrl => 'Server-URL'; @override String get serverUrls => 'Server-URL\'s'; @override String get findServer => 'Server zoeken'; + @override String get searchingLocalServers => 'Lokale Jellyfin-servers zoeken...'; + @override String get localServers => 'Lokale Jellyfin-servers'; @override String get username => 'Gebruikersnaam'; @override String get password => 'Wachtwoord'; @override String get signIn => 'Inloggen'; @@ -2985,6 +2987,8 @@ extension on TranslationsNl { 'addServer.serverUrl' => 'Server-URL', 'addServer.serverUrls' => 'Server-URL\'s', 'addServer.findServer' => 'Server zoeken', + 'addServer.searchingLocalServers' => 'Lokale Jellyfin-servers zoeken...', + 'addServer.localServers' => 'Lokale Jellyfin-servers', 'addServer.username' => 'Gebruikersnaam', 'addServer.password' => 'Wachtwoord', 'addServer.signIn' => 'Inloggen', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index ccfe4cf6..de5fb5fb 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerPl extends TranslationsAddServerEn { @override String get serverUrl => 'URL serwera'; @override String get serverUrls => 'URL-e serwera'; @override String get findServer => 'Znajdź serwer'; + @override String get searchingLocalServers => 'Szukanie lokalnych serwerów Jellyfin...'; + @override String get localServers => 'Lokalne serwery Jellyfin'; @override String get username => 'Nazwa użytkownika'; @override String get password => 'Hasło'; @override String get signIn => 'Zaloguj się'; @@ -2985,6 +2987,8 @@ extension on TranslationsPl { 'addServer.serverUrl' => 'URL serwera', 'addServer.serverUrls' => 'URL-e serwera', 'addServer.findServer' => 'Znajdź serwer', + 'addServer.searchingLocalServers' => 'Szukanie lokalnych serwerów Jellyfin...', + 'addServer.localServers' => 'Lokalne serwery Jellyfin', 'addServer.username' => 'Nazwa użytkownika', 'addServer.password' => 'Hasło', 'addServer.signIn' => 'Zaloguj się', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index ef928c52..df4356e1 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerPt extends TranslationsAddServerEn { @override String get serverUrl => 'URL do servidor'; @override String get serverUrls => 'URLs do servidor'; @override String get findServer => 'Encontrar servidor'; + @override String get searchingLocalServers => 'A procurar servidores Jellyfin locais...'; + @override String get localServers => 'Servidores Jellyfin locais'; @override String get username => 'Usuário'; @override String get password => 'Senha'; @override String get signIn => 'Entrar'; @@ -2985,6 +2987,8 @@ extension on TranslationsPt { 'addServer.serverUrl' => 'URL do servidor', 'addServer.serverUrls' => 'URLs do servidor', 'addServer.findServer' => 'Encontrar servidor', + 'addServer.searchingLocalServers' => 'A procurar servidores Jellyfin locais...', + 'addServer.localServers' => 'Servidores Jellyfin locais', 'addServer.username' => 'Usuário', 'addServer.password' => 'Senha', 'addServer.signIn' => 'Entrar', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 84ca1447..2c7a647a 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerRu extends TranslationsAddServerEn { @override String get serverUrl => 'URL сервера'; @override String get serverUrls => 'URL сервера'; @override String get findServer => 'Найти сервер'; + @override String get searchingLocalServers => 'Поиск локальных серверов Jellyfin...'; + @override String get localServers => 'Локальные серверы Jellyfin'; @override String get username => 'Имя пользователя'; @override String get password => 'Пароль'; @override String get signIn => 'Войти'; @@ -2985,6 +2987,8 @@ extension on TranslationsRu { 'addServer.serverUrl' => 'URL сервера', 'addServer.serverUrls' => 'URL сервера', 'addServer.findServer' => 'Найти сервер', + 'addServer.searchingLocalServers' => 'Поиск локальных серверов Jellyfin...', + 'addServer.localServers' => 'Локальные серверы Jellyfin', 'addServer.username' => 'Имя пользователя', 'addServer.password' => 'Пароль', 'addServer.signIn' => 'Войти', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index eaac3ee3..1f8da7b8 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerSv extends TranslationsAddServerEn { @override String get serverUrl => 'Server-URL'; @override String get serverUrls => 'Server-URL:er'; @override String get findServer => 'Hitta server'; + @override String get searchingLocalServers => 'Söker efter lokala Jellyfin-servrar...'; + @override String get localServers => 'Lokala Jellyfin-servrar'; @override String get username => 'Användarnamn'; @override String get password => 'Lösenord'; @override String get signIn => 'Logga in'; @@ -2985,6 +2987,8 @@ extension on TranslationsSv { 'addServer.serverUrl' => 'Server-URL', 'addServer.serverUrls' => 'Server-URL:er', 'addServer.findServer' => 'Hitta server', + 'addServer.searchingLocalServers' => 'Söker efter lokala Jellyfin-servrar...', + 'addServer.localServers' => 'Lokala Jellyfin-servrar', 'addServer.username' => 'Användarnamn', 'addServer.password' => 'Lösenord', 'addServer.signIn' => 'Logga in', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index f17726d3..de42b7ab 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -1519,6 +1519,8 @@ class _TranslationsAddServerZh extends TranslationsAddServerEn { @override String get serverUrl => '服务器 URL'; @override String get serverUrls => '服务器 URL'; @override String get findServer => '查找服务器'; + @override String get searchingLocalServers => '正在查找本地 Jellyfin 服务器...'; + @override String get localServers => '本地 Jellyfin 服务器'; @override String get username => '用户名'; @override String get password => '密码'; @override String get signIn => '登录'; @@ -2985,6 +2987,8 @@ extension on TranslationsZh { 'addServer.serverUrl' => '服务器 URL', 'addServer.serverUrls' => '服务器 URL', 'addServer.findServer' => '查找服务器', + 'addServer.searchingLocalServers' => '正在查找本地 Jellyfin 服务器...', + 'addServer.localServers' => '本地 Jellyfin 服务器', 'addServer.username' => '用户名', 'addServer.password' => '密码', 'addServer.signIn' => '登录', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index cc8f6e26..d4869539 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "Server-URL", "serverUrls": "Server-URL:er", "findServer": "Hitta server", + "searchingLocalServers": "Söker efter lokala Jellyfin-servrar...", + "localServers": "Lokala Jellyfin-servrar", "username": "Användarnamn", "password": "Lösenord", "signIn": "Logga in", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 951ea3f5..92f67045 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1276,6 +1276,8 @@ "serverUrl": "服务器 URL", "serverUrls": "服务器 URL", "findServer": "查找服务器", + "searchingLocalServers": "正在查找本地 Jellyfin 服务器...", + "localServers": "本地 Jellyfin 服务器", "username": "用户名", "password": "密码", "signIn": "登录", diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 2746fd99..d5901788 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -20,6 +20,7 @@ import '../../profiles/profile_connection.dart'; import '../../profiles/profile_registry.dart'; import '../../services/jellyfin_auth_service.dart'; import '../../services/jellyfin_endpoint_discovery.dart'; +import '../../services/jellyfin_lan_discovery_service.dart'; import '../../services/storage_service.dart'; import '../../utils/app_logger.dart'; import '../../utils/platform_detector.dart'; @@ -62,12 +63,15 @@ class AddJellyfinScreen extends StatefulWidget { /// profile (typical for the global Connections screen entry point). final Profile? targetProfile; final FutureOr Function()? _authServiceFactory; + final FutureOr> Function()? _localDiscoveryFactory; const AddJellyfinScreen({ super.key, this.targetProfile, @visibleForTesting FutureOr Function()? authServiceFactory, - }) : _authServiceFactory = authServiceFactory; + @visibleForTesting FutureOr> Function()? localDiscoveryFactory, + }) : _authServiceFactory = authServiceFactory, + _localDiscoveryFactory = localDiscoveryFactory; @override State createState() => _AddJellyfinScreenState(); @@ -87,14 +91,24 @@ class _AddJellyfinScreenState extends State with AsyncFormSta final _signInFocus = FocusNode(debugLabel: 'AddJellyfin:SignIn'); final _quickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:QuickConnect'); final _cancelQuickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:CancelQuickConnect'); + final _discoveredServerFocusNodes = {}; final _formKey = GlobalKey(); JellyfinServerInfo? _serverInfo; JellyfinEndpointRaceResult? _serverEndpoint; + List _localServers = const []; + bool _isDiscoveringLocalServers = true; bool _quickConnectEnabled = false; JellyfinQuickConnectInitiation? _qcInitiation; bool _qcCancelled = false; int _qcAttemptId = 0; + int _localDiscoveryAttemptId = 0; + + @override + void initState() { + super.initState(); + unawaited(_discoverLocalServers()); + } @override void dispose() { @@ -109,19 +123,83 @@ class _AddJellyfinScreenState extends State with AsyncFormSta _signInFocus.dispose(); _quickConnectFocus.dispose(); _cancelQuickConnectFocus.dispose(); + for (final node in _discoveredServerFocusNodes.values) { + node.dispose(); + } super.dispose(); } + Future _discoverLocalServers() async { + final attemptId = ++_localDiscoveryAttemptId; + try { + final factory = widget._localDiscoveryFactory; + final servers = factory != null + ? await factory() + : await JellyfinLanDiscoveryService().discover(timeout: const Duration(milliseconds: 1300)); + if (!mounted || attemptId != _localDiscoveryAttemptId) return; + final focusFirstServer = + servers.isNotEmpty && _urlController.text.trim().isEmpty && _serverInfo == null && PlatformDetector.isTV(); + setState(() { + _localServers = servers; + _isDiscoveringLocalServers = false; + _syncDiscoveredServerFocusNodes(servers); + }); + if (focusFirstServer) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _discoveredServerFocusNodes[servers.first.id]?.requestFocus(); + }); + } + } catch (e, st) { + appLogger.w('Add Jellyfin local discovery failed', error: e, stackTrace: st); + if (!mounted || attemptId != _localDiscoveryAttemptId) return; + setState(() => _isDiscoveringLocalServers = false); + } + } + + void _syncDiscoveredServerFocusNodes(List servers) { + final ids = servers.map((server) => server.id).toSet(); + final removed = _discoveredServerFocusNodes.keys.where((id) => !ids.contains(id)).toList(growable: false); + for (final id in removed) { + _discoveredServerFocusNodes.remove(id)?.dispose(); + } + for (final server in servers) { + _discoveredServerFocusNodes.putIfAbsent( + server.id, + () => FocusNode(debugLabel: 'AddJellyfin:Discovered:${server.id}'), + ); + } + } + + void _clearResolvedServer() { + _serverEndpoint = null; + _serverInfo = null; + _quickConnectEnabled = false; + } + + Future _useDiscoveredServer(DiscoveredJellyfinServer server) async { + if (busy) return; + setState(() { + _urlController.text = server.address; + _clearResolvedServer(); + }); + await _probe(); + } + Future _probe() async { - final urls = _enteredUrls(); - if (urls.isEmpty) { + final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls()); + if (input.probeBaseUrls.isEmpty) { setErrorText(t.addServer.enterJellyfinUrlError); return; } await runAsync( () async { final auth = await _buildAuthService(); - final endpoint = await auth.raceEndpoints(urls); + final endpoint = await auth.raceEndpoints( + input.probeBaseUrls, + baseUrlsToPersist: input.explicitBaseUrls, + baseUrlsToValidate: input.explicitBaseUrls, + ); final qcEnabled = await auth.isQuickConnectEnabled(endpoint.activeBaseUrl); if (!mounted) return; setState(() { @@ -241,7 +319,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta List _enteredUrls() { return _urlController.text .split(RegExp(r'[\n,]+')) - .map(JellyfinEndpointDiscovery.normalizeBaseUrl) + .map((url) => url.trim()) .where((url) => url.isNotEmpty) .toList(growable: false); } @@ -367,6 +445,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta } return [ Text(t.addServer.jellyfinUrlsIntro, style: theme.textTheme.bodyMedium), + if (_serverInfo == null) ..._buildLocalDiscoverySection(theme), const SizedBox(height: 16), FocusableTextFormField( controller: _urlController, @@ -381,9 +460,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta onChanged: (_) { if (_serverInfo == null && _serverEndpoint == null && !_quickConnectEnabled) return; setState(() { - _serverEndpoint = null; - _serverInfo = null; - _quickConnectEnabled = false; + _clearResolvedServer(); }); }, onNavigateDown: _serverInfo == null @@ -502,9 +579,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta onPressed: busy ? null : () => setState(() { - _serverEndpoint = null; - _serverInfo = null; - _quickConnectEnabled = false; + _clearResolvedServer(); }), child: Text(t.addServer.change), ), @@ -513,6 +588,74 @@ class _AddJellyfinScreenState extends State with AsyncFormSta ); } + List _buildLocalDiscoverySection(ThemeData theme) { + if (_isDiscoveringLocalServers) { + return [ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const LoadingIndicatorBox(size: 20), + const SizedBox(width: 12), + Expanded(child: Text(t.addServer.searchingLocalServers, style: theme.textTheme.bodyMedium)), + ], + ), + ), + ]; + } + + if (_localServers.isEmpty) return const []; + return [ + const SizedBox(height: 16), + Text(t.addServer.localServers, style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + for (final server in _localServers) ...[ + FocusableButton( + focusNode: _discoveredServerFocusNodes[server.id], + useBackgroundFocus: true, + onPressed: busy ? null : () => unawaited(_useDiscoveredServer(server)), + child: OutlinedButton( + onPressed: busy ? null : () => unawaited(_useDiscoveredServer(server)), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const AppIcon(Symbols.dns_rounded, fill: 1), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(server.name), + Text( + server.address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + const AppIcon(Symbols.chevron_right_rounded), + ], + ), + ), + ), + ), + const SizedBox(height: 8), + ], + ]; + } + List _buildQuickConnectPanel(ThemeData theme) { final code = _qcInitiation!.code; return [ diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index cee0b9fc..2ddbe161 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -43,10 +43,13 @@ class _EditJellyfinConnectionScreenState extends State( () async { + final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls()); final endpoint = await JellyfinEndpointDiscovery().raceEndpoints( - _enteredUrls(), + input.probeBaseUrls, preferredUrl: widget.connection.baseUrl, expectedMachineId: widget.connection.serverMachineId, + baseUrlsToPersist: input.explicitBaseUrls, + baseUrlsToValidate: input.explicitBaseUrls, ); final updated = widget.connection.copyWith( baseUrl: endpoint.activeBaseUrl, @@ -69,7 +72,7 @@ class _EditJellyfinConnectionScreenState extends State _enteredUrls() { return _urlsController.text .split(RegExp(r'[\n,]+')) - .map(JellyfinEndpointDiscovery.normalizeBaseUrl) + .map((url) => url.trim()) .where((url) => url.isNotEmpty) .toList(growable: false); } diff --git a/lib/services/jellyfin_auth_service.dart b/lib/services/jellyfin_auth_service.dart index 0b14f6b5..5d2b6a20 100644 --- a/lib/services/jellyfin_auth_service.dart +++ b/lib/services/jellyfin_auth_service.dart @@ -75,8 +75,16 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { Iterable baseUrls, { String? preferredUrl, String? expectedMachineId, + Iterable? baseUrlsToPersist, + Iterable? baseUrlsToValidate, }) { - return _endpointDiscovery.raceEndpoints(baseUrls, preferredUrl: preferredUrl, expectedMachineId: expectedMachineId); + return _endpointDiscovery.raceEndpoints( + baseUrls, + preferredUrl: preferredUrl, + expectedMachineId: expectedMachineId, + baseUrlsToPersist: baseUrlsToPersist, + baseUrlsToValidate: baseUrlsToValidate, + ); } /// Authenticate against [baseUrl] with [username]/[password] and return a diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index d6a2b18b..410ba769 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -46,7 +46,16 @@ class JellyfinEndpointCandidate { const JellyfinEndpointCandidate({required this.url, required this.index}); } +class JellyfinEndpointUserInputCandidates { + final List probeBaseUrls; + final List explicitBaseUrls; + + const JellyfinEndpointUserInputCandidates({required this.probeBaseUrls, required this.explicitBaseUrls}); +} + class JellyfinEndpointDiscovery { + static const int defaultPort = 8096; + JellyfinEndpointDiscovery({http.Client Function()? testHttpClientFactory}) : _testHttpClientFactory = testHttpClientFactory; @@ -91,12 +100,18 @@ class JellyfinEndpointDiscovery { Iterable baseUrls, { String? preferredUrl, String? expectedMachineId, + Iterable? baseUrlsToPersist, + Iterable? baseUrlsToValidate, }) async { final urls = normalizeBaseUrls(baseUrls); if (urls.isEmpty) { throw MediaServerUrlException('Enter at least one Jellyfin server URL'); } + final persistUrls = baseUrlsToPersist == null ? urls : normalizeBaseUrls(baseUrlsToPersist); + final validateUrls = baseUrlsToValidate == null ? urls : normalizeBaseUrls(baseUrlsToValidate); + final validateUrlSet = validateUrls.toSet(); + final preferred = preferredUrl == null || preferredUrl.trim().isEmpty ? null : normalizeBaseUrl(preferredUrl); final candidates = [for (var i = 0; i < urls.length; i++) JellyfinEndpointCandidate(url: urls[i], index: i)]; @@ -122,16 +137,38 @@ class JellyfinEndpointDiscovery { } final selected = bestSelection ?? firstSelection; - final selectedInfo = selected?.result.serverInfo; - if (selected == null || selectedInfo == null) { + if (selected == null || selected.result.serverInfo == null) { throw MediaServerUrlException('No reachable Jellyfin server found'); } final Map successfulResults = bestSelection?.successfulResults ?? firstSelection?.successfulResults ?? const {}; - final expected = expectedMachineId?.trim().isNotEmpty == true ? expectedMachineId!.trim() : selectedInfo.machineId; - for (final result in successfulResults.values) { - final info = result.serverInfo; + var selectedCandidate = selected.candidate; + var selectedResult = selected.result; + + final expectedMachineIdTrimmed = expectedMachineId?.trim(); + final hasExpectedMachineId = expectedMachineIdTrimmed?.isNotEmpty == true; + if (hasExpectedMachineId) { + final matchingResults = Map.fromEntries( + successfulResults.entries.where((entry) => entry.value.serverInfo?.machineId == expectedMachineIdTrimmed), + ); + final matchingCandidate = _selectLowestLatencyCandidate(matchingResults); + final matchingResult = matchingCandidate == null ? null : matchingResults[matchingCandidate]; + if (matchingCandidate != null && matchingResult != null) { + selectedCandidate = matchingCandidate; + selectedResult = matchingResult; + } + } + + final selectedInfo = selectedResult.serverInfo; + if (selectedInfo == null) { + throw MediaServerUrlException('No reachable Jellyfin server found'); + } + + final expected = hasExpectedMachineId ? expectedMachineIdTrimmed! : selectedInfo.machineId; + for (final entry in successfulResults.entries) { + if (!validateUrlSet.contains(entry.key.url)) continue; + final info = entry.value.serverInfo; if (info != null && info.machineId != expected) { throw MediaServerUrlException('The URLs point to different Jellyfin servers'); } @@ -142,8 +179,8 @@ class JellyfinEndpointDiscovery { } return JellyfinEndpointRaceResult( - activeBaseUrl: selected.candidate.url, - baseUrls: _activeFirst(selected.candidate.url, urls), + activeBaseUrl: selectedCandidate.url, + baseUrls: _activeFirst(selectedCandidate.url, persistUrls), serverInfo: selectedInfo, ); } @@ -188,8 +225,76 @@ class JellyfinEndpointDiscovery { return entries.first.key; } + /// Normalizes a concrete Jellyfin base URL without inventing a scheme or port. static String normalizeBaseUrl(String input) => stripTrailingSlash(input); + /// Expands a user-typed add/edit form entry into temporary probe candidates. + /// These guesses are for discovery only; failed guesses should not be stored. + static List expandInputToBaseUrls(String input) { + final trimmed = stripTrailingSlash(input); + if (trimmed.isEmpty) return const []; + if (_hasScheme(trimmed)) return [trimmed]; + + final parsed = Uri.tryParse('http://$trimmed'); + if (parsed == null || parsed.host.isEmpty) return [trimmed]; + + final result = []; + final seen = {}; + void add(Uri uri) { + final normalized = stripTrailingSlash(uri.replace(query: null, fragment: null).toString()); + if (normalized.isEmpty || !seen.add(normalized)) return; + result.add(normalized); + } + + if (parsed.hasPort) { + add(parsed.replace(scheme: 'http')); + add(parsed.replace(scheme: 'https')); + } else { + add(parsed.replace(scheme: 'http', port: defaultPort)); + add(parsed.replace(scheme: 'https')); + add(parsed.replace(scheme: 'https', port: defaultPort)); + add(parsed.replace(scheme: 'http')); + } + return List.unmodifiable(result); + } + + static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable input) { + final probeBaseUrls = []; + final explicitBaseUrls = []; + final seenProbe = {}; + final seenExplicit = {}; + + void addProbe(String url) { + final normalized = normalizeBaseUrl(url); + if (normalized.isEmpty || !seenProbe.add(normalized)) return; + probeBaseUrls.add(normalized); + } + + void addExplicit(String url) { + final normalized = normalizeBaseUrl(url); + if (normalized.isEmpty || !seenExplicit.add(normalized)) return; + explicitBaseUrls.add(normalized); + } + + for (final raw in input) { + final normalized = normalizeBaseUrl(raw); + if (normalized.isEmpty) continue; + if (_hasScheme(normalized)) { + addProbe(normalized); + addExplicit(normalized); + } else { + for (final candidate in expandInputToBaseUrls(normalized)) { + addProbe(candidate); + } + } + } + + return JellyfinEndpointUserInputCandidates( + probeBaseUrls: List.unmodifiable(probeBaseUrls), + explicitBaseUrls: List.unmodifiable(explicitBaseUrls), + ); + } + static List normalizeBaseUrls(Iterable input) { final result = []; final seen = {}; @@ -201,6 +306,8 @@ class JellyfinEndpointDiscovery { return List.unmodifiable(result); } + static bool _hasScheme(String input) => RegExp(r'^[a-zA-Z][a-zA-Z\d+.-]*://').hasMatch(input); + static List _activeFirst(String activeBaseUrl, List urls) { final result = []; final seen = {}; diff --git a/lib/services/jellyfin_lan_discovery_service.dart b/lib/services/jellyfin_lan_discovery_service.dart new file mode 100644 index 00000000..d8415618 --- /dev/null +++ b/lib/services/jellyfin_lan_discovery_service.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../utils/app_logger.dart'; +import 'jellyfin_endpoint_discovery.dart'; + +class DiscoveredJellyfinServer { + final String address; + final String id; + final String name; + + DiscoveredJellyfinServer({required this.address, required this.id, required this.name}); +} + +class JellyfinLanDiscoveryService { + static const int discoveryPort = 7359; + static const String discoveryMessage = 'who is JellyfinServer?'; + + Future> discover({ + Duration timeout = const Duration(seconds: 2), + InternetAddress? broadcastAddress, + }) async { + RawDatagramSocket? socket; + StreamSubscription? subscription; + final discovered = {}; + try { + socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); + socket.broadcastEnabled = true; + subscription = socket.listen((event) { + if (event != RawSocketEvent.read) return; + Datagram? datagram; + while ((datagram = socket?.receive()) != null) { + final server = parseDiscoveryResponse(datagram!.data); + if (server == null) continue; + discovered.putIfAbsent(server.id, () => server); + } + }); + + final data = utf8.encode(discoveryMessage); + final target = broadcastAddress ?? InternetAddress('255.255.255.255'); + socket.send(data, target, discoveryPort); + await Future.delayed(const Duration(milliseconds: 350)); + socket.send(data, target, discoveryPort); + await Future.delayed(timeout); + } catch (e, st) { + appLogger.w('Jellyfin LAN discovery failed', error: e, stackTrace: st); + } finally { + await subscription?.cancel(); + socket?.close(); + } + + final servers = discovered.values.toList() + ..sort((a, b) { + final name = a.name.toLowerCase().compareTo(b.name.toLowerCase()); + if (name != 0) return name; + return a.address.compareTo(b.address); + }); + return List.unmodifiable(servers); + } + + static DiscoveredJellyfinServer? parseDiscoveryResponse(List data) { + try { + final decoded = jsonDecode(utf8.decode(data)); + if (decoded is! Map) return null; + + final address = _stringValue(decoded, 'Address') ?? _stringValue(decoded, 'address'); + final id = _stringValue(decoded, 'Id') ?? _stringValue(decoded, 'id'); + final name = _stringValue(decoded, 'Name') ?? _stringValue(decoded, 'name'); + if (address == null || id == null || name == null) return null; + + final normalized = JellyfinEndpointDiscovery.normalizeBaseUrl(address); + if (normalized.isEmpty || id.trim().isEmpty || name.trim().isEmpty) return null; + return DiscoveredJellyfinServer(address: normalized, id: id.trim(), name: name.trim()); + } catch (_) { + return null; + } + } + + static String? _stringValue(Map json, String key) { + final value = json[key]; + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } +} diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index f14f748e..80b26d07 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -9,6 +9,7 @@ import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/screens/settings/add_jellyfin_screen.dart'; import 'package:plezy/services/jellyfin_auth_service.dart'; +import 'package:plezy/services/jellyfin_lan_discovery_service.dart'; import 'package:plezy/utils/platform_detector.dart'; Profile _profile(String id) => @@ -35,6 +36,32 @@ JellyfinConnectionAuthService _jellyfinAuthService({bool quickConnectEnabled = f ); } +JellyfinConnectionAuthService _jellyfinAuthServiceForBareHost() { + return JellyfinConnectionAuthService( + clientName: 'Plezy', + clientVersion: 'test', + deviceName: 'TestDevice', + testHttpClientFactory: () => MockClient((request) async { + switch (request.url.path) { + case '/System/Info/Public': + if (request.url.scheme == 'http' && request.url.host == 'jf.example.com' && request.url.port == 8096) { + return http.Response( + jsonEncode({'Id': 'srv-1', 'ServerName': 'Home', 'Version': '10.9.0'}), + 200, + headers: {'content-type': 'application/json'}, + ); + } + throw Exception('offline'); + case '/QuickConnect/Enabled': + return http.Response(jsonEncode(false), 200, headers: {'content-type': 'application/json'}); + } + return http.Response('', 404); + }), + ); +} + +Future> _noLocalServers() async => const []; + void main() { tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); @@ -42,7 +69,7 @@ void main() { }); testWidgets('autofocuses the server URL field', (tester) async { - await tester.pumpWidget(const MaterialApp(home: AddJellyfinScreen())); + await tester.pumpWidget(MaterialApp(home: AddJellyfinScreen(localDiscoveryFactory: _noLocalServers))); await tester.pump(); final field = tester.widget(find.byType(TextField)); @@ -53,7 +80,11 @@ void main() { testWidgets('TV initial focus opens the server URL keyboard', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); - await tester.pumpWidget(const InputModeTracker(child: MaterialApp(home: AddJellyfinScreen()))); + await tester.pumpWidget( + InputModeTracker( + child: MaterialApp(home: AddJellyfinScreen(localDiscoveryFactory: _noLocalServers)), + ), + ); await tester.pumpAndSettle(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); @@ -65,7 +96,11 @@ void main() { await TvDetectionService.getInstance(forceTv: true); TvDetectionService.setForceTVSync(true); - await tester.pumpWidget(const InputModeTracker(child: MaterialApp(home: AddJellyfinScreen()))); + await tester.pumpWidget( + InputModeTracker( + child: MaterialApp(home: AddJellyfinScreen(localDiscoveryFactory: _noLocalServers)), + ), + ); await tester.pumpAndSettle(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); @@ -83,7 +118,14 @@ void main() { }); testWidgets('D-pad moves from URL to credentials after server is found', (tester) async { - await tester.pumpWidget(MaterialApp(home: AddJellyfinScreen(authServiceFactory: () => _jellyfinAuthService()))); + await tester.pumpWidget( + MaterialApp( + home: AddJellyfinScreen( + authServiceFactory: () => _jellyfinAuthService(), + localDiscoveryFactory: _noLocalServers, + ), + ), + ); await tester.pump(); await tester.enterText(find.byType(TextField).first, 'https://jf.example.com'); @@ -106,6 +148,49 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); }); + testWidgets('accepts a bare Jellyfin host and expands it before probing', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: AddJellyfinScreen( + authServiceFactory: () => _jellyfinAuthServiceForBareHost(), + localDiscoveryFactory: _noLocalServers, + ), + ), + ); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'jf.example.com'); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField).first); + expect(field.controller?.text, 'http://jf.example.com:8096'); + expect(find.text('Home'), findsOneWidget); + }); + + testWidgets('selecting a discovered Jellyfin server probes that address', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: AddJellyfinScreen( + authServiceFactory: () => _jellyfinAuthService(), + localDiscoveryFactory: () async => [ + DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.text('Home')); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField).first); + expect(field.controller?.text, contains('http://192.168.1.20:8096')); + expect(find.text('Jellyfin 10.9.0'), findsOneWidget); + }); + group('Jellyfin profile binding decisions', () { test('creates a local profile only on true first-run with no profiles', () { expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue); diff --git a/test/services/jellyfin_endpoint_discovery_test.dart b/test/services/jellyfin_endpoint_discovery_test.dart index c7a64c64..9db11698 100644 --- a/test/services/jellyfin_endpoint_discovery_test.dart +++ b/test/services/jellyfin_endpoint_discovery_test.dart @@ -25,6 +25,111 @@ void main() { ]), ['https://jf.example.com', 'https://jf.lan:8096'], ); + expect(JellyfinEndpointDiscovery.normalizeBaseUrl('jf.example.com/'), 'jf.example.com'); + }); + + test('expands bare host input into Jellyfin URL candidates', () { + expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('jf.example.com'), [ + 'http://jf.example.com:8096', + 'https://jf.example.com', + 'https://jf.example.com:8096', + 'http://jf.example.com', + ]); + }); + + test('expands host and port input without changing the port', () { + expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('192.168.1.10:8096'), [ + 'http://192.168.1.10:8096', + 'https://192.168.1.10:8096', + ]); + }); + + test('races expanded bare host candidates', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((req) async { + if (req.url.scheme == 'http' && req.url.host == 'jf.example.com' && req.url.port == 8096) { + return _info(id: 'srv-1'); + } + throw TimeoutException('offline'); + }), + ); + final input = JellyfinEndpointDiscovery.buildUserInputCandidates(['jf.example.com']); + + final result = await discovery.raceEndpoints( + input.probeBaseUrls, + baseUrlsToPersist: input.explicitBaseUrls, + baseUrlsToValidate: input.explicitBaseUrls, + ); + + expect(result.activeBaseUrl, 'http://jf.example.com:8096'); + expect(result.baseUrls, ['http://jf.example.com:8096']); + }); + + test('does not persist failed shorthand guesses as failover URLs', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((req) async { + if (req.url.scheme == 'http' && req.url.host == '192.168.1.10' && req.url.port == 8096) { + return _info(id: 'srv-1'); + } + throw TimeoutException('offline'); + }), + ); + final input = JellyfinEndpointDiscovery.buildUserInputCandidates(['192.168.1.10']); + + final result = await discovery.raceEndpoints( + input.probeBaseUrls, + baseUrlsToPersist: input.explicitBaseUrls, + baseUrlsToValidate: input.explicitBaseUrls, + ); + + expect(result.baseUrls, ['http://192.168.1.10:8096']); + }); + + test('does not reject different servers found only through shorthand guesses', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((req) async { + if (req.url.scheme == 'http' && req.url.host == 'jf.example.com' && req.url.port == 8096) { + return _info(id: 'srv-1'); + } + if (req.url.scheme == 'https' && req.url.host == 'jf.example.com' && !req.url.hasPort) { + return _info(id: 'srv-2'); + } + throw TimeoutException('offline'); + }), + ); + final input = JellyfinEndpointDiscovery.buildUserInputCandidates(['jf.example.com']); + + final result = await discovery.raceEndpoints( + input.probeBaseUrls, + baseUrlsToPersist: input.explicitBaseUrls, + baseUrlsToValidate: input.explicitBaseUrls, + ); + + expect(result.baseUrls, [result.activeBaseUrl]); + }); + + test('retains explicit user-entered failover URLs when using input candidates', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((req) async { + if (req.url.host == 'offline.example.com') { + throw TimeoutException('offline'); + } + return _info(id: 'srv-1'); + }), + ); + final input = JellyfinEndpointDiscovery.buildUserInputCandidates([ + 'https://offline.example.com', + 'https://jf.example.com', + ]); + + final result = await discovery.raceEndpoints( + input.probeBaseUrls, + baseUrlsToPersist: input.explicitBaseUrls, + baseUrlsToValidate: input.explicitBaseUrls, + ); + + expect(result.activeBaseUrl, 'https://jf.example.com'); + expect(result.baseUrls, ['https://jf.example.com', 'https://offline.example.com']); }); test('races URLs and selects the lowest-latency reachable endpoint', () async { diff --git a/test/services/jellyfin_lan_discovery_service_test.dart b/test/services/jellyfin_lan_discovery_service_test.dart new file mode 100644 index 00000000..93ecb069 --- /dev/null +++ b/test/services/jellyfin_lan_discovery_service_test.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/jellyfin_lan_discovery_service.dart'; + +void main() { + group('JellyfinLanDiscoveryService', () { + test('parses Jellyfin UDP discovery responses', () { + final server = JellyfinLanDiscoveryService.parseDiscoveryResponse( + utf8.encode(jsonEncode({'Address': 'http://192.168.1.20:8096/', 'Id': 'srv-1', 'Name': 'Home'})), + ); + + expect(server, isNotNull); + expect(server!.address, 'http://192.168.1.20:8096'); + expect(server.id, 'srv-1'); + expect(server.name, 'Home'); + }); + + test('does not expand bare discovery addresses while parsing', () { + final server = JellyfinLanDiscoveryService.parseDiscoveryResponse( + utf8.encode(jsonEncode({'Address': '192.168.1.20', 'Id': 'srv-1', 'Name': 'Home'})), + ); + + expect(server?.address, '192.168.1.20'); + }); + + test('ignores malformed discovery responses', () { + expect(JellyfinLanDiscoveryService.parseDiscoveryResponse(utf8.encode('not json')), isNull); + expect( + JellyfinLanDiscoveryService.parseDiscoveryResponse(utf8.encode(jsonEncode({'Address': 'http://x'}))), + isNull, + ); + }); + }); +}