diff --git a/lib/connection/connection.dart b/lib/connection/connection.dart index 2fa709af..d310fdf1 100644 --- a/lib/connection/connection.dart +++ b/lib/connection/connection.dart @@ -200,9 +200,13 @@ class JellyfinConnection extends Connection { @override final DateTime? lastAuthenticatedAt; - /// Server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`. + /// Active server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`. final String baseUrl; + /// Candidate server URLs for this Jellyfin server, with [baseUrl] first. + /// Existing installs only have [baseUrl]; deserialization backfills this. + final List baseUrls; + /// Server's reported name (System/Info). final String serverName; @@ -230,6 +234,7 @@ class JellyfinConnection extends Connection { JellyfinConnection({ required this.id, required this.baseUrl, + List? baseUrls, required this.serverName, required this.serverMachineId, required this.userId, @@ -240,7 +245,7 @@ class JellyfinConnection extends Connection { this.status = ConnectionStatus.unknown, required this.createdAt, this.lastAuthenticatedAt, - }); + }) : baseUrls = _normalizeBaseUrls(baseUrl, baseUrls); @override ConnectionKind get kind => ConnectionKind.jellyfin; @@ -252,16 +257,38 @@ class JellyfinConnection extends Connection { String get displayLabel => serverName; @override - String? get displaySubtitle => '$userName · ${_truncateUrl(baseUrl)}'; + String? get displaySubtitle { + final extraCount = baseUrls.length - 1; + final suffix = extraCount > 0 ? ' +$extraCount' : ''; + return '$userName · ${_truncateUrl(baseUrl)}$suffix'; + } static String _truncateUrl(String url) { if (url.length <= 40) return url; return '${url.substring(0, 37)}…'; } + static List _normalizeBaseUrls(String activeBaseUrl, List? urls) { + final result = []; + final seen = {}; + + void add(String url) { + final trimmed = url.trim(); + if (trimmed.isEmpty || !seen.add(trimmed)) return; + result.add(trimmed); + } + + add(activeBaseUrl); + for (final url in urls ?? const []) { + add(url); + } + return List.unmodifiable(result); + } + JellyfinConnection copyWith({ String? id, String? baseUrl, + List? baseUrls, String? serverName, String? serverMachineId, String? userId, @@ -273,9 +300,11 @@ class JellyfinConnection extends Connection { DateTime? createdAt, DateTime? lastAuthenticatedAt, }) { + final nextBaseUrl = baseUrl ?? this.baseUrl; return JellyfinConnection( id: id ?? this.id, - baseUrl: baseUrl ?? this.baseUrl, + baseUrl: nextBaseUrl, + baseUrls: baseUrls ?? this.baseUrls, serverName: serverName ?? this.serverName, serverMachineId: serverMachineId ?? this.serverMachineId, userId: userId ?? this.userId, @@ -293,6 +322,7 @@ class JellyfinConnection extends Connection { Map toConfigJson() { return { 'baseUrl': baseUrl, + 'baseUrls': baseUrls, 'serverName': serverName, 'serverMachineId': serverMachineId, 'userId': userId, @@ -310,9 +340,16 @@ class JellyfinConnection extends Connection { required DateTime createdAt, DateTime? lastAuthenticatedAt, }) { + final rawBaseUrls = json['baseUrls']; + final baseUrls = rawBaseUrls is List ? rawBaseUrls.whereType().toList(growable: false) : const []; + final rawBaseUrl = json['baseUrl'] as String?; + final baseUrl = rawBaseUrl != null && rawBaseUrl.isNotEmpty + ? rawBaseUrl + : (baseUrls.isNotEmpty ? baseUrls.first : ''); return JellyfinConnection( id: id, - baseUrl: json['baseUrl'] as String? ?? '', + baseUrl: baseUrl, + baseUrls: baseUrls, serverName: json['serverName'] as String? ?? 'Jellyfin', serverMachineId: json['serverMachineId'] as String? ?? '', userId: json['userId'] as String? ?? '', diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index a7d734b9..04c7737f 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -36,6 +36,7 @@ "yes": "Ja", "no": "Nej", "delete": "Slet", + "edit": "Rediger", "shuffle": "Bland", "addTo": "Tilføj til...", "createNew": "Opret ny", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Føj til ${displayName}: Plex, Jellyfin eller en anden profilforbindelse", "sessionExpiredOne": "Sessionen er udløbet for ${name}", "sessionExpiredMany": "Sessionen er udløbet for ${count} servere", - "signInAgain": "Log ind igen" + "signInAgain": "Log ind igen", + "editJellyfinTitle": "Rediger Jellyfin-forbindelse", + "editJellyfinIntro": "Tilføj eller fjern URL'er for ${serverName}. Plezy bruger den tilgængelige URL med lavest latenstid." }, "discover": { "title": "Opdag", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Tilføj Jellyfin-server", "jellyfinUrlIntro": "Indtast server-URL'en, f.eks. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Indtast en eller flere server-URL'er, adskilt med kommaer eller nye linjer. Plezy bruger den tilgængelige URL med lavest latenstid.", "serverUrl": "Server-URL", + "serverUrls": "Server-URL'er", "findServer": "Find server", "username": "Brugernavn", "password": "Adgangskode", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 91c78e92..4ddca97b 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -36,6 +36,7 @@ "yes": "Ja", "no": "Nein", "delete": "Löschen", + "edit": "Bearbeiten", "shuffle": "Zufall", "addTo": "Hinzufügen zu...", "createNew": "Neu erstellen", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Zu ${displayName} hinzufügen: Plex, Jellyfin oder eine andere Profilverbindung", "sessionExpiredOne": "Sitzung für ${name} abgelaufen", "sessionExpiredMany": "Sitzungen für ${count} Server abgelaufen", - "signInAgain": "Erneut anmelden" + "signInAgain": "Erneut anmelden", + "editJellyfinTitle": "Jellyfin-Verbindung bearbeiten", + "editJellyfinIntro": "Füge URLs für ${serverName} hinzu oder entferne sie. Plezy verwendet die erreichbare URL mit der geringsten Latenz." }, "discover": { "title": "Entdecken", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Jellyfin-Server hinzufügen", "jellyfinUrlIntro": "Gib die Server-URL ein, z. B. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Gib eine oder mehrere Server-URLs ein, getrennt durch Kommas oder neue Zeilen. Plezy verwendet die erreichbare URL mit der geringsten Latenz.", "serverUrl": "Server-URL", + "serverUrls": "Server-URLs", "findServer": "Server finden", "username": "Benutzername", "password": "Passwort", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 8cf366c6..42a2b809 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -36,6 +36,7 @@ "yes": "Yes", "no": "No", "delete": "Delete", + "edit": "Edit", "shuffle": "Shuffle", "addTo": "Add to...", "createNew": "Create new", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Add to ${displayName}: Plex, Jellyfin, or another profile connection", "sessionExpiredOne": "Session expired for ${name}", "sessionExpiredMany": "Session expired for ${count} servers", - "signInAgain": "Sign in again" + "signInAgain": "Sign in again", + "editJellyfinTitle": "Edit Jellyfin connection", + "editJellyfinIntro": "Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency." }, "discover": { "title": "Discover", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Add Jellyfin server", "jellyfinUrlIntro": "Enter the server URL, e.g. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Enter one or more server URLs, separated by commas or new lines. Plezy will use the reachable URL with the lowest latency.", "serverUrl": "Server URL", + "serverUrls": "Server URLs", "findServer": "Find server", "username": "Username", "password": "Password", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index ddfccfe9..48a556b3 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -36,6 +36,7 @@ "yes": "Sí", "no": "No", "delete": "Eliminar", + "edit": "Editar", "shuffle": "Aleatorio", "addTo": "Añadir a...", "createNew": "Crear", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Añadir a ${displayName}: Plex, Jellyfin u otra conexión de perfil", "sessionExpiredOne": "Sesión caducada para ${name}", "sessionExpiredMany": "Sesión caducada para ${count} servidores", - "signInAgain": "Iniciar sesión de nuevo" + "signInAgain": "Iniciar sesión de nuevo", + "editJellyfinTitle": "Editar conexión de Jellyfin", + "editJellyfinIntro": "Añade o elimina URL para ${serverName}. Plezy usará la URL accesible con menor latencia." }, "discover": { "title": "Descubrir", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Añadir servidor Jellyfin", "jellyfinUrlIntro": "Introduce la URL del servidor, p. ej. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Introduce una o más URL del servidor, separadas por comas o líneas nuevas. Plezy usará la URL accesible con menor latencia.", "serverUrl": "URL del servidor", + "serverUrls": "URL del servidor", "findServer": "Buscar servidor", "username": "Usuario", "password": "Contraseña", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 3de3d7a5..b39f5cfb 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -36,6 +36,7 @@ "yes": "Oui", "no": "Non", "delete": "Supprimer", + "edit": "Modifier", "shuffle": "Mélanger", "addTo": "Ajouter à...", "createNew": "Créer", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Ajouter à ${displayName} : Plex, Jellyfin ou une autre connexion de profil", "sessionExpiredOne": "Session expirée pour ${name}", "sessionExpiredMany": "Session expirée pour ${count} serveurs", - "signInAgain": "Se reconnecter" + "signInAgain": "Se reconnecter", + "editJellyfinTitle": "Modifier la connexion Jellyfin", + "editJellyfinIntro": "Ajoutez ou supprimez des URL pour ${serverName}. Plezy utilisera l'URL joignable avec la latence la plus faible." }, "discover": { "title": "Découvrez", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Ajouter un serveur Jellyfin", "jellyfinUrlIntro": "Saisissez l'URL du serveur, par ex. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Saisissez une ou plusieurs URL de serveur, séparées par des virgules ou des retours à la ligne. Plezy utilisera l'URL joignable avec la latence la plus faible.", "serverUrl": "URL du serveur", + "serverUrls": "URL du serveur", "findServer": "Rechercher un serveur", "username": "Nom d'utilisateur", "password": "Mot de passe", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 21e69c7b..fdab1508 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -36,6 +36,7 @@ "yes": "Sì", "no": "No", "delete": "Elimina", + "edit": "Modifica", "shuffle": "Casuale", "addTo": "Aggiungi a...", "createNew": "Crea", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Aggiungi a ${displayName}: Plex, Jellyfin o un'altra connessione profilo", "sessionExpiredOne": "Sessione scaduta per ${name}", "sessionExpiredMany": "Sessione scaduta per ${count} server", - "signInAgain": "Accedi di nuovo" + "signInAgain": "Accedi di nuovo", + "editJellyfinTitle": "Modifica connessione Jellyfin", + "editJellyfinIntro": "Aggiungi o rimuovi URL per ${serverName}. Plezy userà l'URL raggiungibile con la latenza più bassa." }, "discover": { "title": "Esplora", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Aggiungi server Jellyfin", "jellyfinUrlIntro": "Inserisci l'URL del server, es. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Inserisci uno o più URL del server, separati da virgole o nuove righe. Plezy userà l'URL raggiungibile con la latenza più bassa.", "serverUrl": "URL del server", + "serverUrls": "URL del server", "findServer": "Trova server", "username": "Nome utente", "password": "Password", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 540fbf58..91d3b6d7 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -36,6 +36,7 @@ "yes": "はい", "no": "いいえ", "delete": "削除", + "edit": "編集", "shuffle": "シャッフル", "addTo": "追加...", "createNew": "新規作成", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "${displayName}に追加: Plex、Jellyfin、または別プロフィールの接続", "sessionExpiredOne": "${name} のセッションの有効期限が切れました", "sessionExpiredMany": "${count} 台のサーバーのセッションの有効期限が切れました", - "signInAgain": "再度サインイン" + "signInAgain": "再度サインイン", + "editJellyfinTitle": "Jellyfin接続を編集", + "editJellyfinIntro": "${serverName} のURLを追加または削除します。Plezyは到達可能なURLのうち最も低遅延のものを使用します。" }, "discover": { "title": "探す", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Jellyfinサーバーを追加", "jellyfinUrlIntro": "サーバーURLを入力してください。例: `https://jellyfin.example.com`。", + "jellyfinUrlsIntro": "サーバーURLを1つ以上、カンマまたは改行で区切って入力してください。Plezyは到達可能なURLのうち最も低遅延のものを使用します。", "serverUrl": "サーバーURL", + "serverUrls": "サーバーURL", "findServer": "サーバーを検索", "username": "ユーザー名", "password": "パスワード", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index c622841c..0f674c6e 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -36,6 +36,7 @@ "yes": "예", "no": "아니오", "delete": "삭제", + "edit": "편집", "shuffle": "무작위 재생", "addTo": "추가하기...", "createNew": "새로 만들기", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "${displayName}에 추가: Plex, Jellyfin 또는 다른 프로필 연결", "sessionExpiredOne": "${name} 의 세션이 만료되었습니다", "sessionExpiredMany": "${count} 개의 서버에서 세션이 만료되었습니다", - "signInAgain": "다시 로그인" + "signInAgain": "다시 로그인", + "editJellyfinTitle": "Jellyfin 연결 편집", + "editJellyfinIntro": "${serverName}의 URL을 추가하거나 제거하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다." }, "discover": { "title": "발견", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Jellyfin 서버 추가", "jellyfinUrlIntro": "서버 URL을 입력하세요. 예: `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "서버 URL을 하나 이상 쉼표나 줄바꿈으로 구분해 입력하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다.", "serverUrl": "서버 URL", + "serverUrls": "서버 URL", "findServer": "서버 찾기", "username": "사용자 이름", "password": "비밀번호", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 33f0c58d..6802e5d7 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -36,6 +36,7 @@ "yes": "Ja", "no": "Nei", "delete": "Slett", + "edit": "Rediger", "shuffle": "Tilfeldig", "addTo": "Legg til i...", "createNew": "Opprett ny", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Legg til for ${displayName}: Plex, Jellyfin eller en annen profiltilkobling", "sessionExpiredOne": "Økten er utløpt for ${name}", "sessionExpiredMany": "Økten er utløpt for ${count} servere", - "signInAgain": "Logg inn igjen" + "signInAgain": "Logg inn igjen", + "editJellyfinTitle": "Rediger Jellyfin-tilkobling", + "editJellyfinIntro": "Legg til eller fjern URL-er for ${serverName}. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse." }, "discover": { "title": "Oppdag", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Legg til Jellyfin-server", "jellyfinUrlIntro": "Skriv inn server-URL-en, f.eks. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Skriv inn én eller flere server-URL-er, adskilt med kommaer eller nye linjer. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse.", "serverUrl": "Server-URL", + "serverUrls": "Server-URL-er", "findServer": "Finn server", "username": "Brukernavn", "password": "Passord", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index f77d0559..a074f63e 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -36,6 +36,7 @@ "yes": "Ja", "no": "Nee", "delete": "Verwijderen", + "edit": "Bewerken", "shuffle": "Willekeurig", "addTo": "Toevoegen aan...", "createNew": "Nieuw aanmaken", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Toevoegen aan ${displayName}: Plex, Jellyfin of een andere profielverbinding", "sessionExpiredOne": "Sessie verlopen voor ${name}", "sessionExpiredMany": "Sessie verlopen voor ${count} servers", - "signInAgain": "Opnieuw aanmelden" + "signInAgain": "Opnieuw aanmelden", + "editJellyfinTitle": "Jellyfin-verbinding bewerken", + "editJellyfinIntro": "Voeg URL's voor ${serverName} toe of verwijder ze. Plezy gebruikt de bereikbare URL met de laagste latentie." }, "discover": { "title": "Ontdekken", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Jellyfin-server toevoegen", "jellyfinUrlIntro": "Voer de server-URL in, bijv. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Voer een of meer server-URL's in, gescheiden door komma's of nieuwe regels. Plezy gebruikt de bereikbare URL met de laagste latentie.", "serverUrl": "Server-URL", + "serverUrls": "Server-URL's", "findServer": "Server zoeken", "username": "Gebruikersnaam", "password": "Wachtwoord", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index ee55715e..09ac4c16 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -36,6 +36,7 @@ "yes": "Tak", "no": "Nie", "delete": "Usuń", + "edit": "Edytuj", "shuffle": "Losowo", "addTo": "Dodaj do...", "createNew": "Utwórz nowy", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Dodaj do ${displayName}: Plex, Jellyfin lub połączenie innego profilu", "sessionExpiredOne": "Sesja wygasła dla ${name}", "sessionExpiredMany": "Sesja wygasła dla ${count} serwerów", - "signInAgain": "Zaloguj się ponownie" + "signInAgain": "Zaloguj się ponownie", + "editJellyfinTitle": "Edytuj połączenie Jellyfin", + "editJellyfinIntro": "Dodaj lub usuń adresy URL dla ${serverName}. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu." }, "discover": { "title": "Odkryj", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Dodaj serwer Jellyfin", "jellyfinUrlIntro": "Wpisz URL serwera, np. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Wpisz co najmniej jeden URL serwera, oddzielając je przecinkami lub nowymi wierszami. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu.", "serverUrl": "URL serwera", + "serverUrls": "URL-e serwera", "findServer": "Znajdź serwer", "username": "Nazwa użytkownika", "password": "Hasło", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 3b39f013..b9799458 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -36,6 +36,7 @@ "yes": "Sim", "no": "Não", "delete": "Excluir", + "edit": "Editar", "shuffle": "Aleatório", "addTo": "Adicionar a...", "createNew": "Criar novo", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Adicionar a ${displayName}: Plex, Jellyfin ou outra conexão de perfil", "sessionExpiredOne": "Sessão expirada para ${name}", "sessionExpiredMany": "Sessão expirada para ${count} servidores", - "signInAgain": "Entrar novamente" + "signInAgain": "Entrar novamente", + "editJellyfinTitle": "Editar conexão Jellyfin", + "editJellyfinIntro": "Adicione ou remova URLs de ${serverName}. O Plezy usará a URL acessível com a menor latência." }, "discover": { "title": "Descobrir", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Adicionar servidor Jellyfin", "jellyfinUrlIntro": "Insira a URL do servidor, ex. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Insira uma ou mais URLs do servidor, separadas por vírgulas ou novas linhas. O Plezy usará a URL acessível com a menor latência.", "serverUrl": "URL do servidor", + "serverUrls": "URLs do servidor", "findServer": "Encontrar servidor", "username": "Usuário", "password": "Senha", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index d252ed7b..8be44f81 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -36,6 +36,7 @@ "yes": "Да", "no": "Нет", "delete": "Удалить", + "edit": "Редактировать", "shuffle": "Перемешать", "addTo": "Добавить в...", "createNew": "Создать новый", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Добавить к ${displayName}: Plex, Jellyfin или подключение другого профиля", "sessionExpiredOne": "Сессия истекла для ${name}", "sessionExpiredMany": "Сессия истекла для ${count} серверов", - "signInAgain": "Войти снова" + "signInAgain": "Войти снова", + "editJellyfinTitle": "Изменить подключение Jellyfin", + "editJellyfinIntro": "Добавьте или удалите URL для ${serverName}. Plezy будет использовать доступный URL с минимальной задержкой." }, "discover": { "title": "Обзор", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Добавить сервер Jellyfin", "jellyfinUrlIntro": "Введите URL сервера, например `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Введите один или несколько URL сервера, разделяя их запятыми или новыми строками. Plezy будет использовать доступный URL с минимальной задержкой.", "serverUrl": "URL сервера", + "serverUrls": "URL сервера", "findServer": "Найти сервер", "username": "Имя пользователя", "password": "Пароль", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 08ba1af6..f9cec21a 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 15 -/// Strings: 17580 (1172 per locale) +/// Strings: 17655 (1177 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 401696bf..73cd207c 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonDa extends TranslationsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nej'; @override String get delete => 'Slet'; + @override String get edit => 'Rediger'; @override String get shuffle => 'Bland'; @override String get addTo => 'Tilføj til...'; @override String get createNew => 'Opret ny'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsDa extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Sessionen er udløbet for ${name}'; @override String sessionExpiredMany({required Object count}) => 'Sessionen er udløbet for ${count} servere'; @override String get signInAgain => 'Log ind igen'; + @override String get editJellyfinTitle => 'Rediger Jellyfin-forbindelse'; + @override String editJellyfinIntro({required Object serverName}) => 'Tilføj eller fjern URL\'er for ${serverName}. Plezy bruger den tilgængelige URL med lavest latenstid.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerDa extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Tilføj Jellyfin-server'; @override String get jellyfinUrlIntro => 'Indtast server-URL\'en, f.eks. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Indtast en eller flere server-URL\'er, adskilt med kommaer eller nye linjer. Plezy bruger den tilgængelige URL med lavest latenstid.'; @override String get serverUrl => 'Server-URL'; + @override String get serverUrls => 'Server-URL\'er'; @override String get findServer => 'Find server'; @override String get username => 'Brugernavn'; @override String get password => 'Adgangskode'; @@ -1833,6 +1838,7 @@ extension on TranslationsDa { 'common.yes' => 'Ja', 'common.no' => 'Nej', 'common.delete' => 'Slet', + 'common.edit' => 'Rediger', 'common.shuffle' => 'Bland', 'common.addTo' => 'Tilføj til...', 'common.createNew' => 'Opret ny', @@ -2312,9 +2318,9 @@ extension on TranslationsDa { 'profiles.delete' => 'Slet', 'profiles.signOut' => 'Log ud', 'profiles.signOutPlexTitle' => 'Log ud af Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjern ${displayName} og alle Plex Home-brugere? Log ind igen når som helst.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjern ${displayName} og alle Plex Home-brugere? Log ind igen når som helst.', 'profiles.signedOutPlex' => 'Logget ud af Plex.', 'profiles.signOutFailed' => 'Log ud mislykkedes.', 'profiles.sectionTitle' => 'Profiler', @@ -2369,6 +2375,8 @@ extension on TranslationsDa { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessionen er udløbet for ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessionen er udløbet for ${count} servere', 'connections.signInAgain' => 'Log ind igen', + 'connections.editJellyfinTitle' => 'Rediger Jellyfin-forbindelse', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Tilføj eller fjern URL\'er for ${serverName}. Plezy bruger den tilgængelige URL med lavest latenstid.', 'discover.title' => 'Opdag', 'discover.switchProfile' => 'Skift profil', 'discover.noContentAvailable' => 'Intet indhold tilgængeligt', @@ -2824,11 +2832,11 @@ extension on TranslationsDa { 'metadataEdit.contentRating' => 'Aldersgrænse', 'metadataEdit.studio' => 'Studie', 'metadataEdit.tagline' => 'Tagline', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Resumé', 'metadataEdit.poster' => 'Plakat', 'metadataEdit.background' => 'Baggrund', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Kvadratisk billede', 'metadataEdit.selectPoster' => 'Vælg plakat', @@ -2947,7 +2955,9 @@ extension on TranslationsDa { 'trackers.libraryFilter.noLibraries' => 'Ingen biblioteker tilgængelige', 'addServer.addJellyfinTitle' => 'Tilføj Jellyfin-server', 'addServer.jellyfinUrlIntro' => 'Indtast server-URL\'en, f.eks. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Indtast en eller flere server-URL\'er, adskilt med kommaer eller nye linjer. Plezy bruger den tilgængelige URL med lavest latenstid.', 'addServer.serverUrl' => 'Server-URL', + 'addServer.serverUrls' => 'Server-URL\'er', 'addServer.findServer' => 'Find server', 'addServer.username' => 'Brugernavn', 'addServer.password' => 'Adgangskode', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 21539616..85728712 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonDe extends TranslationsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nein'; @override String get delete => 'Löschen'; + @override String get edit => 'Bearbeiten'; @override String get shuffle => 'Zufall'; @override String get addTo => 'Hinzufügen zu...'; @override String get createNew => 'Neu erstellen'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsDe extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Sitzung für ${name} abgelaufen'; @override String sessionExpiredMany({required Object count}) => 'Sitzungen für ${count} Server abgelaufen'; @override String get signInAgain => 'Erneut anmelden'; + @override String get editJellyfinTitle => 'Jellyfin-Verbindung bearbeiten'; + @override String editJellyfinIntro({required Object serverName}) => 'Füge URLs für ${serverName} hinzu oder entferne sie. Plezy verwendet die erreichbare URL mit der geringsten Latenz.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerDe extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Jellyfin-Server hinzufügen'; @override String get jellyfinUrlIntro => 'Gib die Server-URL ein, z. B. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Gib eine oder mehrere Server-URLs ein, getrennt durch Kommas oder neue Zeilen. Plezy verwendet die erreichbare URL mit der geringsten Latenz.'; @override String get serverUrl => 'Server-URL'; + @override String get serverUrls => 'Server-URLs'; @override String get findServer => 'Server finden'; @override String get username => 'Benutzername'; @override String get password => 'Passwort'; @@ -1833,6 +1838,7 @@ extension on TranslationsDe { 'common.yes' => 'Ja', 'common.no' => 'Nein', 'common.delete' => 'Löschen', + 'common.edit' => 'Bearbeiten', 'common.shuffle' => 'Zufall', 'common.addTo' => 'Hinzufügen zu...', 'common.createNew' => 'Neu erstellen', @@ -2312,9 +2318,9 @@ extension on TranslationsDe { 'profiles.delete' => 'Löschen', 'profiles.signOut' => 'Abmelden', 'profiles.signOutPlexTitle' => 'Von Plex abmelden?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} und alle Plex Home-Benutzer entfernen? Du kannst dich jederzeit wieder anmelden.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} und alle Plex Home-Benutzer entfernen? Du kannst dich jederzeit wieder anmelden.', 'profiles.signedOutPlex' => 'Von Plex abgemeldet.', 'profiles.signOutFailed' => 'Abmeldung fehlgeschlagen.', 'profiles.sectionTitle' => 'Profile', @@ -2369,6 +2375,8 @@ extension on TranslationsDe { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sitzung für ${name} abgelaufen', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sitzungen für ${count} Server abgelaufen', 'connections.signInAgain' => 'Erneut anmelden', + 'connections.editJellyfinTitle' => 'Jellyfin-Verbindung bearbeiten', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Füge URLs für ${serverName} hinzu oder entferne sie. Plezy verwendet die erreichbare URL mit der geringsten Latenz.', 'discover.title' => 'Entdecken', 'discover.switchProfile' => 'Profil wechseln', 'discover.noContentAvailable' => 'Kein Inhalt verfügbar', @@ -2824,11 +2832,11 @@ extension on TranslationsDe { 'metadataEdit.contentRating' => 'Altersfreigabe', 'metadataEdit.studio' => 'Studio', 'metadataEdit.tagline' => 'Tagline', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Zusammenfassung', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Hintergrund', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Quadratisches Bild', 'metadataEdit.selectPoster' => 'Poster auswählen', @@ -2947,7 +2955,9 @@ extension on TranslationsDe { 'trackers.libraryFilter.noLibraries' => 'Keine Bibliotheken verfügbar', 'addServer.addJellyfinTitle' => 'Jellyfin-Server hinzufügen', 'addServer.jellyfinUrlIntro' => 'Gib die Server-URL ein, z. B. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Gib eine oder mehrere Server-URLs ein, getrennt durch Kommas oder neue Zeilen. Plezy verwendet die erreichbare URL mit der geringsten Latenz.', 'addServer.serverUrl' => 'Server-URL', + 'addServer.serverUrls' => 'Server-URLs', 'addServer.findServer' => 'Server finden', 'addServer.username' => 'Benutzername', 'addServer.password' => 'Passwort', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 6a7b56c6..c480ebfd 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -209,6 +209,9 @@ class TranslationsCommonEn { /// en: 'Delete' String get delete => 'Delete'; + /// en: 'Edit' + String get edit => 'Edit'; + /// en: 'Shuffle' String get shuffle => 'Shuffle'; @@ -1889,6 +1892,12 @@ class TranslationsConnectionsEn { /// en: 'Sign in again' String get signInAgain => 'Sign in again'; + + /// en: 'Edit Jellyfin connection' + String get editJellyfinTitle => 'Edit Jellyfin connection'; + + /// en: 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.' + String editJellyfinIntro({required Object serverName}) => 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.'; } // Path: discover @@ -3509,9 +3518,15 @@ class TranslationsAddServerEn { /// en: 'Enter the server URL, e.g. `https://jellyfin.example.com`.' String get jellyfinUrlIntro => 'Enter the server URL, e.g. `https://jellyfin.example.com`.'; + /// en: 'Enter one or more server URLs, separated by commas or new lines. Plezy will use the reachable URL with the lowest latency.' + String get jellyfinUrlsIntro => 'Enter one or more server URLs, separated by commas or new lines. Plezy will use the reachable URL with the lowest latency.'; + /// en: 'Server URL' String get serverUrl => 'Server URL'; + /// en: 'Server URLs' + String get serverUrls => 'Server URLs'; + /// en: 'Find server' String get findServer => 'Find server'; @@ -4183,6 +4198,7 @@ extension on Translations { 'common.yes' => 'Yes', 'common.no' => 'No', 'common.delete' => 'Delete', + 'common.edit' => 'Edit', 'common.shuffle' => 'Shuffle', 'common.addTo' => 'Add to...', 'common.createNew' => 'Create new', @@ -4662,9 +4678,9 @@ extension on Translations { 'profiles.delete' => 'Delete', 'profiles.signOut' => 'Sign out', 'profiles.signOutPlexTitle' => 'Sign out of Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remove ${displayName} and all Plex Home users? Sign back in anytime.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remove ${displayName} and all Plex Home users? Sign back in anytime.', 'profiles.signedOutPlex' => 'Signed out of Plex.', 'profiles.signOutFailed' => 'Sign out failed.', 'profiles.sectionTitle' => 'Profiles', @@ -4719,6 +4735,8 @@ extension on Translations { 'connections.sessionExpiredOne' => ({required Object name}) => 'Session expired for ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Session expired for ${count} servers', 'connections.signInAgain' => 'Sign in again', + 'connections.editJellyfinTitle' => 'Edit Jellyfin connection', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.', 'discover.title' => 'Discover', 'discover.switchProfile' => 'Switch Profile', 'discover.noContentAvailable' => 'No content available', @@ -5174,11 +5192,11 @@ extension on Translations { 'metadataEdit.contentRating' => 'Content Rating', 'metadataEdit.studio' => 'Studio', 'metadataEdit.tagline' => 'Tagline', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Summary', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Background', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Square Art', 'metadataEdit.selectPoster' => 'Select Poster', @@ -5297,7 +5315,9 @@ extension on Translations { 'trackers.libraryFilter.noLibraries' => 'No libraries available', 'addServer.addJellyfinTitle' => 'Add Jellyfin server', 'addServer.jellyfinUrlIntro' => 'Enter the server URL, e.g. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Enter one or more server URLs, separated by commas or new lines. Plezy will use the reachable URL with the lowest latency.', 'addServer.serverUrl' => 'Server URL', + 'addServer.serverUrls' => 'Server URLs', 'addServer.findServer' => 'Find server', 'addServer.username' => 'Username', 'addServer.password' => 'Password', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 773b48d9..13d2bf8f 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonEs extends TranslationsCommonEn { @override String get yes => 'Sí'; @override String get no => 'No'; @override String get delete => 'Eliminar'; + @override String get edit => 'Editar'; @override String get shuffle => 'Aleatorio'; @override String get addTo => 'Añadir a...'; @override String get createNew => 'Crear'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsEs extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Sesión caducada para ${name}'; @override String sessionExpiredMany({required Object count}) => 'Sesión caducada para ${count} servidores'; @override String get signInAgain => 'Iniciar sesión de nuevo'; + @override String get editJellyfinTitle => 'Editar conexión de Jellyfin'; + @override String editJellyfinIntro({required Object serverName}) => 'Añade o elimina URL para ${serverName}. Plezy usará la URL accesible con menor latencia.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerEs extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Añadir servidor Jellyfin'; @override String get jellyfinUrlIntro => 'Introduce la URL del servidor, p. ej. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Introduce una o más URL del servidor, separadas por comas o líneas nuevas. Plezy usará la URL accesible con menor latencia.'; @override String get serverUrl => 'URL del servidor'; + @override String get serverUrls => 'URL del servidor'; @override String get findServer => 'Buscar servidor'; @override String get username => 'Usuario'; @override String get password => 'Contraseña'; @@ -1833,6 +1838,7 @@ extension on TranslationsEs { 'common.yes' => 'Sí', 'common.no' => 'No', 'common.delete' => 'Eliminar', + 'common.edit' => 'Editar', 'common.shuffle' => 'Aleatorio', 'common.addTo' => 'Añadir a...', 'common.createNew' => 'Crear', @@ -2312,9 +2318,9 @@ extension on TranslationsEs { 'profiles.delete' => 'Eliminar', 'profiles.signOut' => 'Cerrar sesión', 'profiles.signOutPlexTitle' => '¿Cerrar sesión de Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => '¿Eliminar ${displayName} y todos los usuarios de Plex Home? Puedes iniciar sesión de nuevo cuando quieras.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '¿Eliminar ${displayName} y todos los usuarios de Plex Home? Puedes iniciar sesión de nuevo cuando quieras.', 'profiles.signedOutPlex' => 'Sesión de Plex cerrada.', 'profiles.signOutFailed' => 'Error al cerrar sesión.', 'profiles.sectionTitle' => 'Perfiles', @@ -2369,6 +2375,8 @@ extension on TranslationsEs { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sesión caducada para ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sesión caducada para ${count} servidores', 'connections.signInAgain' => 'Iniciar sesión de nuevo', + 'connections.editJellyfinTitle' => 'Editar conexión de Jellyfin', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Añade o elimina URL para ${serverName}. Plezy usará la URL accesible con menor latencia.', 'discover.title' => 'Descubrir', 'discover.switchProfile' => 'Cambiar Perfil', 'discover.noContentAvailable' => 'No hay contenido disponible', @@ -2824,11 +2832,11 @@ extension on TranslationsEs { 'metadataEdit.contentRating' => 'Clasificación de contenido', 'metadataEdit.studio' => 'Estudio', 'metadataEdit.tagline' => 'Eslogan', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Resumen', 'metadataEdit.poster' => 'Póster', 'metadataEdit.background' => 'Fondo', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Imagen cuadrada', 'metadataEdit.selectPoster' => 'Seleccionar póster', @@ -2947,7 +2955,9 @@ extension on TranslationsEs { 'trackers.libraryFilter.noLibraries' => 'No hay bibliotecas disponibles', 'addServer.addJellyfinTitle' => 'Añadir servidor Jellyfin', 'addServer.jellyfinUrlIntro' => 'Introduce la URL del servidor, p. ej. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Introduce una o más URL del servidor, separadas por comas o líneas nuevas. Plezy usará la URL accesible con menor latencia.', 'addServer.serverUrl' => 'URL del servidor', + 'addServer.serverUrls' => 'URL del servidor', 'addServer.findServer' => 'Buscar servidor', 'addServer.username' => 'Usuario', 'addServer.password' => 'Contraseña', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 864a5268..812318d8 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonFr extends TranslationsCommonEn { @override String get yes => 'Oui'; @override String get no => 'Non'; @override String get delete => 'Supprimer'; + @override String get edit => 'Modifier'; @override String get shuffle => 'Mélanger'; @override String get addTo => 'Ajouter à...'; @override String get createNew => 'Créer'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsFr extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Session expirée pour ${name}'; @override String sessionExpiredMany({required Object count}) => 'Session expirée pour ${count} serveurs'; @override String get signInAgain => 'Se reconnecter'; + @override String get editJellyfinTitle => 'Modifier la connexion Jellyfin'; + @override String editJellyfinIntro({required Object serverName}) => 'Ajoutez ou supprimez des URL pour ${serverName}. Plezy utilisera l\'URL joignable avec la latence la plus faible.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerFr extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Ajouter un serveur Jellyfin'; @override String get jellyfinUrlIntro => 'Saisissez l\'URL du serveur, par ex. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Saisissez une ou plusieurs URL de serveur, séparées par des virgules ou des retours à la ligne. Plezy utilisera l\'URL joignable avec la latence la plus faible.'; @override String get serverUrl => 'URL du serveur'; + @override String get serverUrls => 'URL du serveur'; @override String get findServer => 'Rechercher un serveur'; @override String get username => 'Nom d\'utilisateur'; @override String get password => 'Mot de passe'; @@ -1833,6 +1838,7 @@ extension on TranslationsFr { 'common.yes' => 'Oui', 'common.no' => 'Non', 'common.delete' => 'Supprimer', + 'common.edit' => 'Modifier', 'common.shuffle' => 'Mélanger', 'common.addTo' => 'Ajouter à...', 'common.createNew' => 'Créer', @@ -2312,9 +2318,9 @@ extension on TranslationsFr { 'profiles.delete' => 'Supprimer', 'profiles.signOut' => 'Se déconnecter', 'profiles.signOutPlexTitle' => 'Se déconnecter de Plex ?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Supprimer ${displayName} et tous les utilisateurs Plex Home ? Reconnexion possible à tout moment.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Supprimer ${displayName} et tous les utilisateurs Plex Home ? Reconnexion possible à tout moment.', 'profiles.signedOutPlex' => 'Déconnecté de Plex.', 'profiles.signOutFailed' => 'Échec de la déconnexion.', 'profiles.sectionTitle' => 'Profils', @@ -2369,6 +2375,8 @@ extension on TranslationsFr { 'connections.sessionExpiredOne' => ({required Object name}) => 'Session expirée pour ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Session expirée pour ${count} serveurs', 'connections.signInAgain' => 'Se reconnecter', + 'connections.editJellyfinTitle' => 'Modifier la connexion Jellyfin', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Ajoutez ou supprimez des URL pour ${serverName}. Plezy utilisera l\'URL joignable avec la latence la plus faible.', 'discover.title' => 'Découvrez', 'discover.switchProfile' => 'Changer de profil', 'discover.noContentAvailable' => 'Aucun contenu disponible', @@ -2824,11 +2832,11 @@ extension on TranslationsFr { 'metadataEdit.contentRating' => 'Classification', 'metadataEdit.studio' => 'Studio', 'metadataEdit.tagline' => 'Slogan', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Résumé', 'metadataEdit.poster' => 'Affiche', 'metadataEdit.background' => 'Arrière-plan', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Image carrée', 'metadataEdit.selectPoster' => 'Sélectionner l\'affiche', @@ -2947,7 +2955,9 @@ extension on TranslationsFr { 'trackers.libraryFilter.noLibraries' => 'Aucune bibliothèque disponible', 'addServer.addJellyfinTitle' => 'Ajouter un serveur Jellyfin', 'addServer.jellyfinUrlIntro' => 'Saisissez l\'URL du serveur, par ex. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Saisissez une ou plusieurs URL de serveur, séparées par des virgules ou des retours à la ligne. Plezy utilisera l\'URL joignable avec la latence la plus faible.', 'addServer.serverUrl' => 'URL du serveur', + 'addServer.serverUrls' => 'URL du serveur', 'addServer.findServer' => 'Rechercher un serveur', 'addServer.username' => 'Nom d\'utilisateur', 'addServer.password' => 'Mot de passe', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 1c443686..3d74f985 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonIt extends TranslationsCommonEn { @override String get yes => 'Sì'; @override String get no => 'No'; @override String get delete => 'Elimina'; + @override String get edit => 'Modifica'; @override String get shuffle => 'Casuale'; @override String get addTo => 'Aggiungi a...'; @override String get createNew => 'Crea'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsIt extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Sessione scaduta per ${name}'; @override String sessionExpiredMany({required Object count}) => 'Sessione scaduta per ${count} server'; @override String get signInAgain => 'Accedi di nuovo'; + @override String get editJellyfinTitle => 'Modifica connessione Jellyfin'; + @override String editJellyfinIntro({required Object serverName}) => 'Aggiungi o rimuovi URL per ${serverName}. Plezy userà l\'URL raggiungibile con la latenza più bassa.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerIt extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Aggiungi server Jellyfin'; @override String get jellyfinUrlIntro => 'Inserisci l\'URL del server, es. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Inserisci uno o più URL del server, separati da virgole o nuove righe. Plezy userà l\'URL raggiungibile con la latenza più bassa.'; @override String get serverUrl => 'URL del server'; + @override String get serverUrls => 'URL del server'; @override String get findServer => 'Trova server'; @override String get username => 'Nome utente'; @override String get password => 'Password'; @@ -1833,6 +1838,7 @@ extension on TranslationsIt { 'common.yes' => 'Sì', 'common.no' => 'No', 'common.delete' => 'Elimina', + 'common.edit' => 'Modifica', 'common.shuffle' => 'Casuale', 'common.addTo' => 'Aggiungi a...', 'common.createNew' => 'Crea', @@ -2312,9 +2318,9 @@ extension on TranslationsIt { 'profiles.delete' => 'Elimina', 'profiles.signOut' => 'Esci', 'profiles.signOutPlexTitle' => 'Uscire da Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Rimuovere ${displayName} e tutti gli utenti Plex Home? Puoi accedere di nuovo quando vuoi.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Rimuovere ${displayName} e tutti gli utenti Plex Home? Puoi accedere di nuovo quando vuoi.', 'profiles.signedOutPlex' => 'Uscito da Plex.', 'profiles.signOutFailed' => 'Uscita non riuscita.', 'profiles.sectionTitle' => 'Profili', @@ -2369,6 +2375,8 @@ extension on TranslationsIt { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessione scaduta per ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessione scaduta per ${count} server', 'connections.signInAgain' => 'Accedi di nuovo', + 'connections.editJellyfinTitle' => 'Modifica connessione Jellyfin', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Aggiungi o rimuovi URL per ${serverName}. Plezy userà l\'URL raggiungibile con la latenza più bassa.', 'discover.title' => 'Esplora', 'discover.switchProfile' => 'Cambia profilo', 'discover.noContentAvailable' => 'Nessun contenuto disponibile', @@ -2824,11 +2832,11 @@ extension on TranslationsIt { 'metadataEdit.contentRating' => 'Classificazione', 'metadataEdit.studio' => 'Studio', 'metadataEdit.tagline' => 'Tagline', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Trama', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Sfondo', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Immagine quadrata', 'metadataEdit.selectPoster' => 'Seleziona poster', @@ -2947,7 +2955,9 @@ extension on TranslationsIt { 'trackers.libraryFilter.noLibraries' => 'Nessuna libreria disponibile', 'addServer.addJellyfinTitle' => 'Aggiungi server Jellyfin', 'addServer.jellyfinUrlIntro' => 'Inserisci l\'URL del server, es. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Inserisci uno o più URL del server, separati da virgole o nuove righe. Plezy userà l\'URL raggiungibile con la latenza più bassa.', 'addServer.serverUrl' => 'URL del server', + 'addServer.serverUrls' => 'URL del server', 'addServer.findServer' => 'Trova server', 'addServer.username' => 'Nome utente', 'addServer.password' => 'Password', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index c56acd7c..9b46e8b6 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonJa extends TranslationsCommonEn { @override String get yes => 'はい'; @override String get no => 'いいえ'; @override String get delete => '削除'; + @override String get edit => '編集'; @override String get shuffle => 'シャッフル'; @override String get addTo => '追加...'; @override String get createNew => '新規作成'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsJa extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => '${name} のセッションの有効期限が切れました'; @override String sessionExpiredMany({required Object count}) => '${count} 台のサーバーのセッションの有効期限が切れました'; @override String get signInAgain => '再度サインイン'; + @override String get editJellyfinTitle => 'Jellyfin接続を編集'; + @override String editJellyfinIntro({required Object serverName}) => '${serverName} のURLを追加または削除します。Plezyは到達可能なURLのうち最も低遅延のものを使用します。'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerJa extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Jellyfinサーバーを追加'; @override String get jellyfinUrlIntro => 'サーバーURLを入力してください。例: `https://jellyfin.example.com`。'; + @override String get jellyfinUrlsIntro => 'サーバーURLを1つ以上、カンマまたは改行で区切って入力してください。Plezyは到達可能なURLのうち最も低遅延のものを使用します。'; @override String get serverUrl => 'サーバーURL'; + @override String get serverUrls => 'サーバーURL'; @override String get findServer => 'サーバーを検索'; @override String get username => 'ユーザー名'; @override String get password => 'パスワード'; @@ -1833,6 +1838,7 @@ extension on TranslationsJa { 'common.yes' => 'はい', 'common.no' => 'いいえ', 'common.delete' => '削除', + 'common.edit' => '編集', 'common.shuffle' => 'シャッフル', 'common.addTo' => '追加...', 'common.createNew' => '新規作成', @@ -2312,9 +2318,9 @@ extension on TranslationsJa { 'profiles.delete' => '削除', 'profiles.signOut' => 'サインアウト', 'profiles.signOutPlexTitle' => 'Plex からサインアウトしますか?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName}とすべてのPlex Homeユーザーを削除しますか?いつでも再サインインできます。', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName}とすべてのPlex Homeユーザーを削除しますか?いつでも再サインインできます。', 'profiles.signedOutPlex' => 'Plex からサインアウトしました。', 'profiles.signOutFailed' => 'サインアウトに失敗しました。', 'profiles.sectionTitle' => 'プロファイル', @@ -2369,6 +2375,8 @@ extension on TranslationsJa { 'connections.sessionExpiredOne' => ({required Object name}) => '${name} のセッションの有効期限が切れました', 'connections.sessionExpiredMany' => ({required Object count}) => '${count} 台のサーバーのセッションの有効期限が切れました', 'connections.signInAgain' => '再度サインイン', + 'connections.editJellyfinTitle' => 'Jellyfin接続を編集', + 'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName} のURLを追加または削除します。Plezyは到達可能なURLのうち最も低遅延のものを使用します。', 'discover.title' => '探す', 'discover.switchProfile' => 'プロフィール切替', 'discover.noContentAvailable' => 'コンテンツがありません', @@ -2824,11 +2832,11 @@ extension on TranslationsJa { 'metadataEdit.contentRating' => 'コンテンツレーティング', 'metadataEdit.studio' => 'スタジオ', 'metadataEdit.tagline' => 'タグライン', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'あらすじ', 'metadataEdit.poster' => 'ポスター', 'metadataEdit.background' => '背景', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'ロゴ', 'metadataEdit.squareArt' => '正方形アート', 'metadataEdit.selectPoster' => 'ポスターを選択', @@ -2947,7 +2955,9 @@ extension on TranslationsJa { 'trackers.libraryFilter.noLibraries' => '利用できるライブラリがありません', 'addServer.addJellyfinTitle' => 'Jellyfinサーバーを追加', 'addServer.jellyfinUrlIntro' => 'サーバーURLを入力してください。例: `https://jellyfin.example.com`。', + 'addServer.jellyfinUrlsIntro' => 'サーバーURLを1つ以上、カンマまたは改行で区切って入力してください。Plezyは到達可能なURLのうち最も低遅延のものを使用します。', 'addServer.serverUrl' => 'サーバーURL', + 'addServer.serverUrls' => 'サーバーURL', 'addServer.findServer' => 'サーバーを検索', 'addServer.username' => 'ユーザー名', 'addServer.password' => 'パスワード', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 7cc3a616..ad0cf0c0 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonKo extends TranslationsCommonEn { @override String get yes => '예'; @override String get no => '아니오'; @override String get delete => '삭제'; + @override String get edit => '편집'; @override String get shuffle => '무작위 재생'; @override String get addTo => '추가하기...'; @override String get createNew => '새로 만들기'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsKo extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => '${name} 의 세션이 만료되었습니다'; @override String sessionExpiredMany({required Object count}) => '${count} 개의 서버에서 세션이 만료되었습니다'; @override String get signInAgain => '다시 로그인'; + @override String get editJellyfinTitle => 'Jellyfin 연결 편집'; + @override String editJellyfinIntro({required Object serverName}) => '${serverName}의 URL을 추가하거나 제거하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerKo extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Jellyfin 서버 추가'; @override String get jellyfinUrlIntro => '서버 URL을 입력하세요. 예: `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => '서버 URL을 하나 이상 쉼표나 줄바꿈으로 구분해 입력하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다.'; @override String get serverUrl => '서버 URL'; + @override String get serverUrls => '서버 URL'; @override String get findServer => '서버 찾기'; @override String get username => '사용자 이름'; @override String get password => '비밀번호'; @@ -1833,6 +1838,7 @@ extension on TranslationsKo { 'common.yes' => '예', 'common.no' => '아니오', 'common.delete' => '삭제', + 'common.edit' => '편집', 'common.shuffle' => '무작위 재생', 'common.addTo' => '추가하기...', 'common.createNew' => '새로 만들기', @@ -2312,9 +2318,9 @@ extension on TranslationsKo { 'profiles.delete' => '삭제', 'profiles.signOut' => '로그아웃', 'profiles.signOutPlexTitle' => 'Plex에서 로그아웃하시겠습니까?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} 및 모든 Plex Home 사용자를 제거할까요? 언제든 다시 로그인할 수 있습니다.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} 및 모든 Plex Home 사용자를 제거할까요? 언제든 다시 로그인할 수 있습니다.', 'profiles.signedOutPlex' => 'Plex에서 로그아웃되었습니다.', 'profiles.signOutFailed' => '로그아웃에 실패했습니다.', 'profiles.sectionTitle' => '프로필', @@ -2369,6 +2375,8 @@ extension on TranslationsKo { 'connections.sessionExpiredOne' => ({required Object name}) => '${name} 의 세션이 만료되었습니다', 'connections.sessionExpiredMany' => ({required Object count}) => '${count} 개의 서버에서 세션이 만료되었습니다', 'connections.signInAgain' => '다시 로그인', + 'connections.editJellyfinTitle' => 'Jellyfin 연결 편집', + 'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName}의 URL을 추가하거나 제거하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다.', 'discover.title' => '발견', 'discover.switchProfile' => '사용자 전환', 'discover.noContentAvailable' => '사용 가능한 콘텐츠가 없습니다', @@ -2824,11 +2832,11 @@ extension on TranslationsKo { 'metadataEdit.contentRating' => '콘텐츠 등급', 'metadataEdit.studio' => '스튜디오', 'metadataEdit.tagline' => '태그라인', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => '줄거리', 'metadataEdit.poster' => '포스터', 'metadataEdit.background' => '배경', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => '로고', 'metadataEdit.squareArt' => '정사각형 아트', 'metadataEdit.selectPoster' => '포스터 선택', @@ -2947,7 +2955,9 @@ extension on TranslationsKo { 'trackers.libraryFilter.noLibraries' => '사용 가능한 라이브러리가 없습니다', 'addServer.addJellyfinTitle' => 'Jellyfin 서버 추가', 'addServer.jellyfinUrlIntro' => '서버 URL을 입력하세요. 예: `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => '서버 URL을 하나 이상 쉼표나 줄바꿈으로 구분해 입력하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다.', 'addServer.serverUrl' => '서버 URL', + 'addServer.serverUrls' => '서버 URL', 'addServer.findServer' => '서버 찾기', 'addServer.username' => '사용자 이름', 'addServer.password' => '비밀번호', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 9dbc1a60..f9afbb60 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonNb extends TranslationsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nei'; @override String get delete => 'Slett'; + @override String get edit => 'Rediger'; @override String get shuffle => 'Tilfeldig'; @override String get addTo => 'Legg til i...'; @override String get createNew => 'Opprett ny'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsNb extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Økten er utløpt for ${name}'; @override String sessionExpiredMany({required Object count}) => 'Økten er utløpt for ${count} servere'; @override String get signInAgain => 'Logg inn igjen'; + @override String get editJellyfinTitle => 'Rediger Jellyfin-tilkobling'; + @override String editJellyfinIntro({required Object serverName}) => 'Legg til eller fjern URL-er for ${serverName}. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerNb extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Legg til Jellyfin-server'; @override String get jellyfinUrlIntro => 'Skriv inn server-URL-en, f.eks. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Skriv inn én eller flere server-URL-er, adskilt med kommaer eller nye linjer. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse.'; @override String get serverUrl => 'Server-URL'; + @override String get serverUrls => 'Server-URL-er'; @override String get findServer => 'Finn server'; @override String get username => 'Brukernavn'; @override String get password => 'Passord'; @@ -1833,6 +1838,7 @@ extension on TranslationsNb { 'common.yes' => 'Ja', 'common.no' => 'Nei', 'common.delete' => 'Slett', + 'common.edit' => 'Rediger', 'common.shuffle' => 'Tilfeldig', 'common.addTo' => 'Legg til i...', 'common.createNew' => 'Opprett ny', @@ -2312,9 +2318,9 @@ extension on TranslationsNb { 'profiles.delete' => 'Slett', 'profiles.signOut' => 'Logg ut', 'profiles.signOutPlexTitle' => 'Logge ut av Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjerne ${displayName} og alle Plex Home-brukere? Du kan logge inn igjen når som helst.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Fjerne ${displayName} og alle Plex Home-brukere? Du kan logge inn igjen når som helst.', 'profiles.signedOutPlex' => 'Logget ut av Plex.', 'profiles.signOutFailed' => 'Utlogging mislyktes.', 'profiles.sectionTitle' => 'Profiler', @@ -2369,6 +2375,8 @@ extension on TranslationsNb { 'connections.sessionExpiredOne' => ({required Object name}) => 'Økten er utløpt for ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Økten er utløpt for ${count} servere', 'connections.signInAgain' => 'Logg inn igjen', + 'connections.editJellyfinTitle' => 'Rediger Jellyfin-tilkobling', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Legg til eller fjern URL-er for ${serverName}. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse.', 'discover.title' => 'Oppdag', 'discover.switchProfile' => 'Bytt profil', 'discover.noContentAvailable' => 'Ingen innhold tilgjengelig', @@ -2824,11 +2832,11 @@ extension on TranslationsNb { 'metadataEdit.contentRating' => 'Innholdsvurdering', 'metadataEdit.studio' => 'Studio', 'metadataEdit.tagline' => 'Slagord', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Sammendrag', 'metadataEdit.poster' => 'Plakat', 'metadataEdit.background' => 'Bakgrunn', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Kvadratisk bilde', 'metadataEdit.selectPoster' => 'Velg plakat', @@ -2947,7 +2955,9 @@ extension on TranslationsNb { 'trackers.libraryFilter.noLibraries' => 'Ingen biblioteker tilgjengelige', 'addServer.addJellyfinTitle' => 'Legg til Jellyfin-server', 'addServer.jellyfinUrlIntro' => 'Skriv inn server-URL-en, f.eks. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Skriv inn én eller flere server-URL-er, adskilt med kommaer eller nye linjer. Plezy bruker den tilgjengelige URL-en med lavest forsinkelse.', 'addServer.serverUrl' => 'Server-URL', + 'addServer.serverUrls' => 'Server-URL-er', 'addServer.findServer' => 'Finn server', 'addServer.username' => 'Brukernavn', 'addServer.password' => 'Passord', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 669571e9..0d4e7327 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonNl extends TranslationsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nee'; @override String get delete => 'Verwijderen'; + @override String get edit => 'Bewerken'; @override String get shuffle => 'Willekeurig'; @override String get addTo => 'Toevoegen aan...'; @override String get createNew => 'Nieuw aanmaken'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsNl extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Sessie verlopen voor ${name}'; @override String sessionExpiredMany({required Object count}) => 'Sessie verlopen voor ${count} servers'; @override String get signInAgain => 'Opnieuw aanmelden'; + @override String get editJellyfinTitle => 'Jellyfin-verbinding bewerken'; + @override String editJellyfinIntro({required Object serverName}) => 'Voeg URL\'s voor ${serverName} toe of verwijder ze. Plezy gebruikt de bereikbare URL met de laagste latentie.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerNl extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Jellyfin-server toevoegen'; @override String get jellyfinUrlIntro => 'Voer de server-URL in, bijv. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Voer een of meer server-URL\'s in, gescheiden door komma\'s of nieuwe regels. Plezy gebruikt de bereikbare URL met de laagste latentie.'; @override String get serverUrl => 'Server-URL'; + @override String get serverUrls => 'Server-URL\'s'; @override String get findServer => 'Server zoeken'; @override String get username => 'Gebruikersnaam'; @override String get password => 'Wachtwoord'; @@ -1833,6 +1838,7 @@ extension on TranslationsNl { 'common.yes' => 'Ja', 'common.no' => 'Nee', 'common.delete' => 'Verwijderen', + 'common.edit' => 'Bewerken', 'common.shuffle' => 'Willekeurig', 'common.addTo' => 'Toevoegen aan...', 'common.createNew' => 'Nieuw aanmaken', @@ -2312,9 +2318,9 @@ extension on TranslationsNl { 'profiles.delete' => 'Verwijderen', 'profiles.signOut' => 'Afmelden', 'profiles.signOutPlexTitle' => 'Afmelden bij Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} en alle Plex Home-gebruikers verwijderen? Je kunt altijd opnieuw inloggen.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} en alle Plex Home-gebruikers verwijderen? Je kunt altijd opnieuw inloggen.', 'profiles.signedOutPlex' => 'Afgemeld bij Plex.', 'profiles.signOutFailed' => 'Afmelden mislukt.', 'profiles.sectionTitle' => 'Profielen', @@ -2369,6 +2375,8 @@ extension on TranslationsNl { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessie verlopen voor ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessie verlopen voor ${count} servers', 'connections.signInAgain' => 'Opnieuw aanmelden', + 'connections.editJellyfinTitle' => 'Jellyfin-verbinding bewerken', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Voeg URL\'s voor ${serverName} toe of verwijder ze. Plezy gebruikt de bereikbare URL met de laagste latentie.', 'discover.title' => 'Ontdekken', 'discover.switchProfile' => 'Wissel van profiel', 'discover.noContentAvailable' => 'Geen inhoud beschikbaar', @@ -2824,11 +2832,11 @@ extension on TranslationsNl { 'metadataEdit.contentRating' => 'Leeftijdsclassificatie', 'metadataEdit.studio' => 'Studio', 'metadataEdit.tagline' => 'Tagline', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Samenvatting', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Achtergrond', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Vierkante afbeelding', 'metadataEdit.selectPoster' => 'Poster selecteren', @@ -2947,7 +2955,9 @@ extension on TranslationsNl { 'trackers.libraryFilter.noLibraries' => 'Geen bibliotheken beschikbaar', 'addServer.addJellyfinTitle' => 'Jellyfin-server toevoegen', 'addServer.jellyfinUrlIntro' => 'Voer de server-URL in, bijv. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Voer een of meer server-URL\'s in, gescheiden door komma\'s of nieuwe regels. Plezy gebruikt de bereikbare URL met de laagste latentie.', 'addServer.serverUrl' => 'Server-URL', + 'addServer.serverUrls' => 'Server-URL\'s', 'addServer.findServer' => 'Server zoeken', 'addServer.username' => 'Gebruikersnaam', 'addServer.password' => 'Wachtwoord', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 258e9505..46a29863 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonPl extends TranslationsCommonEn { @override String get yes => 'Tak'; @override String get no => 'Nie'; @override String get delete => 'Usuń'; + @override String get edit => 'Edytuj'; @override String get shuffle => 'Losowo'; @override String get addTo => 'Dodaj do...'; @override String get createNew => 'Utwórz nowy'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsPl extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Sesja wygasła dla ${name}'; @override String sessionExpiredMany({required Object count}) => 'Sesja wygasła dla ${count} serwerów'; @override String get signInAgain => 'Zaloguj się ponownie'; + @override String get editJellyfinTitle => 'Edytuj połączenie Jellyfin'; + @override String editJellyfinIntro({required Object serverName}) => 'Dodaj lub usuń adresy URL dla ${serverName}. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerPl extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Dodaj serwer Jellyfin'; @override String get jellyfinUrlIntro => 'Wpisz URL serwera, np. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Wpisz co najmniej jeden URL serwera, oddzielając je przecinkami lub nowymi wierszami. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu.'; @override String get serverUrl => 'URL serwera'; + @override String get serverUrls => 'URL-e serwera'; @override String get findServer => 'Znajdź serwer'; @override String get username => 'Nazwa użytkownika'; @override String get password => 'Hasło'; @@ -1833,6 +1838,7 @@ extension on TranslationsPl { 'common.yes' => 'Tak', 'common.no' => 'Nie', 'common.delete' => 'Usuń', + 'common.edit' => 'Edytuj', 'common.shuffle' => 'Losowo', 'common.addTo' => 'Dodaj do...', 'common.createNew' => 'Utwórz nowy', @@ -2312,9 +2318,9 @@ extension on TranslationsPl { 'profiles.delete' => 'Usuń', 'profiles.signOut' => 'Wyloguj się', 'profiles.signOutPlexTitle' => 'Wylogować się z Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Usunąć ${displayName} i wszystkich użytkowników Plex Home? Możesz zalogować się ponownie w każdej chwili.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Usunąć ${displayName} i wszystkich użytkowników Plex Home? Możesz zalogować się ponownie w każdej chwili.', 'profiles.signedOutPlex' => 'Wylogowano z Plex.', 'profiles.signOutFailed' => 'Wylogowanie nie powiodło się.', 'profiles.sectionTitle' => 'Profile', @@ -2369,6 +2375,8 @@ extension on TranslationsPl { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sesja wygasła dla ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sesja wygasła dla ${count} serwerów', 'connections.signInAgain' => 'Zaloguj się ponownie', + 'connections.editJellyfinTitle' => 'Edytuj połączenie Jellyfin', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Dodaj lub usuń adresy URL dla ${serverName}. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu.', 'discover.title' => 'Odkryj', 'discover.switchProfile' => 'Zmień profil', 'discover.noContentAvailable' => 'Brak dostępnych treści', @@ -2824,11 +2832,11 @@ extension on TranslationsPl { 'metadataEdit.contentRating' => 'Klasyfikacja wiekowa', 'metadataEdit.studio' => 'Studio', 'metadataEdit.tagline' => 'Tagline', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Opis', 'metadataEdit.poster' => 'Plakat', 'metadataEdit.background' => 'Tło', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Kwadratowy obraz', 'metadataEdit.selectPoster' => 'Wybierz plakat', @@ -2947,7 +2955,9 @@ extension on TranslationsPl { 'trackers.libraryFilter.noLibraries' => 'Brak dostępnych bibliotek', 'addServer.addJellyfinTitle' => 'Dodaj serwer Jellyfin', 'addServer.jellyfinUrlIntro' => 'Wpisz URL serwera, np. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Wpisz co najmniej jeden URL serwera, oddzielając je przecinkami lub nowymi wierszami. Plezy użyje osiągalnego URL-a o najniższym opóźnieniu.', 'addServer.serverUrl' => 'URL serwera', + 'addServer.serverUrls' => 'URL-e serwera', 'addServer.findServer' => 'Znajdź serwer', 'addServer.username' => 'Nazwa użytkownika', 'addServer.password' => 'Hasło', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 2fd187a5..fbf40651 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonPt extends TranslationsCommonEn { @override String get yes => 'Sim'; @override String get no => 'Não'; @override String get delete => 'Excluir'; + @override String get edit => 'Editar'; @override String get shuffle => 'Aleatório'; @override String get addTo => 'Adicionar a...'; @override String get createNew => 'Criar novo'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsPt extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Sessão expirada para ${name}'; @override String sessionExpiredMany({required Object count}) => 'Sessão expirada para ${count} servidores'; @override String get signInAgain => 'Entrar novamente'; + @override String get editJellyfinTitle => 'Editar conexão Jellyfin'; + @override String editJellyfinIntro({required Object serverName}) => 'Adicione ou remova URLs de ${serverName}. O Plezy usará a URL acessível com a menor latência.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerPt extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Adicionar servidor Jellyfin'; @override String get jellyfinUrlIntro => 'Insira a URL do servidor, ex. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Insira uma ou mais URLs do servidor, separadas por vírgulas ou novas linhas. O Plezy usará a URL acessível com a menor latência.'; @override String get serverUrl => 'URL do servidor'; + @override String get serverUrls => 'URLs do servidor'; @override String get findServer => 'Encontrar servidor'; @override String get username => 'Usuário'; @override String get password => 'Senha'; @@ -1833,6 +1838,7 @@ extension on TranslationsPt { 'common.yes' => 'Sim', 'common.no' => 'Não', 'common.delete' => 'Excluir', + 'common.edit' => 'Editar', 'common.shuffle' => 'Aleatório', 'common.addTo' => 'Adicionar a...', 'common.createNew' => 'Criar novo', @@ -2312,9 +2318,9 @@ extension on TranslationsPt { 'profiles.delete' => 'Excluir', 'profiles.signOut' => 'Sair', 'profiles.signOutPlexTitle' => 'Sair do Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remover ${displayName} e todos os usuários Plex Home? Você pode entrar novamente quando quiser.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Remover ${displayName} e todos os usuários Plex Home? Você pode entrar novamente quando quiser.', 'profiles.signedOutPlex' => 'Saiu do Plex.', 'profiles.signOutFailed' => 'Falha ao sair.', 'profiles.sectionTitle' => 'Perfis', @@ -2369,6 +2375,8 @@ extension on TranslationsPt { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessão expirada para ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessão expirada para ${count} servidores', 'connections.signInAgain' => 'Entrar novamente', + 'connections.editJellyfinTitle' => 'Editar conexão Jellyfin', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Adicione ou remova URLs de ${serverName}. O Plezy usará a URL acessível com a menor latência.', 'discover.title' => 'Descobrir', 'discover.switchProfile' => 'Trocar Perfil', 'discover.noContentAvailable' => 'Nenhum conteúdo disponível', @@ -2824,11 +2832,11 @@ extension on TranslationsPt { 'metadataEdit.contentRating' => 'Classificação Indicativa', 'metadataEdit.studio' => 'Estúdio', 'metadataEdit.tagline' => 'Tagline', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Sinopse', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Plano de Fundo', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logo', 'metadataEdit.squareArt' => 'Imagem Quadrada', 'metadataEdit.selectPoster' => 'Selecionar Poster', @@ -2947,7 +2955,9 @@ extension on TranslationsPt { 'trackers.libraryFilter.noLibraries' => 'Nenhuma biblioteca disponível', 'addServer.addJellyfinTitle' => 'Adicionar servidor Jellyfin', 'addServer.jellyfinUrlIntro' => 'Insira a URL do servidor, ex. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Insira uma ou mais URLs do servidor, separadas por vírgulas ou novas linhas. O Plezy usará a URL acessível com a menor latência.', 'addServer.serverUrl' => 'URL do servidor', + 'addServer.serverUrls' => 'URLs do servidor', 'addServer.findServer' => 'Encontrar servidor', 'addServer.username' => 'Usuário', 'addServer.password' => 'Senha', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 337bb6e8..91188e79 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonRu extends TranslationsCommonEn { @override String get yes => 'Да'; @override String get no => 'Нет'; @override String get delete => 'Удалить'; + @override String get edit => 'Редактировать'; @override String get shuffle => 'Перемешать'; @override String get addTo => 'Добавить в...'; @override String get createNew => 'Создать новый'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsRu extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Сессия истекла для ${name}'; @override String sessionExpiredMany({required Object count}) => 'Сессия истекла для ${count} серверов'; @override String get signInAgain => 'Войти снова'; + @override String get editJellyfinTitle => 'Изменить подключение Jellyfin'; + @override String editJellyfinIntro({required Object serverName}) => 'Добавьте или удалите URL для ${serverName}. Plezy будет использовать доступный URL с минимальной задержкой.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerRu extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Добавить сервер Jellyfin'; @override String get jellyfinUrlIntro => 'Введите URL сервера, например `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Введите один или несколько URL сервера, разделяя их запятыми или новыми строками. Plezy будет использовать доступный URL с минимальной задержкой.'; @override String get serverUrl => 'URL сервера'; + @override String get serverUrls => 'URL сервера'; @override String get findServer => 'Найти сервер'; @override String get username => 'Имя пользователя'; @override String get password => 'Пароль'; @@ -1833,6 +1838,7 @@ extension on TranslationsRu { 'common.yes' => 'Да', 'common.no' => 'Нет', 'common.delete' => 'Удалить', + 'common.edit' => 'Редактировать', 'common.shuffle' => 'Перемешать', 'common.addTo' => 'Добавить в...', 'common.createNew' => 'Создать новый', @@ -2312,9 +2318,9 @@ extension on TranslationsRu { 'profiles.delete' => 'Удалить', 'profiles.signOut' => 'Выйти', 'profiles.signOutPlexTitle' => 'Выйти из Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Удалить ${displayName} и всех пользователей Plex Home? Вы сможете войти снова в любое время.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Удалить ${displayName} и всех пользователей Plex Home? Вы сможете войти снова в любое время.', 'profiles.signedOutPlex' => 'Вы вышли из Plex.', 'profiles.signOutFailed' => 'Не удалось выйти.', 'profiles.sectionTitle' => 'Профили', @@ -2369,6 +2375,8 @@ extension on TranslationsRu { 'connections.sessionExpiredOne' => ({required Object name}) => 'Сессия истекла для ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Сессия истекла для ${count} серверов', 'connections.signInAgain' => 'Войти снова', + 'connections.editJellyfinTitle' => 'Изменить подключение Jellyfin', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Добавьте или удалите URL для ${serverName}. Plezy будет использовать доступный URL с минимальной задержкой.', 'discover.title' => 'Обзор', 'discover.switchProfile' => 'Сменить профиль', 'discover.noContentAvailable' => 'Контент недоступен', @@ -2824,11 +2832,11 @@ extension on TranslationsRu { 'metadataEdit.contentRating' => 'Возрастной рейтинг', 'metadataEdit.studio' => 'Студия', 'metadataEdit.tagline' => 'Слоган', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Описание', 'metadataEdit.poster' => 'Постер', 'metadataEdit.background' => 'Фон', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Логотип', 'metadataEdit.squareArt' => 'Квадратное изображение', 'metadataEdit.selectPoster' => 'Выбрать постер', @@ -2947,7 +2955,9 @@ extension on TranslationsRu { 'trackers.libraryFilter.noLibraries' => 'Библиотеки недоступны', 'addServer.addJellyfinTitle' => 'Добавить сервер Jellyfin', 'addServer.jellyfinUrlIntro' => 'Введите URL сервера, например `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Введите один или несколько URL сервера, разделяя их запятыми или новыми строками. Plezy будет использовать доступный URL с минимальной задержкой.', 'addServer.serverUrl' => 'URL сервера', + 'addServer.serverUrls' => 'URL сервера', 'addServer.findServer' => 'Найти сервер', 'addServer.username' => 'Имя пользователя', 'addServer.password' => 'Пароль', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 4c1c9a1c..424d3739 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonSv extends TranslationsCommonEn { @override String get yes => 'Ja'; @override String get no => 'Nej'; @override String get delete => 'Ta bort'; + @override String get edit => 'Redigera'; @override String get shuffle => 'Blanda'; @override String get addTo => 'Lägg till i...'; @override String get createNew => 'Skapa ny'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsSv extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => 'Sessionen har gått ut för ${name}'; @override String sessionExpiredMany({required Object count}) => 'Sessionen har gått ut för ${count} servrar'; @override String get signInAgain => 'Logga in igen'; + @override String get editJellyfinTitle => 'Redigera Jellyfin-anslutning'; + @override String editJellyfinIntro({required Object serverName}) => 'Lägg till eller ta bort URL:er för ${serverName}. Plezy använder den nåbara URL:en med lägst latens.'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerSv extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => 'Lägg till Jellyfin-server'; @override String get jellyfinUrlIntro => 'Ange server-URL, t.ex. `https://jellyfin.example.com`.'; + @override String get jellyfinUrlsIntro => 'Ange en eller flera server-URL:er, separerade med kommatecken eller nya rader. Plezy använder den nåbara URL:en med lägst latens.'; @override String get serverUrl => 'Server-URL'; + @override String get serverUrls => 'Server-URL:er'; @override String get findServer => 'Hitta server'; @override String get username => 'Användarnamn'; @override String get password => 'Lösenord'; @@ -1833,6 +1838,7 @@ extension on TranslationsSv { 'common.yes' => 'Ja', 'common.no' => 'Nej', 'common.delete' => 'Ta bort', + 'common.edit' => 'Redigera', 'common.shuffle' => 'Blanda', 'common.addTo' => 'Lägg till i...', 'common.createNew' => 'Skapa ny', @@ -2312,9 +2318,9 @@ extension on TranslationsSv { 'profiles.delete' => 'Ta bort', 'profiles.signOut' => 'Logga ut', 'profiles.signOutPlexTitle' => 'Logga ut från Plex?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Ta bort ${displayName} och alla Plex Home-användare? Du kan logga in igen när som helst.', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Ta bort ${displayName} och alla Plex Home-användare? Du kan logga in igen när som helst.', 'profiles.signedOutPlex' => 'Utloggad från Plex.', 'profiles.signOutFailed' => 'Utloggningen misslyckades.', 'profiles.sectionTitle' => 'Profiler', @@ -2369,6 +2375,8 @@ extension on TranslationsSv { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessionen har gått ut för ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessionen har gått ut för ${count} servrar', 'connections.signInAgain' => 'Logga in igen', + 'connections.editJellyfinTitle' => 'Redigera Jellyfin-anslutning', + 'connections.editJellyfinIntro' => ({required Object serverName}) => 'Lägg till eller ta bort URL:er för ${serverName}. Plezy använder den nåbara URL:en med lägst latens.', 'discover.title' => 'Upptäck', 'discover.switchProfile' => 'Byt profil', 'discover.noContentAvailable' => 'Inget innehåll tillgängligt', @@ -2824,11 +2832,11 @@ extension on TranslationsSv { 'metadataEdit.contentRating' => 'Åldersgräns', 'metadataEdit.studio' => 'Studio', 'metadataEdit.tagline' => 'Tagline', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => 'Sammanfattning', 'metadataEdit.poster' => 'Poster', 'metadataEdit.background' => 'Bakgrund', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => 'Logotyp', 'metadataEdit.squareArt' => 'Kvadratisk bild', 'metadataEdit.selectPoster' => 'Välj poster', @@ -2947,7 +2955,9 @@ extension on TranslationsSv { 'trackers.libraryFilter.noLibraries' => 'Inga bibliotek tillgängliga', 'addServer.addJellyfinTitle' => 'Lägg till Jellyfin-server', 'addServer.jellyfinUrlIntro' => 'Ange server-URL, t.ex. `https://jellyfin.example.com`.', + 'addServer.jellyfinUrlsIntro' => 'Ange en eller flera server-URL:er, separerade med kommatecken eller nya rader. Plezy använder den nåbara URL:en med lägst latens.', 'addServer.serverUrl' => 'Server-URL', + 'addServer.serverUrls' => 'Server-URL:er', 'addServer.findServer' => 'Hitta server', 'addServer.username' => 'Användarnamn', 'addServer.password' => 'Lösenord', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index e693b86b..db91f67e 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonZh extends TranslationsCommonEn { @override String get yes => '是'; @override String get no => '否'; @override String get delete => '删除'; + @override String get edit => '编辑'; @override String get shuffle => '随机播放'; @override String get addTo => '添加到...'; @override String get createNew => '新建'; @@ -812,6 +813,8 @@ class _TranslationsConnectionsZh extends TranslationsConnectionsEn { @override String sessionExpiredOne({required Object name}) => '${name} 的会话已过期'; @override String sessionExpiredMany({required Object count}) => '${count} 个服务器的会话已过期'; @override String get signInAgain => '重新登录'; + @override String get editJellyfinTitle => '编辑 Jellyfin 连接'; + @override String editJellyfinIntro({required Object serverName}) => '添加或移除 ${serverName} 的 URL。Plezy 会使用可访问且延迟最低的 URL。'; } // Path: discover @@ -1502,7 +1505,9 @@ class _TranslationsAddServerZh extends TranslationsAddServerEn { // Translations @override String get addJellyfinTitle => '添加 Jellyfin 服务器'; @override String get jellyfinUrlIntro => '输入服务器 URL,例如 `https://jellyfin.example.com`。'; + @override String get jellyfinUrlsIntro => '输入一个或多个服务器 URL,用逗号或换行分隔。Plezy 会使用可访问且延迟最低的 URL。'; @override String get serverUrl => '服务器 URL'; + @override String get serverUrls => '服务器 URL'; @override String get findServer => '查找服务器'; @override String get username => '用户名'; @override String get password => '密码'; @@ -1833,6 +1838,7 @@ extension on TranslationsZh { 'common.yes' => '是', 'common.no' => '否', 'common.delete' => '删除', + 'common.edit' => '编辑', 'common.shuffle' => '随机播放', 'common.addTo' => '添加到...', 'common.createNew' => '新建', @@ -2312,9 +2318,9 @@ extension on TranslationsZh { 'profiles.delete' => '删除', 'profiles.signOut' => '退出登录', 'profiles.signOutPlexTitle' => '退出 Plex 登录?', - 'profiles.signOutPlexMessage' => ({required Object displayName}) => '要移除 ${displayName} 和所有 Plex Home 用户吗?可随时重新登录。', _ => null, } ?? switch (path) { + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '要移除 ${displayName} 和所有 Plex Home 用户吗?可随时重新登录。', 'profiles.signedOutPlex' => '已退出 Plex 登录。', 'profiles.signOutFailed' => '退出登录失败。', 'profiles.sectionTitle' => '配置文件', @@ -2369,6 +2375,8 @@ extension on TranslationsZh { 'connections.sessionExpiredOne' => ({required Object name}) => '${name} 的会话已过期', 'connections.sessionExpiredMany' => ({required Object count}) => '${count} 个服务器的会话已过期', 'connections.signInAgain' => '重新登录', + 'connections.editJellyfinTitle' => '编辑 Jellyfin 连接', + 'connections.editJellyfinIntro' => ({required Object serverName}) => '添加或移除 ${serverName} 的 URL。Plezy 会使用可访问且延迟最低的 URL。', 'discover.title' => '发现', 'discover.switchProfile' => '切换用户', 'discover.noContentAvailable' => '没有可用内容', @@ -2824,11 +2832,11 @@ extension on TranslationsZh { 'metadataEdit.contentRating' => '内容分级', 'metadataEdit.studio' => '制片厂', 'metadataEdit.tagline' => '标语', + _ => null, + } ?? switch (path) { 'metadataEdit.summary' => '简介', 'metadataEdit.poster' => '海报', 'metadataEdit.background' => '背景', - _ => null, - } ?? switch (path) { 'metadataEdit.logo' => '标志', 'metadataEdit.squareArt' => '方形图片', 'metadataEdit.selectPoster' => '选择海报', @@ -2947,7 +2955,9 @@ extension on TranslationsZh { 'trackers.libraryFilter.noLibraries' => '没有可用的媒体库', 'addServer.addJellyfinTitle' => '添加 Jellyfin 服务器', 'addServer.jellyfinUrlIntro' => '输入服务器 URL,例如 `https://jellyfin.example.com`。', + 'addServer.jellyfinUrlsIntro' => '输入一个或多个服务器 URL,用逗号或换行分隔。Plezy 会使用可访问且延迟最低的 URL。', 'addServer.serverUrl' => '服务器 URL', + 'addServer.serverUrls' => '服务器 URL', 'addServer.findServer' => '查找服务器', 'addServer.username' => '用户名', 'addServer.password' => '密码', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index e3d350d6..0894b09e 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -36,6 +36,7 @@ "yes": "Ja", "no": "Nej", "delete": "Ta bort", + "edit": "Redigera", "shuffle": "Blanda", "addTo": "Lägg till i...", "createNew": "Skapa ny", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "Lägg till för ${displayName}: Plex, Jellyfin eller en annan profilanslutning", "sessionExpiredOne": "Sessionen har gått ut för ${name}", "sessionExpiredMany": "Sessionen har gått ut för ${count} servrar", - "signInAgain": "Logga in igen" + "signInAgain": "Logga in igen", + "editJellyfinTitle": "Redigera Jellyfin-anslutning", + "editJellyfinIntro": "Lägg till eller ta bort URL:er för ${serverName}. Plezy använder den nåbara URL:en med lägst latens." }, "discover": { "title": "Upptäck", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "Lägg till Jellyfin-server", "jellyfinUrlIntro": "Ange server-URL, t.ex. `https://jellyfin.example.com`.", + "jellyfinUrlsIntro": "Ange en eller flera server-URL:er, separerade med kommatecken eller nya rader. Plezy använder den nåbara URL:en med lägst latens.", "serverUrl": "Server-URL", + "serverUrls": "Server-URL:er", "findServer": "Hitta server", "username": "Användarnamn", "password": "Lösenord", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 8b8a27cd..797a8c96 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -36,6 +36,7 @@ "yes": "是", "no": "否", "delete": "删除", + "edit": "编辑", "shuffle": "随机播放", "addTo": "添加到...", "createNew": "新建", @@ -609,7 +610,9 @@ "addConnectionSubtitleScoped": "添加到 ${displayName}: Plex、Jellyfin 或其他个人资料连接", "sessionExpiredOne": "${name} 的会话已过期", "sessionExpiredMany": "${count} 个服务器的会话已过期", - "signInAgain": "重新登录" + "signInAgain": "重新登录", + "editJellyfinTitle": "编辑 Jellyfin 连接", + "editJellyfinIntro": "添加或移除 ${serverName} 的 URL。Plezy 会使用可访问且延迟最低的 URL。" }, "discover": { "title": "发现", @@ -1256,7 +1259,9 @@ "addServer": { "addJellyfinTitle": "添加 Jellyfin 服务器", "jellyfinUrlIntro": "输入服务器 URL,例如 `https://jellyfin.example.com`。", + "jellyfinUrlsIntro": "输入一个或多个服务器 URL,用逗号或换行分隔。Plezy 会使用可访问且延迟最低的 URL。", "serverUrl": "服务器 URL", + "serverUrls": "服务器 URL", "findServer": "查找服务器", "username": "用户名", "password": "密码", diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 4a83ba8b..f4fcfafa 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -75,9 +75,9 @@ class ServerCapabilities { /// still works but choices don't follow the user across devices. final bool trackPreferencePersistence; - /// Multi-endpoint connection model with relay/local/remote racing - /// (Plex `findBestWorkingConnection`). Jellyfin servers expose a single - /// URL, so this is false there. + /// Multi-endpoint connection model with endpoint racing/failover. Plex gets + /// local/remote/relay candidates from plex.tv; Jellyfin uses user-entered + /// URLs for the same server. final bool endpointFailover; /// Watch progress can be queued offline and replayed when reconnected @@ -173,7 +173,7 @@ class ServerCapabilities { numericUserRating: false, externalSubtitleSearch: false, trackPreferencePersistence: true, - endpointFailover: false, + endpointFailover: true, offlineWatchQueue: false, discordRpc: false, richMetadataEdit: false, diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index 9f63fd48..a6a2891c 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -26,6 +26,7 @@ import '../../widgets/focusable_popup_menu_button.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../utils/dialogs.dart'; import '../settings/add_connection_screen.dart'; +import '../settings/edit_jellyfin_connection_screen.dart'; import 'pin_entry_dialog.dart'; import 'pin_status_row.dart'; import 'profile_delete_flow.dart'; @@ -130,6 +131,16 @@ class _ProfileDetailScreenState extends State with Controll unawaited(context.read().rebindIfActive(_profile.id)); } + Future _editConnection(Connection conn) async { + if (conn is! JellyfinConnection) return; + final changed = await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => EditJellyfinConnectionScreen(connection: conn))); + if (changed != true || !mounted) return; + setState(() {}); + unawaited(context.read().rebindIfActive(_profile.id)); + } + Set _serverIdsForConnection(Connection conn) { return switch (conn) { PlexAccountConnection(:final servers) => servers.map((s) => s.clientIdentifier).toSet(), @@ -228,7 +239,7 @@ class _ProfileDetailScreenState extends State with Controll ], ), const SizedBox(height: 8), - _ConnectionsList(profile: _profile, onRemove: _removeConnection), + _ConnectionsList(profile: _profile, onRemove: _removeConnection, onEdit: _editConnection), const SizedBox(height: 24), if (isLocal) FocusableButton( @@ -251,8 +262,9 @@ class _ProfileDetailScreenState extends State with Controll class _ConnectionsList extends StatelessWidget { final Profile profile; final Future Function(ProfileConnection pc, Connection conn) onRemove; + final Future Function(Connection conn) onEdit; - const _ConnectionsList({required this.profile, required this.onRemove}); + const _ConnectionsList({required this.profile, required this.onRemove, required this.onEdit}); @override Widget build(BuildContext context) { @@ -319,12 +331,16 @@ class _ConnectionsList extends StatelessWidget { onSelected: (value) { if (value == 'default') { unawaited(pcRegistry.setDefault(profile.id, pc.connectionId)); + } else if (value == 'edit') { + unawaited(onEdit(conn)); } else if (value == 'remove') { unawaited(onRemove(pc, conn)); } }, itemBuilder: (_) => [ if (!pc.isDefault) PopupMenuItem(value: 'default', child: Text(t.profiles.makeDefault)), + if (conn is JellyfinConnection) + PopupMenuItem(value: 'edit', child: Text(t.common.edit)), PopupMenuItem(value: 'remove', child: Text(t.profiles.removeConnection)), ], ), diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index ebf23436..02668348 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -19,6 +19,7 @@ import '../../profiles/profile.dart'; import '../../profiles/profile_connection.dart'; import '../../profiles/profile_registry.dart'; import '../../services/jellyfin_auth_service.dart'; +import '../../services/jellyfin_endpoint_discovery.dart'; import '../../services/storage_service.dart'; import '../../utils/app_logger.dart'; import '../../utils/platform_detector.dart'; @@ -47,7 +48,7 @@ bool shouldPromptForJellyfinProfileSelection({ } /// Three-step form to add a Jellyfin server: -/// 1. Probe URL (`/System/Info/Public`). +/// 1. Probe URL candidates (`/System/Info/Public`). /// 2. Username + password (`/Users/AuthenticateByName`) **or** Quick Connect /// (`/QuickConnect/Initiate` → poll → `/Users/AuthenticateWithQuickConnect`). /// 3. Persist via [ConnectionRegistry] and create a [ProfileConnection] @@ -84,6 +85,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta final _formKey = GlobalKey(); JellyfinServerInfo? _serverInfo; + JellyfinEndpointRaceResult? _serverEndpoint; bool _quickConnectEnabled = false; JellyfinQuickConnectInitiation? _qcInitiation; bool _qcCancelled = false; @@ -106,24 +108,22 @@ class _AddJellyfinScreenState extends State with AsyncFormSta } Future _probe() async { - final url = _urlController.text.trim(); - if (url.isEmpty) { + final urls = _enteredUrls(); + if (urls.isEmpty) { setErrorText(t.addServer.enterJellyfinUrlError); return; } await runAsync( () async { final auth = await _buildAuthService(); - // Run the probe and the QC capability check in parallel — the latter - // is independent and just tells the UI whether to surface the button. - final probeFuture = auth.probe(url); - final qcFuture = auth.isQuickConnectEnabled(url); - final info = await probeFuture; - final qcEnabled = await qcFuture; + final endpoint = await auth.raceEndpoints(urls); + final qcEnabled = await auth.isQuickConnectEnabled(endpoint.activeBaseUrl); if (!mounted) return; setState(() { - _serverInfo = info; + _serverEndpoint = endpoint; + _serverInfo = endpoint.serverInfo; _quickConnectEnabled = qcEnabled; + _urlController.text = endpoint.baseUrls.join('\n'); }); // On TV, typing a username/password with a remote is misery — auto-jump // to Quick Connect when the server supports it. Mirrors the @@ -140,7 +140,8 @@ class _AddJellyfinScreenState extends State with AsyncFormSta Future _signIn() async { if (!(_formKey.currentState?.validate() ?? false)) return; final info = _serverInfo; - if (info == null) { + final endpoint = _serverEndpoint; + if (info == null || endpoint == null) { await _probe(); return; } @@ -151,7 +152,8 @@ class _AddJellyfinScreenState extends State with AsyncFormSta final deviceId = await storage.getOrCreateClientIdentifier(); final connection = await auth.authenticateByName( - baseUrl: _urlController.text, + baseUrl: endpoint.activeBaseUrl, + baseUrls: endpoint.baseUrls, username: _usernameController.text, password: _passwordController.text, deviceId: deviceId, @@ -171,7 +173,8 @@ class _AddJellyfinScreenState extends State with AsyncFormSta Future _startQuickConnect() async { final info = _serverInfo; - if (info == null) return; + final endpoint = _serverEndpoint; + if (info == null || endpoint == null) return; final attemptId = ++_qcAttemptId; setState(() => _qcCancelled = false); await runAsync( @@ -180,7 +183,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta final storage = await StorageService.getInstance(); final deviceId = await storage.getOrCreateClientIdentifier(); - final initiation = await auth.initiateQuickConnect(baseUrl: _urlController.text, deviceId: deviceId); + final initiation = await auth.initiateQuickConnect(baseUrl: endpoint.activeBaseUrl, deviceId: deviceId); if (!_isCurrentQuickConnectAttempt(attemptId)) return; // Show the waiting panel without a spinner — opt-out of busy mid-flow // so the user-visible state matches "we're polling, nothing for you to do". @@ -188,7 +191,8 @@ class _AddJellyfinScreenState extends State with AsyncFormSta setBusy(false); final connection = await auth.authenticateByQuickConnect( - baseUrl: _urlController.text, + baseUrl: endpoint.activeBaseUrl, + baseUrls: endpoint.baseUrls, secret: initiation.secret, deviceId: deviceId, serverInfo: info, @@ -229,6 +233,14 @@ class _AddJellyfinScreenState extends State with AsyncFormSta setBusy(false); } + List _enteredUrls() { + return _urlController.text + .split(RegExp(r'[\n,]+')) + .map(JellyfinEndpointDiscovery.normalizeBaseUrl) + .where((url) => url.isNotEmpty) + .toList(growable: false); + } + /// Shared persistence path for both username/password and Quick Connect: /// upsert the connection, attach a ProfileConnection to the bound profile, /// register with the live manager when binding to the active profile, and @@ -347,24 +359,34 @@ class _AddJellyfinScreenState extends State with AsyncFormSta ]; } return [ - Text(t.addServer.jellyfinUrlIntro, style: theme.textTheme.bodyMedium), + Text(t.addServer.jellyfinUrlsIntro, style: theme.textTheme.bodyMedium), const SizedBox(height: 16), FocusableTextFormField( controller: _urlController, focusNode: _urlFocus, autofocus: true, keyboardType: TextInputType.url, + minLines: 1, + maxLines: 4, autocorrect: false, enableSuggestions: false, enabled: !busy, + onChanged: (_) { + if (_serverInfo == null && _serverEndpoint == null && !_quickConnectEnabled) return; + setState(() { + _serverEndpoint = null; + _serverInfo = null; + _quickConnectEnabled = false; + }); + }, onNavigateDown: _serverInfo == null ? () => _findServerFocus.requestFocus() : null, textInputAction: TextInputAction.go, onFieldSubmitted: busy ? null : (_) => _probe(), decoration: InputDecoration( - labelText: t.addServer.serverUrl, + labelText: t.addServer.serverUrls, prefixIcon: const AppIcon(Symbols.link_rounded, fill: 1), ), - validator: (v) => v == null || v.trim().isEmpty ? t.addServer.required : null, + validator: (_) => _enteredUrls().isEmpty ? t.addServer.required : null, ), if (_serverInfo == null) ...[ const SizedBox(height: 16), @@ -477,6 +499,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta onPressed: busy ? null : () => setState(() { + _serverEndpoint = null; _serverInfo = null; _quickConnectEnabled = false; }), diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart new file mode 100644 index 00000000..3cde8223 --- /dev/null +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../connection/connection.dart'; +import '../../connection/connection_registry.dart'; +import '../../exceptions/media_server_exceptions.dart'; +import '../../focus/focusable_button.dart'; +import '../../focus/focusable_text_field.dart'; +import '../../i18n/strings.g.dart'; +import '../../mixins/controller_disposer_mixin.dart'; +import '../../services/jellyfin_endpoint_discovery.dart'; +import '../../utils/app_logger.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/loading_indicator_box.dart'; +import 'async_form_state_mixin.dart'; + +class EditJellyfinConnectionScreen extends StatefulWidget { + final JellyfinConnection connection; + + const EditJellyfinConnectionScreen({super.key, required this.connection}); + + @override + State createState() => _EditJellyfinConnectionScreenState(); +} + +class _EditJellyfinConnectionScreenState extends State + with AsyncFormStateMixin, ControllerDisposerMixin { + late final _urlsController = createTextEditingController(text: widget.connection.baseUrls.join('\n')); + final _urlsFocus = FocusNode(debugLabel: 'EditJellyfin:Urls'); + final _saveFocus = FocusNode(debugLabel: 'EditJellyfin:Save'); + final _formKey = GlobalKey(); + + @override + void dispose() { + _urlsFocus.dispose(); + _saveFocus.dispose(); + super.dispose(); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + await runAsync( + () async { + final endpoint = await JellyfinEndpointDiscovery().raceEndpoints( + _enteredUrls(), + preferredUrl: widget.connection.baseUrl, + expectedMachineId: widget.connection.serverMachineId, + ); + final updated = widget.connection.copyWith( + baseUrl: endpoint.activeBaseUrl, + baseUrls: endpoint.baseUrls, + serverName: endpoint.serverInfo.serverName, + ); + if (!mounted) return; + await context.read().upsert(updated); + if (!mounted) return; + Navigator.of(context).pop(true); + }, + errorMapper: (e) { + if (e is MediaServerUrlException) return e.message; + appLogger.e('Edit Jellyfin connection failed', error: e); + return t.addServer.couldNotReachServer(error: e.toString()); + }, + ); + } + + List _enteredUrls() { + return _urlsController.text + .split(RegExp(r'[\n,]+')) + .map(JellyfinEndpointDiscovery.normalizeBaseUrl) + .where((url) => url.isNotEmpty) + .toList(growable: false); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FocusedScrollScaffold( + title: Text(t.connections.editJellyfinTitle), + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + sliver: SliverToBoxAdapter( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.connections.editJellyfinIntro(serverName: widget.connection.serverName), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + FocusableTextFormField( + controller: _urlsController, + focusNode: _urlsFocus, + autofocus: true, + keyboardType: TextInputType.url, + minLines: 1, + maxLines: 5, + autocorrect: false, + enableSuggestions: false, + enabled: !busy, + onNavigateDown: () => _saveFocus.requestFocus(), + decoration: InputDecoration( + labelText: t.addServer.serverUrls, + prefixIcon: const AppIcon(Symbols.link_rounded, fill: 1), + ), + validator: (_) => _enteredUrls().isEmpty ? t.addServer.required : null, + ), + const SizedBox(height: 16), + FocusableButton( + focusNode: _saveFocus, + useBackgroundFocus: true, + onPressed: busy ? null : _save, + onNavigateUp: () => _urlsFocus.requestFocus(), + child: FilledButton.icon( + onPressed: busy ? null : _save, + icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.save_rounded, fill: 1), + label: Text(t.common.save), + ), + ), + if (errorText != null) ...[ + const SizedBox(height: 12), + Text(errorText!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)), + ], + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/services/jellyfin_auth_service.dart b/lib/services/jellyfin_auth_service.dart index 08b6fff4..0b14f6b5 100644 --- a/lib/services/jellyfin_auth_service.dart +++ b/lib/services/jellyfin_auth_service.dart @@ -12,21 +12,8 @@ import '../utils/media_server_http_client.dart'; import '../utils/media_server_timeouts.dart'; import '../utils/log_redaction_manager.dart'; import '../utils/poll_with_backoff.dart'; -import '../utils/url_utils.dart'; import 'jellyfin_auth_header.dart'; - -/// Result of a successful Jellyfin URL probe (`/System/Info/Public`). -class JellyfinServerInfo { - final String serverName; - - /// Server's `Id` field — Jellyfin's machine identifier (UUID hex). - final String machineId; - - /// Server's reported version string. - final String version; - - const JellyfinServerInfo({required this.serverName, required this.machineId, required this.version}); -} +import 'jellyfin_endpoint_discovery.dart'; /// Result of `POST /QuickConnect/Initiate`. The [code] is shown to the user /// and entered in their Jellyfin web UI to approve sign-in; the [secret] is @@ -52,7 +39,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { required this.clientVersion, required this.deviceName, @visibleForTesting this._testHttpClientFactory, - }); + }) : _endpointDiscovery = JellyfinEndpointDiscovery(testHttpClientFactory: _testHttpClientFactory); /// App identity sent in the `MediaBrowser` Authorization header. Jellyfin /// uses `Client`/`Device`/`DeviceId`/`Version` to populate the device list @@ -69,6 +56,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { /// underlying client on `close()`. final http.Client Function()? _testHttpClientFactory; + final JellyfinEndpointDiscovery _endpointDiscovery; + MediaServerHttpClient _buildHttpClient({required String baseUrl, Map headers = const {}}) { LogRedactionManager.registerServerUrl(baseUrl); return MediaServerHttpClient(baseUrl: baseUrl, defaultHeaders: headers, client: _testHttpClientFactory?.call()); @@ -79,37 +68,15 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { /// before asking for credentials. Throws [MediaServerUrlException] when the /// URL is unreachable or doesn't look like a Jellyfin server. Future probe(String baseUrl) async { - final normalised = _normaliseBaseUrl(baseUrl); - final client = _buildHttpClient(baseUrl: normalised); - try { - final response = await client.get('/System/Info/Public', timeout: MediaServerTimeouts.jellyfinProbe); - throwIfHttpError(response); - final data = response.data; - if (data is! Map) { - throw MediaServerUrlException('Server response was not JSON'); - } - final id = data['Id']; - final name = data['ServerName'] ?? data['LocalAddress']; - if (id is! String || name is! String) { - throw MediaServerUrlException('Server response missing Id/ServerName — not a Jellyfin server?'); - } - return JellyfinServerInfo(serverName: name, machineId: id, version: data['Version'] as String? ?? ''); - } on MediaServerUrlException { - // Already the right shape — propagate without re-wrapping. - rethrow; - } on MediaServerHttpException catch (e) { - throw MediaServerUrlException('Server probe failed: ${e.message}'); - } on TimeoutException { - // Defensive: most request timeouts are wrapped by MediaServerHttpClient, - // but keep raw timeouts surfaced uniformly if one escapes. - throw MediaServerUrlException('Server did not respond in time'); - } catch (e) { - // Catch-all for transport errors that bypass the http client wrap - // (DNS failures, TLS handshake errors, etc.). - throw MediaServerUrlException('Server probe failed: $e'); - } finally { - client.close(); - } + return _endpointDiscovery.probe(baseUrl); + } + + Future raceEndpoints( + Iterable baseUrls, { + String? preferredUrl, + String? expectedMachineId, + }) { + return _endpointDiscovery.raceEndpoints(baseUrls, preferredUrl: preferredUrl, expectedMachineId: expectedMachineId); } /// Authenticate against [baseUrl] with [username]/[password] and return a @@ -117,6 +84,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { /// 401/403 responses; other transport errors propagate. Future authenticateByName({ required String baseUrl, + List? baseUrls, required String username, required String password, required String deviceId, @@ -167,6 +135,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { return _buildConnection( info: info, normalisedBaseUrl: normalised, + baseUrls: baseUrls, userId: userId, userName: userName, accessToken: accessToken, @@ -259,6 +228,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { /// [MediaServerAuthException] on auth failures (401/403). Future authenticateByQuickConnect({ required String baseUrl, + List? baseUrls, required String secret, required String deviceId, JellyfinServerInfo? serverInfo, @@ -355,6 +325,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { return _buildConnection( info: info, normalisedBaseUrl: normalised, + baseUrls: baseUrls, userId: userId, userName: userName, accessToken: accessToken, @@ -429,7 +400,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { /// Strip any trailing slash so subsequent path joins (`/Users/...`) don't /// produce double slashes. Delegates to the shared [stripTrailingSlash]. - static String _normaliseBaseUrl(String input) => stripTrailingSlash(input); + static String _normaliseBaseUrl(String input) => JellyfinEndpointDiscovery.normalizeBaseUrl(input); /// Build a [JellyfinConnection] from a successful auth/exchange response. /// Connection id is derived from `(machineId, userId)` so each user on a @@ -437,6 +408,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { static JellyfinConnection _buildConnection({ required JellyfinServerInfo info, required String normalisedBaseUrl, + List? baseUrls, required String userId, required String userName, required String accessToken, @@ -447,6 +419,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { return JellyfinConnection( id: '${info.machineId}/$userId', baseUrl: normalisedBaseUrl, + baseUrls: baseUrls, serverName: info.serverName, serverMachineId: info.machineId, userId: userId, diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 130bad2e..9c2a5af9 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -36,6 +36,7 @@ import '../models/media_subscription.dart'; import '../media/media_source_info.dart'; import '../media/media_sort.dart'; import '../utils/app_logger.dart'; +import '../utils/endpoint_failover_interceptor.dart'; import '../utils/log_redaction_manager.dart'; import '../utils/external_ids.dart'; import '../utils/media_server_http_client.dart'; @@ -89,9 +90,9 @@ class JellyfinClient JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository}) : _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository(); - /// Build a fully-initialised [JellyfinClient]. The factory probes - /// `/System/Info/Public` to confirm the server is reachable; callers can - /// catch a [MediaServerHttpException] to surface a clean "unavailable" UI. + /// Build a fully-initialised [JellyfinClient]. Endpoint reachability is + /// raced before construction by onboarding/profile binding; this factory + /// keeps network I/O lazy so URL-builder tests don't need a live server. /// /// Sends the full `Authorization: MediaBrowser …, Token="…"` header on /// every request — that's what the official Jellyfin SDK (and Findroid by @@ -131,8 +132,14 @@ class JellyfinClient // pin to the SDK's exact wire format up-front. 'Content-Type': 'application/json', }; - final http = MediaServerHttpClient(baseUrl: connection.baseUrl, defaultHeaders: headers); - final client = JellyfinClient._(connection: connection, http: http, favoritesRepository: favoritesRepository); + late JellyfinClient client; + final http = _JellyfinFailoverHttpClient( + baseUrl: connection.baseUrl, + defaultHeaders: headers, + prioritizedEndpoints: connection.baseUrls, + onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist), + ); + client = JellyfinClient._(connection: connection, http: http, favoritesRepository: favoritesRepository); return client; } @@ -144,12 +151,16 @@ class JellyfinClient required http.Client httpClient, FavoriteChannelsRepository? favoritesRepository, }) { - final mediaHttp = MediaServerHttpClient( + late JellyfinClient client; + final mediaHttp = _JellyfinFailoverHttpClient( baseUrl: connection.baseUrl, defaultHeaders: {'X-Emby-Token': connection.accessToken, 'Accept': 'application/json'}, + prioritizedEndpoints: connection.baseUrls, + onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist), client: httpClient, ); - return JellyfinClient._(connection: connection, http: mediaHttp, favoritesRepository: favoritesRepository); + client = JellyfinClient._(connection: connection, http: mediaHttp, favoritesRepository: favoritesRepository); + return client; } /// Mutable so [isHealthy] can refresh `Policy.IsAdministrator` from the @@ -168,6 +179,20 @@ class JellyfinClient /// to re-broadcast status so admin-gated UI rebuilds. FutureOr Function(JellyfinConnection connection)? onConnectionUpdated; + Future _handleEndpointSwitch(String newBaseUrl, {required bool persist}) async { + final changed = connection.baseUrl != newBaseUrl; + if (changed) { + appLogger.i('Applying Jellyfin endpoint switch', error: newBaseUrl); + _http.baseUrl = newBaseUrl; + _connection = _connection.copyWith(baseUrl: newBaseUrl); + LogRedactionManager.registerServer(newBaseUrl, connection.accessToken); + } + + if (persist) { + await onConnectionUpdated?.call(_connection); + } + } + /// Read-only view of the headers attached to every outgoing request. /// Test-only entry point for asserting the SDK-style `MediaBrowser` /// Authorization shape — Findroid (and the official SDK) sends the same @@ -319,3 +344,122 @@ class JellyfinClient @override ApiCache get cache => JellyfinApiCache.instance; } + +class _JellyfinFailoverHttpClient extends MediaServerHttpClient { + _JellyfinFailoverHttpClient({ + super.client, + required super.baseUrl, + required super.defaultHeaders, + required List prioritizedEndpoints, + required this.onEndpointSwitch, + }) : _endpointManager = prioritizedEndpoints.length > 1 ? EndpointFailoverManager(prioritizedEndpoints) : null; + + final EndpointFailoverManager? _endpointManager; + final Future Function(String newBaseUrl, {required bool persist}) onEndpointSwitch; + bool _failoverSwitching = false; + + @override + Future get( + String path, { + Map? queryParameters, + Map? headers, + Duration? timeout, + AbortController? abort, + }) async { + final gen = _endpointManager?.generation; + try { + final response = await super.get( + path, + queryParameters: queryParameters, + headers: headers, + timeout: timeout, + abort: abort, + ); + if (!_shouldAttemptFailover(statusCode: response.statusCode) || !_canFailover(gen)) { + return response; + } + return _retryNextEndpoint( + path, + queryParameters: queryParameters, + headers: headers, + timeout: timeout, + abort: abort, + ); + } on MediaServerHttpException catch (e) { + if (!_shouldAttemptFailover(exception: e) || !_canFailover(gen)) rethrow; + return _retryNextEndpoint( + path, + queryParameters: queryParameters, + headers: headers, + timeout: timeout, + abort: abort, + ); + } + } + + bool _canFailover(int? requestGeneration) { + final manager = _endpointManager; + return manager != null && !_failoverSwitching && requestGeneration == manager.generation; + } + + bool _shouldAttemptFailover({MediaServerHttpException? exception, int? statusCode}) { + final e = exception; + if (e != null) { + if (e.isTransient) return true; + final sc = e.statusCode; + return sc != null && sc >= 500 && sc <= 599; + } + final sc = statusCode; + return sc != null && sc >= 500 && sc <= 599; + } + + Future _retryNextEndpoint( + String path, { + Map? queryParameters, + Map? headers, + Duration? timeout, + AbortController? abort, + }) async { + final manager = _endpointManager; + if (manager == null) { + throw StateError('No Jellyfin failover endpoints configured'); + } + + if (!manager.hasFallback) { + manager.resetToFirst(); + throw MediaServerHttpException( + type: MediaServerHttpErrorType.connectionError, + message: 'All Jellyfin endpoints exhausted', + ); + } + + final failedEndpoint = manager.current; + final nextBaseUrl = manager.moveToNext(); + if (nextBaseUrl == null) { + throw MediaServerHttpException( + type: MediaServerHttpErrorType.connectionError, + message: 'All Jellyfin endpoints exhausted', + ); + } + + _failoverSwitching = true; + try { + appLogger.i('Switching Jellyfin endpoint after GET failure', error: {'from': failedEndpoint, 'to': nextBaseUrl}); + await onEndpointSwitch(nextBaseUrl, persist: false); + final response = await super.get( + path, + queryParameters: queryParameters, + headers: headers, + timeout: timeout, + abort: abort, + ); + if (response.statusCode < 400) { + appLogger.i('Jellyfin endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl}); + await onEndpointSwitch(nextBaseUrl, persist: true); + } + return response; + } finally { + _failoverSwitching = false; + } + } +} diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart new file mode 100644 index 00000000..d6a2b18b --- /dev/null +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; + +import '../exceptions/media_server_exceptions.dart'; +import '../utils/endpoint_race.dart'; +import '../utils/log_redaction_manager.dart'; +import '../utils/media_server_http_client.dart'; +import '../utils/media_server_timeouts.dart'; +import '../utils/url_utils.dart'; + +/// Result of a successful Jellyfin URL probe (`/System/Info/Public`). +class JellyfinServerInfo { + final String serverName; + + /// Server's `Id` field — Jellyfin's machine identifier (UUID hex). + final String machineId; + + /// Server's reported version string. + final String version; + + const JellyfinServerInfo({required this.serverName, required this.machineId, required this.version}); +} + +class JellyfinEndpointRaceResult { + final String activeBaseUrl; + final List baseUrls; + final JellyfinServerInfo serverInfo; + + const JellyfinEndpointRaceResult({required this.activeBaseUrl, required this.baseUrls, required this.serverInfo}); +} + +class JellyfinEndpointProbeResult { + final bool success; + final int latencyMs; + final JellyfinServerInfo? serverInfo; + final String? error; + + const JellyfinEndpointProbeResult({required this.success, required this.latencyMs, this.serverInfo, this.error}); +} + +class JellyfinEndpointCandidate { + final String url; + final int index; + + const JellyfinEndpointCandidate({required this.url, required this.index}); +} + +class JellyfinEndpointDiscovery { + JellyfinEndpointDiscovery({http.Client Function()? testHttpClientFactory}) + : _testHttpClientFactory = testHttpClientFactory; + + final http.Client Function()? _testHttpClientFactory; + + MediaServerHttpClient _buildHttpClient({required String baseUrl}) { + LogRedactionManager.registerServerUrl(baseUrl); + return MediaServerHttpClient(baseUrl: baseUrl, client: _testHttpClientFactory?.call()); + } + + /// Probe the server identified by [baseUrl] without authenticating. + Future probe(String baseUrl, {Duration timeout = MediaServerTimeouts.jellyfinProbe}) async { + final normalised = normalizeBaseUrl(baseUrl); + final client = _buildHttpClient(baseUrl: normalised); + try { + final response = await client.get('/System/Info/Public', timeout: timeout); + throwIfHttpError(response); + final data = response.data; + if (data is! Map) { + throw MediaServerUrlException('Server response was not JSON'); + } + final id = data['Id']; + final name = data['ServerName'] ?? data['LocalAddress']; + if (id is! String || name is! String) { + throw MediaServerUrlException('Server response missing Id/ServerName — not a Jellyfin server?'); + } + return JellyfinServerInfo(serverName: name, machineId: id, version: data['Version'] as String? ?? ''); + } on MediaServerUrlException { + rethrow; + } on MediaServerHttpException catch (e) { + throw MediaServerUrlException('Server probe failed: ${e.message}'); + } on TimeoutException { + throw MediaServerUrlException('Server did not respond in time'); + } catch (e) { + throw MediaServerUrlException('Server probe failed: $e'); + } finally { + client.close(); + } + } + + Future raceEndpoints( + Iterable baseUrls, { + String? preferredUrl, + String? expectedMachineId, + }) async { + final urls = normalizeBaseUrls(baseUrls); + if (urls.isEmpty) { + throw MediaServerUrlException('Enter at least one Jellyfin server URL'); + } + + 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)]; + + EndpointRaceSelection? firstSelection; + EndpointRaceSelection? bestSelection; + + await for (final selection in raceEndpointCandidates( + label: 'Jellyfin server URL', + candidates: candidates, + preferredUrl: preferred, + urlOf: (candidate) => candidate.url, + failureLogFields: (candidate, result) => {'error': result.error, 'latencyMs': result.latencyMs}, + probe: (candidate, timeout) => _probeWithLatency(candidate.url, timeout: timeout), + measure: (candidate) => _probeWithAverageLatency(candidate.url, attempts: 2), + isSuccess: (result) => result.success, + selectBestCandidate: (results) => _selectLowestLatencyCandidate(results), + )) { + if (selection.phase == EndpointRacePhase.first) { + firstSelection = selection; + } else { + bestSelection = selection; + } + } + + final selected = bestSelection ?? firstSelection; + final selectedInfo = selected?.result.serverInfo; + if (selected == null || selectedInfo == null) { + throw MediaServerUrlException('No reachable Jellyfin server found'); + } + + final Map successfulResults = + bestSelection?.successfulResults ?? firstSelection?.successfulResults ?? const {}; + final expected = expectedMachineId?.trim().isNotEmpty == true ? expectedMachineId!.trim() : selectedInfo.machineId; + for (final result in successfulResults.values) { + final info = result.serverInfo; + if (info != null && info.machineId != expected) { + throw MediaServerUrlException('The URLs point to different Jellyfin servers'); + } + } + + if (selectedInfo.machineId != expected) { + throw MediaServerUrlException('The URL does not match this Jellyfin server'); + } + + return JellyfinEndpointRaceResult( + activeBaseUrl: selected.candidate.url, + baseUrls: _activeFirst(selected.candidate.url, urls), + serverInfo: selectedInfo, + ); + } + + Future _probeWithLatency(String baseUrl, {required Duration timeout}) async { + final stopwatch = Stopwatch()..start(); + try { + final info = await probe(baseUrl, timeout: timeout); + stopwatch.stop(); + return JellyfinEndpointProbeResult(success: true, latencyMs: stopwatch.elapsedMilliseconds, serverInfo: info); + } catch (e) { + stopwatch.stop(); + return JellyfinEndpointProbeResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.toString()); + } + } + + Future _probeWithAverageLatency(String baseUrl, {required int attempts}) async { + final results = []; + JellyfinServerInfo? info; + for (var i = 0; i < attempts; i++) { + final result = await _probeWithLatency(baseUrl, timeout: MediaServerTimeouts.connectionRace); + if (!result.success) { + return JellyfinEndpointProbeResult(success: false, latencyMs: result.latencyMs, error: result.error); + } + info = result.serverInfo; + results.add(result); + } + final avgLatency = results.map((result) => result.latencyMs).reduce((a, b) => a + b) ~/ results.length; + return JellyfinEndpointProbeResult(success: true, latencyMs: avgLatency, serverInfo: info); + } + + JellyfinEndpointCandidate? _selectLowestLatencyCandidate( + Map results, + ) { + if (results.isEmpty) return null; + final entries = results.entries.toList() + ..sort((a, b) { + final latency = a.value.latencyMs.compareTo(b.value.latencyMs); + if (latency != 0) return latency; + return a.key.index.compareTo(b.key.index); + }); + return entries.first.key; + } + + static String normalizeBaseUrl(String input) => stripTrailingSlash(input); + + static List normalizeBaseUrls(Iterable input) { + final result = []; + final seen = {}; + for (final raw in input) { + final normalized = normalizeBaseUrl(raw); + if (normalized.isEmpty || !seen.add(normalized)) continue; + result.add(normalized); + } + return List.unmodifiable(result); + } + + static List _activeFirst(String activeBaseUrl, List urls) { + final result = []; + final seen = {}; + void add(String url) { + if (url.isEmpty || !seen.add(url)) return; + result.add(url); + } + + add(activeBaseUrl); + for (final url in urls) { + add(url); + } + return List.unmodifiable(result); + } +} diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 24346adc..1a2111ef 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import '../connection/connection.dart'; import '../media/media_server_client.dart'; import 'jellyfin_client.dart'; +import 'jellyfin_endpoint_discovery.dart'; import 'plex_client.dart'; import '../models/plex/plex_config.dart'; import '../utils/app_logger.dart'; @@ -485,9 +486,9 @@ class MultiServerManager { /// Add a Jellyfin server backed by an authenticated [JellyfinConnection]. /// Returns true on success. /// - /// Jellyfin clients aren't part of the Plex connection-racing flow — they - /// have a single configured base URL — so they bypass the - /// [_createClientForServer] / [findBestWorkingConnection] logic. + /// Jellyfin clients use the shared endpoint-racing flow when multiple URLs + /// are configured, then instantiate the client against the lowest-latency + /// reachable URL. /// /// Two users on the same Jellyfin server are tracked separately in /// [_jellyfinByCompoundId]; only one is "active" per machineId at a time. @@ -495,12 +496,34 @@ class MultiServerManager { /// client (preserves any in-flight operations on the prior profile). Future addJellyfinConnection(JellyfinConnection connection) async { try { - final client = await JellyfinClient.create(connection); + var resolvedConnection = connection; + if (connection.baseUrls.length > 1) { + try { + final endpoint = await JellyfinEndpointDiscovery().raceEndpoints( + connection.baseUrls, + preferredUrl: connection.baseUrl, + expectedMachineId: connection.serverMachineId, + ); + resolvedConnection = connection.copyWith( + baseUrl: endpoint.activeBaseUrl, + baseUrls: endpoint.baseUrls, + serverName: endpoint.serverInfo.serverName, + ); + } catch (e, st) { + appLogger.w('Jellyfin endpoint race failed; using stored active URL', error: e, stackTrace: st); + } + } + + final client = await JellyfinClient.create(resolvedConnection); // Admin status can change server-side; re-broadcast and persist so // admin-gated UI survives app restarts without requiring re-auth. _wireJellyfinConnectionUpdates(client); - final compoundId = connection.id; - final machineId = connection.serverMachineId; + if (resolvedConnection.baseUrl != connection.baseUrl || + !listEquals(resolvedConnection.baseUrls, connection.baseUrls)) { + await onJellyfinConnectionUpdated?.call(resolvedConnection); + } + final compoundId = resolvedConnection.id; + final machineId = resolvedConnection.serverMachineId; // Replace any prior client for this exact compound id (re-add of the // same user — e.g., token refresh or settings re-add). @@ -519,7 +542,7 @@ class MultiServerManager { _jellyfinHealthByCompoundId[compoundId] = health; _applyHealth(machineId, health); - appLogger.i('Added Jellyfin server: ${connection.serverName}${healthy ? '' : ' (unhealthy)'}'); + appLogger.i('Added Jellyfin server: ${resolvedConnection.serverName}${healthy ? '' : ' (unhealthy)'}'); if (_connectivitySubscription == null && healthy) { _startNetworkMonitoring(); } @@ -736,8 +759,8 @@ class MultiServerManager { } } - // Jellyfin has no endpoint-racing — only offline servers need a reprobe. - // Online ones are left alone; checkServerHealth runs on the same tick. + // Jellyfin re-probes offline servers here. Online clients keep their current + // endpoint and can still fail over per request through JellyfinClient. for (final entry in _activeJellyfinMachine.entries) { final serverId = entry.key; if (_activeOptimizations.containsKey(serverId)) continue; diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 83dcc0f8..b4ec04bd 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -9,6 +9,7 @@ import '../models/plex/plex_user_profile.dart'; import '../models/plex/plex_home.dart'; import '../models/user_switch_response.dart'; import '../utils/app_logger.dart'; +import '../utils/endpoint_race.dart'; import '../utils/media_server_timeouts.dart'; import '../utils/media_server_http_client.dart'; import '../utils/poll_with_backoff.dart'; @@ -427,9 +428,6 @@ class PlexServer { return; } - const preferredTimeout = MediaServerTimeouts.preferredEndpointProbe; - const raceTimeout = MediaServerTimeouts.connectionRace; - final candidates = _buildPrioritizedCandidates(); if (candidates.isEmpty) { appLogger.w('No connection candidates generated for server discovery'); @@ -456,150 +454,66 @@ class PlexServer { ); } - _ConnectionCandidate? firstCandidate; - - // Fast-path: if we have a cached working URI, probe it with a short timeout - if (preferredUri != null) { - final cachedCandidate = _candidateForUrl(preferredUri); - if (cachedCandidate != null) { - appLogger.d('Testing cached endpoint before running full race', error: {'uri': preferredUri}); - final result = await PlexClient.testConnectionWithLatency( - cachedCandidate.url, - accessToken, - timeout: preferredTimeout, + PlexConnection? firstConnection; + await for (final selection in raceEndpointCandidates<_ConnectionCandidate, ConnectionTestResult>( + label: 'Plex server connection', + candidates: candidates, + preferredUrl: preferredUri, + candidateForUrl: _candidateForUrl, + urlOf: (candidate) => candidate.url, + displayTypeOf: (candidate) => candidate.connection.displayType, + failureLogFields: (candidate, result) => { + 'https': candidate.isHttps, + 'error': result.error, + 'latencyMs': result.latencyMs, + }, + probe: (candidate, timeout) => PlexClient.testConnectionWithLatency( + candidate.url, + accessToken, + timeout: timeout, + clientIdentifier: clientIdentifier, + ), + measure: (candidate) => PlexClient.testConnectionWithAverageLatency( + candidate.url, + accessToken, + attempts: 2, + clientIdentifier: clientIdentifier, + ), + isSuccess: (result) => result.success, + selectBestCandidate: _selectBestCandidateWithLatency, + onFirstSuccess: (_, result) { + if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!); + }, + )) { + if (selection.phase == EndpointRacePhase.first) { + final firstCandidate = selection.candidate; + final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible( + firstCandidate, clientIdentifier: clientIdentifier, ); + final emitCandidate = upgradedFirstCandidate ?? firstCandidate; - if (result.success) { - appLogger.i('Cached endpoint succeeded, using immediately', error: {'uri': preferredUri}); - firstCandidate = cachedCandidate; - if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!); - } else { - appLogger.w('Cached endpoint failed, falling back to candidate race', error: {'uri': preferredUri}); + firstConnection = _updateConnectionUrl(emitCandidate.connection, emitCandidate.url); + yield firstConnection; + if (upgradedFirstCandidate != null && upgradedFirstCandidate.url != firstCandidate.url) { + appLogger.i( + 'Phase 1 winner upgraded to HTTPS', + error: {'from': firstCandidate.url, 'to': upgradedFirstCandidate.url}, + ); } - } - } - - // If no cached candidate or it failed, race candidates to find first success - if (firstCandidate == null) { - final completer = Completer<_ConnectionCandidate?>(); - int completedTests = 0; - - appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates}); - - for (final candidate in candidates) { - unawaited( - PlexClient.testConnectionWithLatency( - candidate.url, - accessToken, - timeout: raceTimeout, - clientIdentifier: clientIdentifier, - ).then((result) { - completedTests++; - - if (!result.success) { - appLogger.w( - 'Connection candidate failed', - error: { - 'url': candidate.url, - 'type': candidate.connection.displayType, - 'https': candidate.isHttps, - 'error': result.error, - 'latencyMs': result.latencyMs, - }, - ); - } - - if (result.success && !completer.isCompleted) { - if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!); - completer.complete(candidate); - } - - if (completedTests == candidates.length && !completer.isCompleted) { - completer.complete(null); - } - }), + appLogger.d( + 'Emitted first working connection, continuing latency tests in background', + error: {'uri': firstConnection.uri}, ); + continue; } - firstCandidate = await completer.future; - if (firstCandidate == null) { - appLogger.e( - 'No working server connections after race', - error: { - 'server': name, - 'candidateCount': totalCandidates, - 'types': candidates.map((c) => c.connection.displayType).toSet().toList(), - }, - ); - return; // No working connections found - } - appLogger.i( - 'Connection race found first working endpoint', - error: {'uri': firstCandidate.url, 'type': firstCandidate.connection.displayType}, - ); - } - - // Attempt HTTPS upgrade on the Phase 1 winner before emitting - final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible( - firstCandidate, - clientIdentifier: clientIdentifier, - ); - final emitCandidate = upgradedFirstCandidate ?? firstCandidate; - - final firstConnection = _updateConnectionUrl(emitCandidate.connection, emitCandidate.url); - yield firstConnection; - if (upgradedFirstCandidate != null && upgradedFirstCandidate.url != firstCandidate.url) { - appLogger.i( - 'Phase 1 winner upgraded to HTTPS', - error: {'from': firstCandidate.url, 'to': upgradedFirstCandidate.url}, - ); - } - appLogger.d( - 'Emitted first working connection, continuing latency tests in background', - error: {'uri': firstConnection.uri}, - ); - - // Phase 2: Continue testing in background to find best connection - // Test each candidate 2-3 times and average the latency - final candidateResults = <_ConnectionCandidate, ConnectionTestResult>{}; - - await Future.wait( - candidates.map((candidate) async { - final result = await PlexClient.testConnectionWithAverageLatency( - candidate.url, - accessToken, - attempts: 2, - clientIdentifier: clientIdentifier, - ); - - if (result.success) { - candidateResults[candidate] = result; - } - }), - ); - - // If no connections succeeded, we're done - if (candidateResults.isEmpty) { - appLogger.w('Latency sweep found no additional working endpoints'); - return; - } - - appLogger.d( - 'Completed latency sweep for server connections', - error: {'successfulCandidates': candidateResults.length}, - ); - - // Find the best connection considering priority, latency, and URL type - final bestCandidate = _selectBestCandidateWithLatency(candidateResults); - - // Emit the best connection if it's different from the first one - if (bestCandidate != null) { + final bestCandidate = selection.candidate; final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate, clientIdentifier: clientIdentifier) ?? bestCandidate; final bestConnection = _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url); - if (bestConnection.uri != firstConnection.uri) { + if (firstConnection == null || bestConnection.uri != firstConnection.uri) { appLogger.i('Latency sweep selected better endpoint', error: {'uri': bestConnection.uri}); yield bestConnection; } else { diff --git a/lib/utils/endpoint_race.dart b/lib/utils/endpoint_race.dart new file mode 100644 index 00000000..029f4beb --- /dev/null +++ b/lib/utils/endpoint_race.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'app_logger.dart'; +import 'media_server_timeouts.dart'; + +enum EndpointRacePhase { first, best } + +class EndpointRaceSelection { + final EndpointRacePhase phase; + final C candidate; + final R result; + final bool fromPreferred; + final Map successfulResults; + + const EndpointRaceSelection({ + required this.phase, + required this.candidate, + required this.result, + this.fromPreferred = false, + this.successfulResults = const {}, + }); +} + +/// Shared two-phase endpoint discovery used by Plex and Jellyfin. +/// +/// Phase 1 emits the first reachable endpoint quickly, using a cached/preferred +/// endpoint first when available. Phase 2 measures all candidates and emits the +/// selector's best endpoint, letting callers promote a lower-latency URL in the +/// background without blocking initial connection setup. +Stream> raceEndpointCandidates({ + required String label, + required List candidates, + required String Function(C candidate) urlOf, + String Function(C candidate)? displayTypeOf, + Map Function(C candidate, R result)? failureLogFields, + String? preferredUrl, + C? Function(String url)? candidateForUrl, + required Future Function(C candidate, Duration timeout) probe, + required Future Function(C candidate) measure, + required bool Function(R result) isSuccess, + required C? Function(Map successfulResults) selectBestCandidate, + void Function(C candidate, R result)? onFirstSuccess, + Duration preferredTimeout = MediaServerTimeouts.preferredEndpointProbe, + Duration raceTimeout = MediaServerTimeouts.connectionRace, +}) async* { + if (candidates.isEmpty) { + appLogger.w('No endpoint candidates available for $label discovery'); + return; + } + + C? firstCandidate; + R? firstResult; + var fromPreferred = false; + + if (preferredUrl != null && preferredUrl.isNotEmpty) { + final cachedCandidate = candidateForUrl?.call(preferredUrl) ?? _candidateForUrl(candidates, urlOf, preferredUrl); + if (cachedCandidate != null) { + appLogger.d('Testing cached $label endpoint before running full race', error: {'uri': preferredUrl}); + final result = await probe(cachedCandidate, preferredTimeout); + + if (isSuccess(result)) { + appLogger.i('Cached $label endpoint succeeded, using immediately', error: {'uri': preferredUrl}); + firstCandidate = cachedCandidate; + firstResult = result; + fromPreferred = true; + onFirstSuccess?.call(cachedCandidate, result); + } else { + appLogger.w('Cached $label endpoint failed, falling back to candidate race', error: {'uri': preferredUrl}); + } + } + } + + if (firstCandidate == null || firstResult == null) { + final first = await _raceFirstSuccess( + label: label, + candidates: candidates, + urlOf: urlOf, + displayTypeOf: displayTypeOf, + failureLogFields: failureLogFields, + probe: probe, + isSuccess: isSuccess, + onFirstSuccess: onFirstSuccess, + timeout: raceTimeout, + ); + if (first == null) { + appLogger.e('No working $label endpoints after race', error: {'candidateCount': candidates.length}); + return; + } + appLogger.i( + '$label race found first working endpoint', + error: {'uri': urlOf(first.candidate), 'type': displayTypeOf?.call(first.candidate)}, + ); + firstCandidate = first.candidate; + firstResult = first.result; + } + + final resolvedFirstCandidate = firstCandidate; + final resolvedFirstResult = firstResult; + if (resolvedFirstCandidate == null || resolvedFirstResult == null) return; + + yield EndpointRaceSelection( + phase: EndpointRacePhase.first, + candidate: resolvedFirstCandidate, + result: resolvedFirstResult, + fromPreferred: fromPreferred, + successfulResults: {resolvedFirstCandidate: resolvedFirstResult}, + ); + + final successfulResults = {}; + await Future.wait( + candidates.map((candidate) async { + final result = await measure(candidate); + if (isSuccess(result)) { + successfulResults[candidate] = result; + } + }), + ); + + if (successfulResults.isEmpty) { + appLogger.w('$label latency sweep found no additional working endpoints'); + return; + } + + appLogger.d( + 'Completed latency sweep for $label endpoints', + error: {'successfulCandidates': successfulResults.length}, + ); + + final bestCandidate = selectBestCandidate(successfulResults); + if (bestCandidate == null) return; + final bestResult = successfulResults[bestCandidate]; + if (bestResult == null) return; + + yield EndpointRaceSelection( + phase: EndpointRacePhase.best, + candidate: bestCandidate, + result: bestResult, + successfulResults: Map.unmodifiable(successfulResults), + ); +} + +C? _candidateForUrl(List candidates, String Function(C candidate) urlOf, String url) { + for (final candidate in candidates) { + if (urlOf(candidate) == url) return candidate; + } + return null; +} + +Future<({C candidate, R result})?> _raceFirstSuccess({ + required String label, + required List candidates, + required String Function(C candidate) urlOf, + required Future Function(C candidate, Duration timeout) probe, + required bool Function(R result) isSuccess, + required Duration timeout, + String Function(C candidate)? displayTypeOf, + Map Function(C candidate, R result)? failureLogFields, + void Function(C candidate, R result)? onFirstSuccess, +}) async { + final completer = Completer<({C candidate, R result})?>(); + var completedTests = 0; + + appLogger.d( + 'Running $label endpoint race to find first working endpoint', + error: {'candidateCount': candidates.length}, + ); + + for (final candidate in candidates) { + unawaited( + probe(candidate, timeout) + .then((result) { + completedTests++; + + if (!isSuccess(result)) { + appLogger.w( + '$label endpoint candidate failed', + error: { + 'url': urlOf(candidate), + 'type': displayTypeOf?.call(candidate), + ...?failureLogFields?.call(candidate, result), + }, + ); + } + + if (isSuccess(result) && !completer.isCompleted) { + onFirstSuccess?.call(candidate, result); + completer.complete((candidate: candidate, result: result)); + } + + if (completedTests == candidates.length && !completer.isCompleted) { + completer.complete(null); + } + }) + .catchError((Object error, StackTrace stackTrace) { + completedTests++; + appLogger.w( + '$label endpoint candidate threw during race', + error: {'url': urlOf(candidate), 'error': error.toString()}, + stackTrace: stackTrace, + ); + if (completedTests == candidates.length && !completer.isCompleted) { + completer.complete(null); + } + }), + ); + } + + return completer.future; +} diff --git a/test/connection/connection_models_test.dart b/test/connection/connection_models_test.dart index 31f8effc..3bd5c3ae 100644 --- a/test/connection/connection_models_test.dart +++ b/test/connection/connection_models_test.dart @@ -29,6 +29,7 @@ void main() { final base = JellyfinConnection( id: 'srv-1/user-1', baseUrl: 'https://jellyfin.example.com', + baseUrls: const ['https://jellyfin.example.com', 'https://jellyfin.lan:8096'], serverName: 'Home', serverMachineId: 'srv-1', userId: 'user-1', @@ -50,6 +51,7 @@ void main() { ); expect(restored.id, base.id); expect(restored.baseUrl, base.baseUrl); + expect(restored.baseUrls, base.baseUrls); expect(restored.serverName, base.serverName); expect(restored.serverMachineId, base.serverMachineId); expect(restored.userId, base.userId); @@ -69,10 +71,34 @@ void main() { ); expect(restored.id, 'orphan'); expect(restored.baseUrl, ''); + expect(restored.baseUrls, isEmpty); expect(restored.serverName, 'Jellyfin'); expect(restored.accessToken, ''); }); + test('fromConfigJson backfills baseUrls from legacy baseUrl', () { + final restored = JellyfinConnection.fromConfigJson( + id: 'legacy', + json: const { + 'baseUrl': 'https://jellyfin.example.com', + 'serverName': 'Home', + 'serverMachineId': 'srv-1', + 'userId': 'user-1', + }, + status: ConnectionStatus.unknown, + createdAt: DateTime.utc(2026), + ); + + expect(restored.baseUrl, 'https://jellyfin.example.com'); + expect(restored.baseUrls, ['https://jellyfin.example.com']); + }); + + test('copyWith moves the active baseUrl to the front of baseUrls', () { + final updated = base.copyWith(baseUrl: 'https://jellyfin.lan:8096'); + expect(updated.baseUrl, 'https://jellyfin.lan:8096'); + expect(updated.baseUrls, ['https://jellyfin.lan:8096', 'https://jellyfin.example.com']); + }); + test('kind and backend match Jellyfin', () { expect(base.kind, ConnectionKind.jellyfin); expect(base.backend, MediaBackend.jellyfin); diff --git a/test/services/jellyfin_auth_service_test.dart b/test/services/jellyfin_auth_service_test.dart index 558a98d6..dd395605 100644 --- a/test/services/jellyfin_auth_service_test.dart +++ b/test/services/jellyfin_auth_service_test.dart @@ -111,11 +111,14 @@ void main() { final conn = await svc.authenticateByName( baseUrl: 'https://jf.example.com', + baseUrls: const ['https://jf.example.com', 'https://jf.lan:8096'], username: 'edde', password: 'pw', deviceId: 'dev-xyz', ); expect(conn.accessToken, 'tok-new'); + expect(conn.baseUrl, 'https://jf.example.com'); + expect(conn.baseUrls, ['https://jf.example.com', 'https://jf.lan:8096']); expect(conn.userId, 'user-7'); expect(conn.userName, 'edde'); expect(conn.serverMachineId, 'srv-1'); diff --git a/test/services/jellyfin_client_failures_test.dart b/test/services/jellyfin_client_failures_test.dart index 1b04bda6..ab70a66a 100644 --- a/test/services/jellyfin_client_failures_test.dart +++ b/test/services/jellyfin_client_failures_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -8,9 +11,10 @@ import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; -JellyfinConnection _conn() => JellyfinConnection( +JellyfinConnection _conn({String baseUrl = 'https://jf.example.com', List? baseUrls}) => JellyfinConnection( id: 'srv-1/user-1', - baseUrl: 'https://jf.example.com', + baseUrl: baseUrl, + baseUrls: baseUrls, serverName: 'Home', serverMachineId: 'srv-1', userId: 'user-1', @@ -136,4 +140,29 @@ void main() { client.close(); }); }); + + group('JellyfinClient endpoint failover', () { + test('switches to the fallback URL after a transient GET failure', () async { + final requests = []; + final client = JellyfinClient.forTesting( + connection: _conn( + baseUrl: 'https://primary.example.com', + baseUrls: const ['https://primary.example.com', 'https://fallback.example.com'], + ), + httpClient: MockClient((req) async { + requests.add(req.url); + if (req.url.host == 'primary.example.com') { + throw TimeoutException('primary down'); + } + return http.Response(jsonEncode({'Id': 'srv-1'}), 200, headers: {'content-type': 'application/json'}); + }), + ); + addTearDown(client.close); + + expect(await client.getMachineIdentifier(), 'srv-1'); + expect(requests.map((uri) => uri.host), ['primary.example.com', 'fallback.example.com']); + expect(client.connection.baseUrl, 'https://fallback.example.com'); + expect(client.connection.baseUrls, ['https://fallback.example.com', 'https://primary.example.com']); + }); + }); } diff --git a/test/services/jellyfin_endpoint_discovery_test.dart b/test/services/jellyfin_endpoint_discovery_test.dart new file mode 100644 index 00000000..c7a64c64 --- /dev/null +++ b/test/services/jellyfin_endpoint_discovery_test.dart @@ -0,0 +1,89 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/services/jellyfin_endpoint_discovery.dart'; + +http.Response _info({required String id, String name = 'Home'}) => http.Response( + jsonEncode({'Id': id, 'ServerName': name, 'Version': '10.9.0'}), + 200, + headers: {'content-type': 'application/json'}, +); + +void main() { + group('JellyfinEndpointDiscovery', () { + test('normalizes and deduplicates endpoint URLs', () { + expect( + JellyfinEndpointDiscovery.normalizeBaseUrls([ + ' https://jf.example.com/ ', + 'https://jf.example.com', + '', + 'https://jf.lan:8096/', + ]), + ['https://jf.example.com', 'https://jf.lan:8096'], + ); + }); + + test('races URLs and selects the lowest-latency reachable endpoint', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((req) async { + if (req.url.host == 'slow.example.com') { + await Future.delayed(const Duration(milliseconds: 35)); + } else { + await Future.delayed(const Duration(milliseconds: 1)); + } + return _info(id: 'srv-1'); + }), + ); + + final result = await discovery.raceEndpoints(['https://slow.example.com', 'https://fast.example.com']); + + expect(result.activeBaseUrl, 'https://fast.example.com'); + expect(result.baseUrls, ['https://fast.example.com', 'https://slow.example.com']); + expect(result.serverInfo.machineId, 'srv-1'); + }); + + test('keeps unreachable URLs but validates every reachable URL is the same server', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((req) async { + if (req.url.host == 'offline.example.com') { + throw TimeoutException('offline'); + } + return _info(id: 'srv-1'); + }), + ); + + final result = await discovery.raceEndpoints(['https://offline.example.com', 'https://jf.example.com']); + + expect(result.activeBaseUrl, 'https://jf.example.com'); + expect(result.baseUrls, ['https://jf.example.com', 'https://offline.example.com']); + }); + + test('rejects reachable URLs that point to different Jellyfin servers', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((req) async { + return _info(id: req.url.host == 'one.example.com' ? 'srv-1' : 'srv-2'); + }), + ); + + await expectLater( + discovery.raceEndpoints(['https://one.example.com', 'https://two.example.com']), + throwsA(isA()), + ); + }); + + test('rejects URLs that do not match an expected existing server id', () async { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((_) async => _info(id: 'srv-2')), + ); + + await expectLater( + discovery.raceEndpoints(['https://jf.example.com'], expectedMachineId: 'srv-1'), + throwsA(isA()), + ); + }); + }); +}