feat(jellyfin): support multiple server urls
This commit is contained in:
@@ -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? ?? '',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "パスワード",
|
||||
|
||||
@@ -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": "비밀번호",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Пароль",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -143,6 +143,7 @@ class _TranslationsCommonEs extends TranslationsCommonEn {
|
||||
@override String get yes => 'Sí';
|
||||
@override String get no => 'No';
|
||||
@override String get delete => 'Eliminar';
|
||||
@override String get edit => 'Editar';
|
||||
@override String get shuffle => 'Aleatorio';
|
||||
@override String get addTo => 'Añadir a...';
|
||||
@override String get createNew => 'Crear';
|
||||
@@ -812,6 +813,8 @@ class _TranslationsConnectionsEs extends TranslationsConnectionsEn {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sesión caducada para ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sesión caducada para ${count} servidores';
|
||||
@override String get signInAgain => 'Iniciar sesión de nuevo';
|
||||
@override String get editJellyfinTitle => 'Editar conexión de Jellyfin';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Añade o elimina URL para ${serverName}. Plezy usará la URL accesible con menor latencia.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1502,7 +1505,9 @@ class _TranslationsAddServerEs extends TranslationsAddServerEn {
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Añadir servidor Jellyfin';
|
||||
@override String get jellyfinUrlIntro => 'Introduce la URL del servidor, p. ej. `https://jellyfin.example.com`.';
|
||||
@override String get jellyfinUrlsIntro => 'Introduce una o más URL del servidor, separadas por comas o líneas nuevas. Plezy usará la URL accesible con menor latencia.';
|
||||
@override String get serverUrl => 'URL del servidor';
|
||||
@override String get serverUrls => 'URL del servidor';
|
||||
@override String get findServer => 'Buscar servidor';
|
||||
@override String get username => 'Usuario';
|
||||
@override String get password => 'Contraseña';
|
||||
@@ -1833,6 +1838,7 @@ extension on TranslationsEs {
|
||||
'common.yes' => 'Sí',
|
||||
'common.no' => 'No',
|
||||
'common.delete' => 'Eliminar',
|
||||
'common.edit' => 'Editar',
|
||||
'common.shuffle' => 'Aleatorio',
|
||||
'common.addTo' => 'Añadir a...',
|
||||
'common.createNew' => 'Crear',
|
||||
@@ -2312,9 +2318,9 @@ extension on TranslationsEs {
|
||||
'profiles.delete' => 'Eliminar',
|
||||
'profiles.signOut' => 'Cerrar sesión',
|
||||
'profiles.signOutPlexTitle' => '¿Cerrar sesión de Plex?',
|
||||
'profiles.signOutPlexMessage' => ({required Object displayName}) => '¿Eliminar ${displayName} y todos los usuarios de Plex Home? Puedes iniciar sesión de nuevo cuando quieras.',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'profiles.signOutPlexMessage' => ({required Object displayName}) => '¿Eliminar ${displayName} y todos los usuarios de Plex Home? Puedes iniciar sesión de nuevo cuando quieras.',
|
||||
'profiles.signedOutPlex' => 'Sesión de Plex cerrada.',
|
||||
'profiles.signOutFailed' => 'Error al cerrar sesión.',
|
||||
'profiles.sectionTitle' => 'Perfiles',
|
||||
@@ -2369,6 +2375,8 @@ extension on TranslationsEs {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sesión caducada para ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sesión caducada para ${count} servidores',
|
||||
'connections.signInAgain' => 'Iniciar sesión de nuevo',
|
||||
'connections.editJellyfinTitle' => 'Editar conexión de Jellyfin',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Añade o elimina URL para ${serverName}. Plezy usará la URL accesible con menor latencia.',
|
||||
'discover.title' => 'Descubrir',
|
||||
'discover.switchProfile' => 'Cambiar Perfil',
|
||||
'discover.noContentAvailable' => 'No hay contenido disponible',
|
||||
@@ -2824,11 +2832,11 @@ extension on TranslationsEs {
|
||||
'metadataEdit.contentRating' => 'Clasificación de contenido',
|
||||
'metadataEdit.studio' => 'Estudio',
|
||||
'metadataEdit.tagline' => 'Eslogan',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.summary' => 'Resumen',
|
||||
'metadataEdit.poster' => 'Póster',
|
||||
'metadataEdit.background' => 'Fondo',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.logo' => 'Logo',
|
||||
'metadataEdit.squareArt' => 'Imagen cuadrada',
|
||||
'metadataEdit.selectPoster' => 'Seleccionar póster',
|
||||
@@ -2947,7 +2955,9 @@ extension on TranslationsEs {
|
||||
'trackers.libraryFilter.noLibraries' => 'No hay bibliotecas disponibles',
|
||||
'addServer.addJellyfinTitle' => 'Añadir servidor Jellyfin',
|
||||
'addServer.jellyfinUrlIntro' => 'Introduce la URL del servidor, p. ej. `https://jellyfin.example.com`.',
|
||||
'addServer.jellyfinUrlsIntro' => 'Introduce una o más URL del servidor, separadas por comas o líneas nuevas. Plezy usará la URL accesible con menor latencia.',
|
||||
'addServer.serverUrl' => 'URL del servidor',
|
||||
'addServer.serverUrls' => 'URL del servidor',
|
||||
'addServer.findServer' => 'Buscar servidor',
|
||||
'addServer.username' => 'Usuario',
|
||||
'addServer.password' => 'Contraseña',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -143,6 +143,7 @@ class _TranslationsCommonIt extends TranslationsCommonEn {
|
||||
@override String get yes => 'Sì';
|
||||
@override String get no => 'No';
|
||||
@override String get delete => 'Elimina';
|
||||
@override String get edit => 'Modifica';
|
||||
@override String get shuffle => 'Casuale';
|
||||
@override String get addTo => 'Aggiungi a...';
|
||||
@override String get createNew => 'Crea';
|
||||
@@ -812,6 +813,8 @@ class _TranslationsConnectionsIt extends TranslationsConnectionsEn {
|
||||
@override String sessionExpiredOne({required Object name}) => 'Sessione scaduta per ${name}';
|
||||
@override String sessionExpiredMany({required Object count}) => 'Sessione scaduta per ${count} server';
|
||||
@override String get signInAgain => 'Accedi di nuovo';
|
||||
@override String get editJellyfinTitle => 'Modifica connessione Jellyfin';
|
||||
@override String editJellyfinIntro({required Object serverName}) => 'Aggiungi o rimuovi URL per ${serverName}. Plezy userà l\'URL raggiungibile con la latenza più bassa.';
|
||||
}
|
||||
|
||||
// Path: discover
|
||||
@@ -1502,7 +1505,9 @@ class _TranslationsAddServerIt extends TranslationsAddServerEn {
|
||||
// Translations
|
||||
@override String get addJellyfinTitle => 'Aggiungi server Jellyfin';
|
||||
@override String get jellyfinUrlIntro => 'Inserisci l\'URL del server, es. `https://jellyfin.example.com`.';
|
||||
@override String get jellyfinUrlsIntro => 'Inserisci uno o più URL del server, separati da virgole o nuove righe. Plezy userà l\'URL raggiungibile con la latenza più bassa.';
|
||||
@override String get serverUrl => 'URL del server';
|
||||
@override String get serverUrls => 'URL del server';
|
||||
@override String get findServer => 'Trova server';
|
||||
@override String get username => 'Nome utente';
|
||||
@override String get password => 'Password';
|
||||
@@ -1833,6 +1838,7 @@ extension on TranslationsIt {
|
||||
'common.yes' => 'Sì',
|
||||
'common.no' => 'No',
|
||||
'common.delete' => 'Elimina',
|
||||
'common.edit' => 'Modifica',
|
||||
'common.shuffle' => 'Casuale',
|
||||
'common.addTo' => 'Aggiungi a...',
|
||||
'common.createNew' => 'Crea',
|
||||
@@ -2312,9 +2318,9 @@ extension on TranslationsIt {
|
||||
'profiles.delete' => 'Elimina',
|
||||
'profiles.signOut' => 'Esci',
|
||||
'profiles.signOutPlexTitle' => 'Uscire da Plex?',
|
||||
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Rimuovere ${displayName} e tutti gli utenti Plex Home? Puoi accedere di nuovo quando vuoi.',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'profiles.signOutPlexMessage' => ({required Object displayName}) => 'Rimuovere ${displayName} e tutti gli utenti Plex Home? Puoi accedere di nuovo quando vuoi.',
|
||||
'profiles.signedOutPlex' => 'Uscito da Plex.',
|
||||
'profiles.signOutFailed' => 'Uscita non riuscita.',
|
||||
'profiles.sectionTitle' => 'Profili',
|
||||
@@ -2369,6 +2375,8 @@ extension on TranslationsIt {
|
||||
'connections.sessionExpiredOne' => ({required Object name}) => 'Sessione scaduta per ${name}',
|
||||
'connections.sessionExpiredMany' => ({required Object count}) => 'Sessione scaduta per ${count} server',
|
||||
'connections.signInAgain' => 'Accedi di nuovo',
|
||||
'connections.editJellyfinTitle' => 'Modifica connessione Jellyfin',
|
||||
'connections.editJellyfinIntro' => ({required Object serverName}) => 'Aggiungi o rimuovi URL per ${serverName}. Plezy userà l\'URL raggiungibile con la latenza più bassa.',
|
||||
'discover.title' => 'Esplora',
|
||||
'discover.switchProfile' => 'Cambia profilo',
|
||||
'discover.noContentAvailable' => 'Nessun contenuto disponibile',
|
||||
@@ -2824,11 +2832,11 @@ extension on TranslationsIt {
|
||||
'metadataEdit.contentRating' => 'Classificazione',
|
||||
'metadataEdit.studio' => 'Studio',
|
||||
'metadataEdit.tagline' => 'Tagline',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.summary' => 'Trama',
|
||||
'metadataEdit.poster' => 'Poster',
|
||||
'metadataEdit.background' => 'Sfondo',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.logo' => 'Logo',
|
||||
'metadataEdit.squareArt' => 'Immagine quadrata',
|
||||
'metadataEdit.selectPoster' => 'Seleziona poster',
|
||||
@@ -2947,7 +2955,9 @@ extension on TranslationsIt {
|
||||
'trackers.libraryFilter.noLibraries' => 'Nessuna libreria disponibile',
|
||||
'addServer.addJellyfinTitle' => 'Aggiungi server Jellyfin',
|
||||
'addServer.jellyfinUrlIntro' => 'Inserisci l\'URL del server, es. `https://jellyfin.example.com`.',
|
||||
'addServer.jellyfinUrlsIntro' => 'Inserisci uno o più URL del server, separati da virgole o nuove righe. Plezy userà l\'URL raggiungibile con la latenza più bassa.',
|
||||
'addServer.serverUrl' => 'URL del server',
|
||||
'addServer.serverUrls' => 'URL del server',
|
||||
'addServer.findServer' => 'Trova server',
|
||||
'addServer.username' => 'Nome utente',
|
||||
'addServer.password' => 'Password',
|
||||
|
||||
@@ -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' => 'パスワード',
|
||||
|
||||
@@ -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' => '비밀번호',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Пароль',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '密码',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "密码",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user