feat(jellyfin): add local server discovery

close #1188
This commit is contained in:
edde746
2026-05-31 10:51:08 +02:00
parent 656d512858
commit e3db54940d
41 changed files with 698 additions and 26 deletions
+2
View File
@@ -1276,6 +1276,8 @@
"serverUrl": "URL адрес на сървъра",
"serverUrls": "URL адреси на сървъра",
"findServer": "Намери сървър",
"searchingLocalServers": "Търсене на локални Jellyfin сървъри...",
"localServers": "Локални Jellyfin сървъри",
"username": "Потребителско име",
"password": "Парола",
"signIn": "Вход",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -1276,6 +1276,8 @@
"serverUrl": "サーバーURL",
"serverUrls": "サーバーURL",
"findServer": "サーバーを検索",
"searchingLocalServers": "ローカル Jellyfin サーバーを検索中...",
"localServers": "ローカル Jellyfin サーバー",
"username": "ユーザー名",
"password": "パスワード",
"signIn": "サインイン",
+2
View File
@@ -1276,6 +1276,8 @@
"serverUrl": "서버 URL",
"serverUrls": "서버 URL",
"findServer": "서버 찾기",
"searchingLocalServers": "로컬 Jellyfin 서버 검색 중...",
"localServers": "로컬 Jellyfin 서버",
"username": "사용자 이름",
"password": "비밀번호",
"signIn": "로그인",
+2
View File
@@ -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å",
+2
View File
@@ -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",
+2
View File
@@ -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ę",
+2
View File
@@ -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",
+2
View File
@@ -1276,6 +1276,8 @@
"serverUrl": "URL сервера",
"serverUrls": "URL сервера",
"findServer": "Найти сервер",
"searchingLocalServers": "Поиск локальных серверов Jellyfin...",
"localServers": "Локальные серверы Jellyfin",
"username": "Имя пользователя",
"password": "Пароль",
"signIn": "Войти",
+1 -1
View File
@@ -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
+4
View File
@@ -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' => 'Вход',
+4
View File
@@ -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',
+4
View File
@@ -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',
+8
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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' => 'サインイン',
+4
View File
@@ -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' => '로그인',
+4
View File
@@ -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å',
+4
View File
@@ -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',
+4
View File
@@ -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ę',
+4
View File
@@ -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',
+4
View File
@@ -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' => 'Войти',
+4
View File
@@ -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',
+4
View File
@@ -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' => '登录',
+2
View File
@@ -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",
+2
View File
@@ -1276,6 +1276,8 @@
"serverUrl": "服务器 URL",
"serverUrls": "服务器 URL",
"findServer": "查找服务器",
"searchingLocalServers": "正在查找本地 Jellyfin 服务器...",
"localServers": "本地 Jellyfin 服务器",
"username": "用户名",
"password": "密码",
"signIn": "登录",
+154 -11
View File
@@ -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<JellyfinConnectionAuthService> Function()? _authServiceFactory;
final FutureOr<List<DiscoveredJellyfinServer>> Function()? _localDiscoveryFactory;
const AddJellyfinScreen({
super.key,
this.targetProfile,
@visibleForTesting FutureOr<JellyfinConnectionAuthService> Function()? authServiceFactory,
}) : _authServiceFactory = authServiceFactory;
@visibleForTesting FutureOr<List<DiscoveredJellyfinServer>> Function()? localDiscoveryFactory,
}) : _authServiceFactory = authServiceFactory,
_localDiscoveryFactory = localDiscoveryFactory;
@override
State<AddJellyfinScreen> createState() => _AddJellyfinScreenState();
@@ -87,14 +91,24 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
final _signInFocus = FocusNode(debugLabel: 'AddJellyfin:SignIn');
final _quickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:QuickConnect');
final _cancelQuickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:CancelQuickConnect');
final _discoveredServerFocusNodes = <String, FocusNode>{};
final _formKey = GlobalKey<FormState>();
JellyfinServerInfo? _serverInfo;
JellyfinEndpointRaceResult? _serverEndpoint;
List<DiscoveredJellyfinServer> _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<AddJellyfinScreen> with AsyncFormSta
_signInFocus.dispose();
_quickConnectFocus.dispose();
_cancelQuickConnectFocus.dispose();
for (final node in _discoveredServerFocusNodes.values) {
node.dispose();
}
super.dispose();
}
Future<void> _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<DiscoveredJellyfinServer> 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<void> _useDiscoveredServer(DiscoveredJellyfinServer server) async {
if (busy) return;
setState(() {
_urlController.text = server.address;
_clearResolvedServer();
});
await _probe();
}
Future<void> _probe() async {
final urls = _enteredUrls();
if (urls.isEmpty) {
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls());
if (input.probeBaseUrls.isEmpty) {
setErrorText(t.addServer.enterJellyfinUrlError);
return;
}
await runAsync<void>(
() 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<AddJellyfinScreen> with AsyncFormSta
List<String> _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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> with AsyncFormSta
);
}
List<Widget> _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<Widget> _buildQuickConnectPanel(ThemeData theme) {
final code = _qcInitiation!.code;
return [
@@ -43,10 +43,13 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
if (!(_formKey.currentState?.validate() ?? false)) return;
await runAsync<void>(
() 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<EditJellyfinConnectionScr
List<String> _enteredUrls() {
return _urlsController.text
.split(RegExp(r'[\n,]+'))
.map(JellyfinEndpointDiscovery.normalizeBaseUrl)
.map((url) => url.trim())
.where((url) => url.isNotEmpty)
.toList(growable: false);
}
+9 -1
View File
@@ -75,8 +75,16 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
Iterable<String> baseUrls, {
String? preferredUrl,
String? expectedMachineId,
Iterable<String>? baseUrlsToPersist,
Iterable<String>? 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
+114 -7
View File
@@ -46,7 +46,16 @@ class JellyfinEndpointCandidate {
const JellyfinEndpointCandidate({required this.url, required this.index});
}
class JellyfinEndpointUserInputCandidates {
final List<String> probeBaseUrls;
final List<String> 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<String> baseUrls, {
String? preferredUrl,
String? expectedMachineId,
Iterable<String>? baseUrlsToPersist,
Iterable<String>? 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<JellyfinEndpointCandidate, JellyfinEndpointProbeResult> 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<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>.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<String> 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 = <String>[];
final seen = <String>{};
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<String> input) {
final probeBaseUrls = <String>[];
final explicitBaseUrls = <String>[];
final seenProbe = <String>{};
final seenExplicit = <String>{};
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<String> normalizeBaseUrls(Iterable<String> input) {
final result = <String>[];
final seen = <String>{};
@@ -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<String> _activeFirst(String activeBaseUrl, List<String> urls) {
final result = <String>[];
final seen = <String>{};
@@ -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<List<DiscoveredJellyfinServer>> discover({
Duration timeout = const Duration(seconds: 2),
InternetAddress? broadcastAddress,
}) async {
RawDatagramSocket? socket;
StreamSubscription<RawSocketEvent>? subscription;
final discovered = <String, DiscoveredJellyfinServer>{};
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<void>.delayed(const Duration(milliseconds: 350));
socket.send(data, target, discoveryPort);
await Future<void>.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<int> data) {
try {
final decoded = jsonDecode(utf8.decode(data));
if (decoded is! Map<String, dynamic>) 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<String, dynamic> json, String key) {
final value = json[key];
if (value is! String) return null;
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
}
@@ -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<List<DiscoveredJellyfinServer>> _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<TextField>(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<TextField>(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<TextField>(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);
@@ -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 {
@@ -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,
);
});
});
}