@@ -1276,6 +1276,8 @@
|
||||
"serverUrl": "URL адрес на сървъра",
|
||||
"serverUrls": "URL адреси на сървъра",
|
||||
"findServer": "Намери сървър",
|
||||
"searchingLocalServers": "Търсене на локални Jellyfin сървъри...",
|
||||
"localServers": "Локални Jellyfin сървъри",
|
||||
"username": "Потребителско име",
|
||||
"password": "Парола",
|
||||
"signIn": "Вход",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1276,6 +1276,8 @@
|
||||
"serverUrl": "サーバーURL",
|
||||
"serverUrls": "サーバーURL",
|
||||
"findServer": "サーバーを検索",
|
||||
"searchingLocalServers": "ローカル Jellyfin サーバーを検索中...",
|
||||
"localServers": "ローカル Jellyfin サーバー",
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"signIn": "サインイン",
|
||||
|
||||
@@ -1276,6 +1276,8 @@
|
||||
"serverUrl": "서버 URL",
|
||||
"serverUrls": "서버 URL",
|
||||
"findServer": "서버 찾기",
|
||||
"searchingLocalServers": "로컬 Jellyfin 서버 검색 중...",
|
||||
"localServers": "로컬 Jellyfin 서버",
|
||||
"username": "사용자 이름",
|
||||
"password": "비밀번호",
|
||||
"signIn": "로그인",
|
||||
|
||||
@@ -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å",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ę",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1276,6 +1276,8 @@
|
||||
"serverUrl": "URL сервера",
|
||||
"serverUrls": "URL сервера",
|
||||
"findServer": "Найти сервер",
|
||||
"searchingLocalServers": "Поиск локальных серверов Jellyfin...",
|
||||
"localServers": "Локальные серверы Jellyfin",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"signIn": "Войти",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => 'Вход',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'サインイン',
|
||||
|
||||
@@ -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' => '로그인',
|
||||
|
||||
@@ -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å',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ę',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Войти',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '登录',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1276,6 +1276,8 @@
|
||||
"serverUrl": "服务器 URL",
|
||||
"serverUrls": "服务器 URL",
|
||||
"findServer": "查找服务器",
|
||||
"searchingLocalServers": "正在查找本地 Jellyfin 服务器...",
|
||||
"localServers": "本地 Jellyfin 服务器",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"signIn": "登录",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user