feat(jellyfin): support multiple server urls

This commit is contained in:
edde746
2026-05-28 03:43:46 +02:00
parent 0b29673a5e
commit 6642348a96
46 changed files with 1367 additions and 291 deletions
+42 -5
View File
@@ -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<String> 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<String>? 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<String> _normalizeBaseUrls(String activeBaseUrl, List<String>? urls) {
final result = <String>[];
final seen = <String>{};
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 <String>[]) {
add(url);
}
return List.unmodifiable(result);
}
JellyfinConnection copyWith({
String? id,
String? baseUrl,
List<String>? 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<String, Object?> 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<String>().toList(growable: false) : const <String>[];
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? ?? '',
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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": "パスワード",
+6 -1
View File
@@ -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": "비밀번호",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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": "Пароль",
+1 -1
View File
@@ -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
+13 -3
View File
@@ -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',
+13 -3
View File
@@ -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',
+23 -3
View File
@@ -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',
+13 -3
View File
@@ -143,6 +143,7 @@ class _TranslationsCommonEs extends TranslationsCommonEn {
@override String get yes => '';
@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' => '',
'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',
+13 -3
View File
@@ -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',
+13 -3
View File
@@ -143,6 +143,7 @@ class _TranslationsCommonIt extends TranslationsCommonEn {
@override String get yes => '';
@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' => '',
'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',
+13 -3
View File
@@ -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' => 'パスワード',
+13 -3
View File
@@ -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' => '비밀번호',
+13 -3
View File
@@ -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',
+13 -3
View File
@@ -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',
+13 -3
View File
@@ -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',
+13 -3
View File
@@ -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',
+13 -3
View File
@@ -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' => 'Пароль',
+13 -3
View File
@@ -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',
+13 -3
View File
@@ -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' => '密码',
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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": "密码",
+4 -4
View File
@@ -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,
+18 -2
View File
@@ -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<ProfileDetailScreen> with Controll
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(_profile.id));
}
Future<void> _editConnection(Connection conn) async {
if (conn is! JellyfinConnection) return;
final changed = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => EditJellyfinConnectionScreen(connection: conn)));
if (changed != true || !mounted) return;
setState(() {});
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(_profile.id));
}
Set<String> _serverIdsForConnection(Connection conn) {
return switch (conn) {
PlexAccountConnection(:final servers) => servers.map((s) => s.clientIdentifier).toSet(),
@@ -228,7 +239,7 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> 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<ProfileDetailScreen> with Controll
class _ConnectionsList extends StatelessWidget {
final Profile profile;
final Future<void> Function(ProfileConnection pc, Connection conn) onRemove;
final Future<void> 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)),
],
),
+41 -18
View File
@@ -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<AddJellyfinScreen> with AsyncFormSta
final _formKey = GlobalKey<FormState>();
JellyfinServerInfo? _serverInfo;
JellyfinEndpointRaceResult? _serverEndpoint;
bool _quickConnectEnabled = false;
JellyfinQuickConnectInitiation? _qcInitiation;
bool _qcCancelled = false;
@@ -106,24 +108,22 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
}
Future<void> _probe() async {
final url = _urlController.text.trim();
if (url.isEmpty) {
final urls = _enteredUrls();
if (urls.isEmpty) {
setErrorText(t.addServer.enterJellyfinUrlError);
return;
}
await runAsync<void>(
() 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<AddJellyfinScreen> with AsyncFormSta
Future<void> _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<AddJellyfinScreen> 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<AddJellyfinScreen> with AsyncFormSta
Future<void> _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<void>(
@@ -180,7 +183,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> with AsyncFormSta
setBusy(false);
}
List<String> _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<AddJellyfinScreen> 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<AddJellyfinScreen> with AsyncFormSta
onPressed: busy
? null
: () => setState(() {
_serverEndpoint = null;
_serverInfo = null;
_quickConnectEnabled = false;
}),
@@ -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<EditJellyfinConnectionScreen> createState() => _EditJellyfinConnectionScreenState();
}
class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScreen>
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<FormState>();
@override
void dispose() {
_urlsFocus.dispose();
_saveFocus.dispose();
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
await runAsync<void>(
() 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<ConnectionRegistry>().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<String> _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)),
],
],
),
),
),
),
],
);
}
}
+20 -47
View File
@@ -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<String, String> 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<JellyfinServerInfo> 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<String, dynamic>) {
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<JellyfinEndpointRaceResult> raceEndpoints(
Iterable<String> 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<JellyfinConnection> authenticateByName({
required String baseUrl,
List<String>? 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<JellyfinConnection?> authenticateByQuickConnect({
required String baseUrl,
List<String>? 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<String>? 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,
+151 -7
View File
@@ -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<void> Function(JellyfinConnection connection)? onConnectionUpdated;
Future<void> _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<String> prioritizedEndpoints,
required this.onEndpointSwitch,
}) : _endpointManager = prioritizedEndpoints.length > 1 ? EndpointFailoverManager(prioritizedEndpoints) : null;
final EndpointFailoverManager? _endpointManager;
final Future<void> Function(String newBaseUrl, {required bool persist}) onEndpointSwitch;
bool _failoverSwitching = false;
@override
Future<MediaServerResponse> get(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? 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<MediaServerResponse> _retryNextEndpoint(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? 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;
}
}
}
@@ -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<String> 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<JellyfinServerInfo> 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<String, dynamic>) {
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<JellyfinEndpointRaceResult> raceEndpoints(
Iterable<String> 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<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>? firstSelection;
EndpointRaceSelection<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>? bestSelection;
await for (final selection in raceEndpointCandidates<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>(
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<JellyfinEndpointCandidate, JellyfinEndpointProbeResult> successfulResults =
bestSelection?.successfulResults ?? firstSelection?.successfulResults ?? const {};
final expected = expectedMachineId?.trim().isNotEmpty == true ? expectedMachineId!.trim() : selectedInfo.machineId;
for (final result in successfulResults.values) {
final info = result.serverInfo;
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<JellyfinEndpointProbeResult> _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<JellyfinEndpointProbeResult> _probeWithAverageLatency(String baseUrl, {required int attempts}) async {
final results = <JellyfinEndpointProbeResult>[];
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<JellyfinEndpointCandidate, JellyfinEndpointProbeResult> 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<String> normalizeBaseUrls(Iterable<String> input) {
final result = <String>[];
final seen = <String>{};
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<String> _activeFirst(String activeBaseUrl, List<String> urls) {
final result = <String>[];
final seen = <String>{};
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);
}
}
+32 -9
View File
@@ -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<bool> 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;
+50 -136
View File
@@ -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 {
+209
View File
@@ -0,0 +1,209 @@
import 'dart:async';
import 'app_logger.dart';
import 'media_server_timeouts.dart';
enum EndpointRacePhase { first, best }
class EndpointRaceSelection<C, R> {
final EndpointRacePhase phase;
final C candidate;
final R result;
final bool fromPreferred;
final Map<C, R> 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<EndpointRaceSelection<C, R>> raceEndpointCandidates<C, R>({
required String label,
required List<C> candidates,
required String Function(C candidate) urlOf,
String Function(C candidate)? displayTypeOf,
Map<String, Object?> Function(C candidate, R result)? failureLogFields,
String? preferredUrl,
C? Function(String url)? candidateForUrl,
required Future<R> Function(C candidate, Duration timeout) probe,
required Future<R> Function(C candidate) measure,
required bool Function(R result) isSuccess,
required C? Function(Map<C, R> 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<C, R>(
phase: EndpointRacePhase.first,
candidate: resolvedFirstCandidate,
result: resolvedFirstResult,
fromPreferred: fromPreferred,
successfulResults: {resolvedFirstCandidate: resolvedFirstResult},
);
final successfulResults = <C, R>{};
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<C, R>(
phase: EndpointRacePhase.best,
candidate: bestCandidate,
result: bestResult,
successfulResults: Map.unmodifiable(successfulResults),
);
}
C? _candidateForUrl<C>(List<C> 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<C, R>({
required String label,
required List<C> candidates,
required String Function(C candidate) urlOf,
required Future<R> Function(C candidate, Duration timeout) probe,
required bool Function(R result) isSuccess,
required Duration timeout,
String Function(C candidate)? displayTypeOf,
Map<String, Object?> 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;
}
@@ -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);
@@ -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');
@@ -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<String>? 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 = <Uri>[];
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']);
});
});
}
@@ -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<void>.delayed(const Duration(milliseconds: 35));
} else {
await Future<void>.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<MediaServerUrlException>()),
);
});
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<MediaServerUrlException>()),
);
});
});
}