diff --git a/assets/emby_icon.svg b/assets/emby_icon.svg new file mode 100644 index 00000000..47ba8df6 --- /dev/null +++ b/assets/emby_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/connection/connection.dart b/lib/connection/connection.dart index 9f02de79..da595abb 100644 --- a/lib/connection/connection.dart +++ b/lib/connection/connection.dart @@ -1,4 +1,5 @@ import '../media/media_backend.dart'; +import '../media/media_browser_dialect.dart'; import '../models/plex/plex_home_user.dart'; import '../services/plex_auth_service.dart'; import '../utils/json_utils.dart'; @@ -9,22 +10,38 @@ import '../utils/url_utils.dart'; /// (e.g. database column values). enum ConnectionKind { plex, - jellyfin; + jellyfin, + emby; String get id => switch (this) { ConnectionKind.plex => 'plex', ConnectionKind.jellyfin => 'jellyfin', + ConnectionKind.emby => 'emby', }; static ConnectionKind fromId(String id) => switch (id) { 'plex' => ConnectionKind.plex, 'jellyfin' => ConnectionKind.jellyfin, + 'emby' => ConnectionKind.emby, _ => throw ArgumentError('Unknown ConnectionKind id: $id'), }; MediaBackend get backend => switch (this) { ConnectionKind.plex => MediaBackend.plex, ConnectionKind.jellyfin => MediaBackend.jellyfin, + ConnectionKind.emby => MediaBackend.emby, + }; + + /// The MediaBrowser dialect this kind speaks, or `null` for Plex. + MediaBrowserDialect? get dialect => switch (this) { + ConnectionKind.plex => null, + ConnectionKind.jellyfin => MediaBrowserDialect.jellyfin, + ConnectionKind.emby => MediaBrowserDialect.emby, + }; + + static ConnectionKind fromDialect(MediaBrowserDialect dialect) => switch (dialect) { + MediaBrowserDialect.jellyfin => ConnectionKind.jellyfin, + MediaBrowserDialect.emby => ConnectionKind.emby, }; } @@ -188,7 +205,9 @@ class PlexAccountConnection extends Connection { } } -/// A single-server Jellyfin connection. +/// A single-server connection to a MediaBrowser-family server — Jellyfin or its +/// Emby ancestor. [dialect] selects which of the two wire dialects this +/// connection speaks; every other field has the same meaning on both. class JellyfinConnection extends Connection { @override final String id; @@ -202,10 +221,14 @@ class JellyfinConnection extends Connection { @override final DateTime? lastAuthenticatedAt; + /// Which MediaBrowser dialect this server speaks. Drives [kind], [backend] + /// and every route/capability delta in [JellyfinClient]. + final MediaBrowserDialect dialect; + /// 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. + /// Candidate server URLs for this server, with [baseUrl] first. /// Existing installs only have [baseUrl]; deserialization backfills this. final List baseUrls; @@ -215,7 +238,7 @@ class JellyfinConnection extends Connection { /// Server's machine identifier (System/Info `Id`). final String serverMachineId; - /// Authenticated Jellyfin user id (UUID). + /// Authenticated user id. A UUID on Jellyfin, an opaque hex string on Emby. final String userId; /// Authenticated user's display name. @@ -228,13 +251,13 @@ class JellyfinConnection extends Connection { /// `Authorization: MediaBrowser DeviceId="..."` header). final String deviceId; - /// Whether this user is a Jellyfin admin (`/Users/{id}.Policy.IsAdministrator`). + /// Whether this user is a server admin (`/Users/{id}.Policy.IsAdministrator`). /// Captured at auth time so the UI can gate admin-only entries (delete, /// match/unmatch, edit metadata) without an extra round-trip. final bool isAdministrator; /// The authenticated user's `PrimaryImageTag`, or `null` when they have no - /// profile picture. Jellyfin omits the key entirely in that case, and the + /// profile picture. The server omits the key entirely in that case, and the /// tag is `MD5(imagePath + lastModified)` so it changes on every upload — /// which makes the derived avatar URL self-invalidating. Captured at auth /// time and refreshed by [JellyfinClient.checkHealth]. @@ -250,6 +273,7 @@ class JellyfinConnection extends Connection { required this.userName, required this.accessToken, required this.deviceId, + this.dialect = MediaBrowserDialect.jellyfin, this.isAdministrator = false, this.primaryImageTag, this.status = ConnectionStatus.unknown, @@ -259,7 +283,7 @@ class JellyfinConnection extends Connection { baseUrls = _normalizeBaseUrls(baseUrl, baseUrls); @override - ConnectionKind get kind => ConnectionKind.jellyfin; + ConnectionKind get kind => ConnectionKind.fromDialect(dialect); @override String get displayName => '$userName · $serverName'; @@ -306,11 +330,12 @@ class JellyfinConnection extends Connection { String? userName, String? accessToken, String? deviceId, + MediaBrowserDialect? dialect, bool? isAdministrator, String? primaryImageTag, - /// Deleting a Jellyfin profile picture drops `PrimaryImageTag` from the - /// user DTO, so a refresh must be able to null the cached value — a bare + /// Deleting a profile picture drops `PrimaryImageTag` from the user DTO, so + /// a refresh must be able to null the cached value — a bare /// `primaryImageTag: null` is indistinguishable from "unchanged". bool clearPrimaryImageTag = false, ConnectionStatus? status, @@ -328,6 +353,7 @@ class JellyfinConnection extends Connection { userName: userName ?? this.userName, accessToken: accessToken ?? this.accessToken, deviceId: deviceId ?? this.deviceId, + dialect: dialect ?? this.dialect, isAdministrator: isAdministrator ?? this.isAdministrator, primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag), status: status ?? this.status, @@ -336,6 +362,9 @@ class JellyfinConnection extends Connection { ); } + /// The persisted payload deliberately omits [dialect]: the `connections.kind` + /// column is the authoritative, indexed discriminator and + /// [JellyfinConnection.fromConfigJson] receives it from there. @override Map toConfigJson() { return { @@ -358,6 +387,7 @@ class JellyfinConnection extends Connection { required ConnectionStatus status, required DateTime createdAt, DateTime? lastAuthenticatedAt, + MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin, }) { final rawBaseUrls = json['baseUrls']; final baseUrls = rawBaseUrls is List ? rawBaseUrls.whereType().toList(growable: false) : const []; @@ -369,7 +399,8 @@ class JellyfinConnection extends Connection { id: id, baseUrl: baseUrl, baseUrls: baseUrls, - serverName: json['serverName'] as String? ?? 'Jellyfin', + dialect: dialect, + serverName: json['serverName'] as String? ?? dialect.productName, serverMachineId: json['serverMachineId'] as String? ?? '', userId: json['userId'] as String? ?? '', userName: json['userName'] as String? ?? '', diff --git a/lib/connection/connection_registry.dart b/lib/connection/connection_registry.dart index eb692b47..a2e93649 100644 --- a/lib/connection/connection_registry.dart +++ b/lib/connection/connection_registry.dart @@ -161,12 +161,13 @@ class ConnectionRegistry { createdAt: createdAt, lastAuthenticatedAt: lastAuth, ), - ConnectionKind.jellyfin => JellyfinConnection.fromConfigJson( + ConnectionKind.jellyfin || ConnectionKind.emby => JellyfinConnection.fromConfigJson( id: row.id, json: revealed.config, status: ConnectionStatus.unknown, createdAt: createdAt, lastAuthenticatedAt: lastAuth, + dialect: kind.dialect!, ), }; if (revealed.migrated) { diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 71adefe5..bcf8ccfd 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -235,7 +235,7 @@ class AppDatabase extends _$AppDatabase { static bool _containsPlaintextConnectionCredential(String kind, Map config) { bool isPlaintext(Object? value) => value is String && value.isNotEmpty && !CredentialVault.isProtected(value); - if (kind == 'jellyfin') return isPlaintext(config['accessToken']); + if (kind == 'jellyfin' || kind == 'emby') return isPlaintext(config['accessToken']); if (kind != 'plex') return false; if (isPlaintext(config['accountToken'])) return true; final servers = config['servers']; diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 19bc7399..a818e3c1 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -4497,7 +4497,7 @@ class ConnectionRow extends DataClass implements Insertable { /// (one per account); for Jellyfin it's the server's machineId. final String id; - /// Backend kind: `'plex'` or `'jellyfin'`. + /// Backend kind: `'plex'`, `'jellyfin'`, or `'emby'`. final String kind; /// User-visible label (account email, server name). diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index 2df91c5f..f154d1c5 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import '../media/ids.dart'; +import '../media/media_backend.dart'; import 'app_database.dart'; import '../models/download_models.dart'; @@ -202,22 +203,30 @@ extension DownloadDatabaseOperations on AppDatabase { final connectionRows = await select(connections).get(); final connectionIds = connectionRows.map((row) => row.id).toSet(); final connectionKindsById = {for (final row in connectionRows) row.id: row.kind}; - final jellyfinIdentities = {}; - final jellyfinMachineIds = {}; - for (final connection in connectionRows.where((row) => row.kind == 'jellyfin')) { - final identity = _jellyfinConnectionIdentity(connection); - jellyfinIdentities[connection.id] = identity; - jellyfinMachineIds.add(identity.machineId); + final mediaBrowserIdentities = {}; + final mediaBrowserMachineIds = {}; + // `Connections.kind` is the authoritative dialect discriminator; both + // MediaBrowser kinds use the same compound machine/user scope shape. + for (final connection in connectionRows.where( + (row) => row.kind == MediaBackend.jellyfin.id || row.kind == MediaBackend.emby.id, + )) { + final identity = _mediaBrowserConnectionIdentity(connection); + mediaBrowserIdentities[connection.id] = ( + machineId: identity.machineId, + userId: identity.userId, + backendId: connection.kind, + ); + mediaBrowserMachineIds.add(identity.machineId); } - final jellyfinScopesByProfileAndMachine = >>{}; + final mediaBrowserScopesByProfileAndMachine = >>{}; for (final binding in await select(profileConnections).get()) { if (binding.userIdentifier.isEmpty) continue; - final identity = jellyfinIdentities[binding.connectionId]; + final identity = mediaBrowserIdentities[binding.connectionId]; if (identity == null || identity.userId != null && identity.userId != binding.userIdentifier) continue; - jellyfinScopesByProfileAndMachine - .putIfAbsent(binding.profileId, () => >{}) - .putIfAbsent(identity.machineId, () => {}) - .add('${identity.machineId}/${binding.userIdentifier}'); + mediaBrowserScopesByProfileAndMachine + .putIfAbsent(binding.profileId, () => >{}) + .putIfAbsent(identity.machineId, () => <({String scopeId, String backendId})>{}) + .add((scopeId: '${identity.machineId}/${binding.userIdentifier}', backendId: identity.backendId)); } final ownedKeys = { for (final owner in owners) @@ -239,35 +248,37 @@ extension DownloadDatabaseOperations on AppDatabase { await addDownloadOwner( profileId: profileId, globalKey: row.globalKey, - backendId: 'plex', + backendId: MediaBackend.plex.id, clientScopeId: scopeId, ); continue; } - final jellyfinScopes = jellyfinScopesByProfileAndMachine[profileId]?[row.serverId] ?? const {}; - if (jellyfinScopes.length == 1) { - final adoptingScope = jellyfinScopes.single; + final mediaBrowserScopes = + mediaBrowserScopesByProfileAndMachine[profileId]?[row.serverId] ?? + const <({String scopeId, String backendId})>{}; + if (mediaBrowserScopes.length == 1) { + final adopting = mediaBrowserScopes.single; await transaction(() async { if (isStillActive != null && !isStillActive()) return; - await updateDownloadedMediaClientScope(row.globalKey, adoptingScope); + await updateDownloadedMediaClientScope(row.globalKey, adopting.scopeId); await addDownloadOwner( profileId: profileId, globalKey: row.globalKey, - backendId: 'jellyfin', - clientScopeId: adoptingScope, + backendId: adopting.backendId, + clientScopeId: adopting.scopeId, ); }); continue; } - // A compound non-Plex scope is a legacy Jellyfin user namespace. + // A compound non-Plex scope is a legacy MediaBrowser user namespace. // Never attach it to another profile unless that profile has exactly - // one matching Jellyfin binding. The same applies when persisted - // Jellyfin connections identify the machine but the profile has zero - // or multiple possible users. - final hasLegacyJellyfinScope = scopeId?.startsWith('${row.serverId}/') ?? false; - if (hasLegacyJellyfinScope || jellyfinMachineIds.contains(row.serverId)) continue; + // one matching MediaBrowser binding. The same applies when persisted + // MediaBrowser connections identify the machine but the profile has + // zero or multiple possible users. + final hasLegacyMediaBrowserScope = scopeId?.startsWith('${row.serverId}/') ?? false; + if (hasLegacyMediaBrowserScope || mediaBrowserMachineIds.contains(row.serverId)) continue; final backendId = connectionKindsById[scopeId]; await addDownloadOwner( @@ -694,7 +705,7 @@ bool _isValidDownloadOwner( return localProfileIds.isEmpty; } -({String machineId, String? userId}) _jellyfinConnectionIdentity(ConnectionRow connection) { +({String machineId, String? userId}) _mediaBrowserConnectionIdentity(ConnectionRow connection) { final separator = connection.id.indexOf('/'); var machineId = separator < 0 ? connection.id : connection.id.substring(0, separator); String? userId = separator < 0 || separator == connection.id.length - 1 diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 6e17d9f6..38fcdb1f 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -131,8 +131,8 @@ class SyncRuleDownloads extends Table { /// Persisted media-server connections. /// /// One row per "connection" the user has added — a Plex account (with its -/// discovered servers and active Home profile) or a single Jellyfin server. -/// The [configJson] payload is backend-specific and parsed by the +/// discovered servers and active Home profile) or a single MediaBrowser +/// server/user. The [configJson] payload is backend-specific and parsed by the /// [Connection] sealed class. @DataClassName('ConnectionRow') @TableIndex(name: 'idx_connections_kind', columns: {#kind}) @@ -141,7 +141,7 @@ class Connections extends Table { /// (one per account); for Jellyfin it's the server's machineId. TextColumn get id => text()(); - /// Backend kind: `'plex'` or `'jellyfin'`. + /// Backend kind: `'plex'`, `'jellyfin'`, or `'emby'`. TextColumn get kind => text()(); /// User-visible label (account email, server name). diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 5101b646..52925cfe 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Təsdiqləmə gözlənilir...\nSəyahətçinizdən (brauzer) daxil olun.", "useBrowser": "Səyahətçini istifadə et", "or": "və ya", - "connectToJellyfin": "Jellyfin-ə qoşul", + "connectToMediaBrowser": "", "useQuickConnect": "Sürətli Qoşulmanı istifadə et", "quickConnectInstructions": "Jellyfin-də Sürətli Qoşulmanı açın və bu kodu daxil edin.", "quickConnectWaiting": "Təsdiq gözlənilir…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "${name} üçün seansın vaxtı bitdi", "sessionExpiredMany": "${count} server üçün seansın vaxtı bitdi", "signInAgain": "Yenidən daxil ol", - "editJellyfinTitle": "Jellyfin qoşulmasını dəyişdir", - "editJellyfinIntro": "${serverName} üçün URL əlavə edin və ya silin." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Kəşf et", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Jellyfin serveri əlavə et", + "addMediaBrowserTitle": "", "serverUrls": "Server URL-ləri", "serverUrlsHelper": "Vergüllə ayrılmış bir neçə URL-ə icazə verilir.", "findServer": "Server tap", - "searchingLocalServers": "Yerli Jellyfin serverləri axtarılır...", - "localServers": "Yerli Jellyfin serverləri", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "İstifadəçi adı", "password": "Şifrə", "signIn": "Daxil ol", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Plex ilə daxil ol", "pinExpired": "PIN-in vaxtı bitdi. Lütfən təzədən cəhd edin.", "failedToRegisterAccount": "Hesab qeydiyyatı uğursuz oldu: ${error}", - "enterJellyfinUrlError": "Jellyfin server URL-inizi daxil edin", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Qoşulma əlavə et", "addConnectionTitleScoped": "${name} profilinə əlavə et", "signInWithPlexCard": "Plex ilə daxil ol", "signInWithPlexCardSubtitle": "Bu cihazı səlahiyyətləndirin.", "signInWithPlexCardSubtitleScoped": "Plex hesabını səlahiyyətləndirin.", - "connectToJellyfinCard": "Jellyfin-ə qoşul", - "connectToJellyfinCardSubtitle": "Server URL, istifadəçi adı və şifrənizi daxil edin.", - "connectToJellyfinCardSubtitleScoped": "Jellyfin serverinə daxil olun. ${name} profilinə bağlanır.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Başqa profildən götür", "borrowFromAnotherProfileSubtitle": "Başqa profilin qoşulmasını yenidən istifadə edin." } diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 2a075ded..b891116d 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Изчакване на удостоверяване...\nВлезте от браузъра си.", "useBrowser": "Използвай браузър", "or": "или", - "connectToJellyfin": "Свържи се с Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Използвай Quick Connect", "quickConnectInstructions": "Отворете Quick Connect в Jellyfin и въведете този код.", "quickConnectWaiting": "Изчакване на одобрение…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Сесията за ${name} е изтекла", "sessionExpiredMany": "Сесиите за ${count} сървъра са изтекли", "signInAgain": "Влез отново", - "editJellyfinTitle": "Редактиране на Jellyfin връзка", - "editJellyfinIntro": "Добавете или премахнете URL адреси за ${serverName}. Plezy ще използва достъпния URL адрес с най-ниска латентност." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Открий", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Добави Jellyfin сървър", + "addMediaBrowserTitle": "", "serverUrls": "URL адреси на сървъра", "serverUrlsHelper": "Позволени са няколко URL адреса, разделени със запетаи.", "findServer": "Намери сървър", - "searchingLocalServers": "Търсене на локални Jellyfin сървъри...", - "localServers": "Локални Jellyfin сървъри", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Потребителско име", "password": "Парола", "signIn": "Вход", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Вход с Plex", "pinExpired": "PIN-ът изтече преди вход. Моля, опитайте отново.", "failedToRegisterAccount": "Неуспешна регистрация на акаунт: ${error}", - "enterJellyfinUrlError": "Въведете URL адреса на вашия Jellyfin сървър", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Добави връзка", "addConnectionTitleScoped": "Добави към ${name}", "signInWithPlexCard": "Вход с Plex", "signInWithPlexCardSubtitle": "Удостоверете това устройство. Споделените сървъри се добавят.", "signInWithPlexCardSubtitleScoped": "Удостоверете Plex акаунт. Домашните потребители стават профили.", - "connectToJellyfinCard": "Свързване с Jellyfin", - "connectToJellyfinCardSubtitle": "Въведете URL адрес на сървъра, потребителско име и парола.", - "connectToJellyfinCardSubtitleScoped": "Вход в Jellyfin сървър. Свързва се с ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Използвай от друг профил", "borrowFromAnotherProfileSubtitle": "Използвай връзка от друг профил. PIN-защитените профили изискват PIN." } diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index d7ead2a5..7b13da37 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Venter på godkendelse...\nLog ind fra din browser.", "useBrowser": "Brug browseren", "or": "eller", - "connectToJellyfin": "Forbind til Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Brug Quick Connect", "quickConnectInstructions": "Åbn Quick Connect i Jellyfin, og indtast denne kode.", "quickConnectWaiting": "Venter på godkendelse…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Sessionen er udløbet for ${name}", "sessionExpiredMany": "Sessionerne er udløbet for ${count} servere", "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." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Opdag", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Tilføj Jellyfin-server", + "addMediaBrowserTitle": "", "serverUrls": "Server-URL'er", "serverUrlsHelper": "Du kan angive flere URL'er adskilt med komma.", "findServer": "Find server", - "searchingLocalServers": "Søger efter lokale Jellyfin-servere...", - "localServers": "Lokale Jellyfin-servere", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Brugernavn", "password": "Adgangskode", "signIn": "Log ind", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Log ind med Plex", "pinExpired": "PIN-koden udløb før login. Prøv igen.", "failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}", - "enterJellyfinUrlError": "Angiv URL'en til din Jellyfin-server", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Tilføj forbindelse", "addConnectionTitleScoped": "Tilføj til ${name}", "signInWithPlexCard": "Log ind med Plex", "signInWithPlexCardSubtitle": "Godkend denne enhed. Delte servere tilføjes.", "signInWithPlexCardSubtitleScoped": "Godkend en Plex-konto. Plex Home-brugere bliver til profiler.", - "connectToJellyfinCard": "Forbind til Jellyfin", - "connectToJellyfinCardSubtitle": "Indtast din server-URL, dit brugernavn og din adgangskode.", - "connectToJellyfinCardSubtitleScoped": "Log ind på en Jellyfin-server. Serveren knyttes til ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Lån fra en anden profil", "borrowFromAnotherProfileSubtitle": "Genbrug en anden profils forbindelse. PIN-beskyttede profiler kræver en PIN." } diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 57c33ff8..fd02e1bf 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Warte auf die Authentifizierung …\nMelde dich über deinen Browser an.", "useBrowser": "Browser verwenden", "or": "oder", - "connectToJellyfin": "Mit Jellyfin verbinden", + "connectToMediaBrowser": "", "useQuickConnect": "Quick Connect verwenden", "quickConnectInstructions": "Öffne Quick Connect in Jellyfin und gib diesen Code ein.", "quickConnectWaiting": "Warte auf Bestätigung…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Sitzung für ${name} abgelaufen", "sessionExpiredMany": "Sitzungen für ${count} Server abgelaufen", "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." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Entdecken", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Jellyfin-Server hinzufügen", + "addMediaBrowserTitle": "", "serverUrls": "Server-URLs", "serverUrlsHelper": "Mehrere URLs möglich, durch Kommas getrennt.", "findServer": "Server finden", - "searchingLocalServers": "Suche nach lokalen Jellyfin-Servern …", - "localServers": "Lokale Jellyfin-Server", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Benutzername", "password": "Passwort", "signIn": "Anmelden", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Mit Plex anmelden", "pinExpired": "PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.", "failedToRegisterAccount": "Konto konnte nicht registriert werden: ${error}", - "enterJellyfinUrlError": "Gib die URL deines Jellyfin-Servers ein", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Verbindung hinzufügen", "addConnectionTitleScoped": "Zu ${name} hinzufügen", "signInWithPlexCard": "Mit Plex anmelden", "signInWithPlexCardSubtitle": "Dieses Gerät autorisieren. Geteilte Server werden hinzugefügt.", "signInWithPlexCardSubtitleScoped": "Ein Plex-Konto autorisieren. Home-Benutzer werden zu Profilen.", - "connectToJellyfinCard": "Mit Jellyfin verbinden", - "connectToJellyfinCardSubtitle": "Gib Server-URL, Benutzername und Passwort ein.", - "connectToJellyfinCardSubtitleScoped": "Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Von einem anderen Profil ausleihen", "borrowFromAnotherProfileSubtitle": "Verbindung eines anderen Profils wiederverwenden. PIN-geschützte Profile erfordern eine PIN." } diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 3353a0bc..d66b40e5 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Waiting for authentication...\nSign in from your browser.", "useBrowser": "Use browser", "or": "or", - "connectToJellyfin": "Connect to Jellyfin", + "connectToMediaBrowser": "Connect to ${product}", "useQuickConnect": "Use Quick Connect", "quickConnectInstructions": "Open Quick Connect in Jellyfin and enter this code.", "quickConnectWaiting": "Waiting for approval…", @@ -802,7 +802,7 @@ "borrowAddTo": "Add to ${displayName}", "borrowExplain": "Borrow another profile's connection. PIN-protected profiles require a PIN.", "borrowEmpty": "Nothing to borrow yet.", - "borrowEmptySubtitle": "Connect Plex or Jellyfin to another profile first.", + "borrowEmptySubtitle": "Connect Plex, Jellyfin, or Emby to another profile first.", "borrowLoadFailed": "Available connections could not be loaded. Try again.", "borrowFromProfile": "From ${displayName}", "borrowConnectionBorrowed": "Connection borrowed.", @@ -822,13 +822,13 @@ "connections": { "sectionTitle": "Connections", "addConnection": "Add connection", - "addConnectionSubtitleNoProfile": "Sign in with Plex or connect a Jellyfin server", - "addConnectionSubtitleScoped": "Add to ${displayName}: Plex, Jellyfin, or another profile connection", + "addConnectionSubtitleNoProfile": "Sign in with Plex or connect a Jellyfin or Emby server", + "addConnectionSubtitleScoped": "Add to ${displayName}: Plex, Jellyfin, Emby, or another profile connection", "sessionExpiredOne": "Session expired for ${name}", "sessionExpiredMany": "Session expired for ${count} servers", "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." + "editMediaBrowserTitle": "Edit ${product} connection", + "editMediaBrowserIntro": "Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency." }, "discover": { "title": "Discover", @@ -966,7 +966,7 @@ "title": "About", "openSourceLicenses": "Open Source Licenses", "versionLabel": "Version ${version}", - "appDescription": "A beautiful Plex and Jellyfin client for Flutter", + "appDescription": "A beautiful Plex, Jellyfin, and Emby client for Flutter", "viewLicensesDescription": "View licenses of third-party libraries" }, "serverSelection": { @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Add Jellyfin server", + "addMediaBrowserTitle": "Add ${product} server", "serverUrls": "Server URLs", "serverUrlsHelper": "Multiple URLs allowed, separated by commas.", "findServer": "Find server", - "searchingLocalServers": "Looking for local Jellyfin servers...", - "localServers": "Local Jellyfin servers", + "searchingLocalMediaBrowserServers": "Looking for local ${product} servers...", + "localMediaBrowserServers": "Local ${product} servers", "username": "Username", "password": "Password", "signIn": "Sign in", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Sign in with Plex", "pinExpired": "PIN expired before sign-in. Please try again.", "failedToRegisterAccount": "Failed to register account: ${error}", - "enterJellyfinUrlError": "Enter your Jellyfin server URL", + "enterMediaBrowserUrlError": "Enter your ${product} server URL", "addConnectionTitle": "Add connection", "addConnectionTitleScoped": "Add to ${name}", "signInWithPlexCard": "Sign in with Plex", "signInWithPlexCardSubtitle": "Authorize this device. Shared servers are added.", "signInWithPlexCardSubtitleScoped": "Authorize a Plex account. Home users become profiles.", - "connectToJellyfinCard": "Connect to Jellyfin", - "connectToJellyfinCardSubtitle": "Enter your server URL, username, and password.", - "connectToJellyfinCardSubtitleScoped": "Sign in to a Jellyfin server. Binds to ${name}.", + "connectToMediaBrowserCard": "Connect to ${product}", + "connectToMediaBrowserCardSubtitle": "Enter your server URL, username, and password.", + "connectToMediaBrowserCardSubtitleScoped": "Sign in to your ${product} server. Binds to ${name}.", "borrowFromAnotherProfile": "Borrow from another profile", "borrowFromAnotherProfileSubtitle": "Reuse another profile's connection. PIN-protected profiles require a PIN." } diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 26c4ef6d..e581bf61 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Esperando autenticación...\nInicia sesión desde tu navegador.", "useBrowser": "Usar navegador", "or": "o", - "connectToJellyfin": "Conectar a Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Usar Quick Connect", "quickConnectInstructions": "Abre Quick Connect en Jellyfin e introduce este código.", "quickConnectWaiting": "Esperando aprobación…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Sesión caducada para ${name}", "sessionExpiredMany": "Sesión caducada para ${count} servidores", "signInAgain": "Iniciar sesión de nuevo", - "editJellyfinTitle": "Editar conexión de Jellyfin", - "editJellyfinIntro": "Añade o elimina direcciones URL para ${serverName}. Plezy usará la dirección accesible con menor latencia." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Descubrir", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Añadir servidor Jellyfin", + "addMediaBrowserTitle": "", "serverUrls": "Direcciones URL del servidor", "serverUrlsHelper": "Se permiten varias URL, separadas por comas.", "findServer": "Buscar servidor", - "searchingLocalServers": "Buscando servidores Jellyfin locales...", - "localServers": "Servidores Jellyfin locales", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Usuario", "password": "Contraseña", "signIn": "Iniciar sesión", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Iniciar sesión con Plex", "pinExpired": "El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.", "failedToRegisterAccount": "No se pudo registrar la cuenta: ${error}", - "enterJellyfinUrlError": "Introduce la URL de tu servidor Jellyfin", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Añadir conexión", "addConnectionTitleScoped": "Añadir a ${name}", "signInWithPlexCard": "Iniciar sesión con Plex", "signInWithPlexCardSubtitle": "Autoriza este dispositivo. Se añaden servidores compartidos.", "signInWithPlexCardSubtitleScoped": "Autoriza una cuenta Plex. Los usuarios de Home se convierten en perfiles.", - "connectToJellyfinCard": "Conectar a Jellyfin", - "connectToJellyfinCardSubtitle": "Introduce la URL del servidor, usuario y contraseña.", - "connectToJellyfinCardSubtitleScoped": "Inicia sesión en un servidor Jellyfin. Se vincula a ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Tomar prestado de otro perfil", "borrowFromAnotherProfileSubtitle": "Reutiliza la conexión de otro perfil. Los perfiles protegidos con PIN requieren un PIN." } diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index b9259cb3..51b5ee53 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "En attente d'authentification...\nConnectez-vous depuis votre navigateur.", "useBrowser": "Utiliser le navigateur", "or": "ou", - "connectToJellyfin": "Se connecter à Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Utiliser Quick Connect", "quickConnectInstructions": "Ouvrez Quick Connect dans Jellyfin et saisissez ce code.", "quickConnectWaiting": "En attente d'approbation…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Session expirée pour ${name}", "sessionExpiredMany": "Session expirée pour ${count} serveurs", "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." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Découvrir", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Ajouter un serveur Jellyfin", + "addMediaBrowserTitle": "", "serverUrls": "URL du serveur", "serverUrlsHelper": "Plusieurs URL possibles, séparées par des virgules.", "findServer": "Rechercher un serveur", - "searchingLocalServers": "Recherche de serveurs Jellyfin locaux...", - "localServers": "Serveurs Jellyfin locaux", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Nom d'utilisateur", "password": "Mot de passe", "signIn": "Se connecter", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Se connecter avec Plex", "pinExpired": "Le PIN a expiré avant la connexion. Veuillez réessayer.", "failedToRegisterAccount": "Échec de l'enregistrement du compte : ${error}", - "enterJellyfinUrlError": "Saisissez l'URL de votre serveur Jellyfin", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Ajouter une connexion", "addConnectionTitleScoped": "Ajouter à ${name}", "signInWithPlexCard": "Se connecter avec Plex", "signInWithPlexCardSubtitle": "Autorisez cet appareil. Les serveurs partagés sont ajoutés.", "signInWithPlexCardSubtitleScoped": "Autorisez un compte Plex. Les utilisateurs Home deviennent des profils.", - "connectToJellyfinCard": "Se connecter à Jellyfin", - "connectToJellyfinCardSubtitle": "Saisissez l'URL du serveur, le nom d'utilisateur et le mot de passe.", - "connectToJellyfinCardSubtitleScoped": "Connectez-vous à un serveur Jellyfin. Cette connexion sera liée à ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Emprunter à un autre profil", "borrowFromAnotherProfileSubtitle": "Réutiliser la connexion d'un autre profil. Les profils protégés par PIN exigent un PIN." } diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index ddd02d3e..099a8569 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Várakozás a hitelesítésre...\nJelentkezz be a böngésződben.", "useBrowser": "Böngésző használata", "or": "vagy", - "connectToJellyfin": "Csatlakozás Jellyfinhez", + "connectToMediaBrowser": "", "useQuickConnect": "Quick Connect használata", "quickConnectInstructions": "Nyisd meg a Quick Connect-et a Jellyfinben, és add meg ezt a kódot.", "quickConnectWaiting": "Várakozás a jóváhagyásra…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "A(z) ${name} munkamenete lejárt", "sessionExpiredMany": "${count} szerver munkamenete lejárt", "signInAgain": "Bejelentkezés újra", - "editJellyfinTitle": "Jellyfin kapcsolat szerkesztése", - "editJellyfinIntro": "URL-ek hozzáadása vagy eltávolítása ehhez: ${serverName}. A Plezy a legalacsonyabb késleltetésű, elérhető URL-t fogja használni." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Felfedezés", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Jellyfin szerver hozzáadása", + "addMediaBrowserTitle": "", "serverUrls": "Szerver URL-címei", "serverUrlsHelper": "Több URL is megadható, vesszővel elválasztva.", "findServer": "Szerver keresése", - "searchingLocalServers": "Helyi Jellyfin-szerverek keresése...", - "localServers": "Helyi Jellyfin-szerverek", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Felhasználónév", "password": "Jelszó", "signIn": "Bejelentkezés", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Bejelentkezés Plexszel", "pinExpired": "A PIN-kód a bejelentkezés előtt lejárt. Próbáld újra.", "failedToRegisterAccount": "Nem sikerült a fiók regisztrálása: ${error}", - "enterJellyfinUrlError": "Add meg a Jellyfin szervered URL-jét", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Kapcsolat hozzáadása", "addConnectionTitleScoped": "Hozzáadás a következőhöz: ${name}", "signInWithPlexCard": "Bejelentkezés Plexszel", "signInWithPlexCardSubtitle": "Eszköz engedélyezése. A megosztott szerverek hozzáadásra kerülnek.", "signInWithPlexCardSubtitleScoped": "Plex-fiók engedélyezése. A Plex Home-felhasználókból profilok lesznek.", - "connectToJellyfinCard": "Csatlakozás Jellyfinhez", - "connectToJellyfinCardSubtitle": "Add meg a szerver URL-jét, felhasználónevedet és jelszavadat.", - "connectToJellyfinCardSubtitleScoped": "Bejelentkezés egy Jellyfin-szerverre. Hozzárendelés ehhez: ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Kapcsolat használata másik profilból", "borrowFromAnotherProfileSubtitle": "Egy másik profil kapcsolatának használata. A PIN-kóddal védett profilokhoz PIN-kód szükséges." } diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index f8766769..c3a07485 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "In attesa di autenticazione...\nAccedi dal browser.", "useBrowser": "Usa il browser", "or": "o", - "connectToJellyfin": "Connettiti a Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Usa Quick Connect", "quickConnectInstructions": "Apri Quick Connect in Jellyfin e inserisci questo codice.", "quickConnectWaiting": "In attesa di approvazione…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Sessione scaduta per ${name}", "sessionExpiredMany": "Sessione scaduta per ${count} server", "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." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Esplora", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Aggiungi server Jellyfin", + "addMediaBrowserTitle": "", "serverUrls": "URL del server", "serverUrlsHelper": "Sono consentiti più URL, separati da virgole.", "findServer": "Trova il server", - "searchingLocalServers": "Ricerca dei server Jellyfin locali...", - "localServers": "Server Jellyfin locali", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Nome utente", "password": "Password", "signIn": "Accedi", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Accedi con Plex", "pinExpired": "PIN scaduto prima dell'accesso. Riprova.", "failedToRegisterAccount": "Registrazione account non riuscita: ${error}", - "enterJellyfinUrlError": "Inserisci l'URL del tuo server Jellyfin", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Aggiungi connessione", "addConnectionTitleScoped": "Aggiungi a ${name}", "signInWithPlexCard": "Accedi con Plex", "signInWithPlexCardSubtitle": "Autorizza questo dispositivo. I server condivisi vengono aggiunti.", "signInWithPlexCardSubtitleScoped": "Autorizza un account Plex. Gli utenti Home diventano profili.", - "connectToJellyfinCard": "Connettiti a Jellyfin", - "connectToJellyfinCardSubtitle": "Inserisci l'URL del server, il nome utente e la password.", - "connectToJellyfinCardSubtitleScoped": "Accedi a un server Jellyfin. Verrà associato a ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Prendi in prestito da un altro profilo", "borrowFromAnotherProfileSubtitle": "Riutilizza la connessione di un altro profilo. I profili protetti da PIN richiedono un PIN." } diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index fc25953a..b8fc6caf 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "認証を待っています…\nブラウザでサインインしてください。", "useBrowser": "ブラウザを使用", "or": "または", - "connectToJellyfin": "Jellyfinに接続", + "connectToMediaBrowser": "", "useQuickConnect": "Quick Connect を使う", "quickConnectInstructions": "JellyfinでQuick Connectを開き、このコードを入力してください。", "quickConnectWaiting": "承認を待っています…", @@ -826,8 +826,8 @@ "sessionExpiredOne": "${name} のセッションの有効期限が切れました", "sessionExpiredMany": "${count} 台のサーバーのセッションの有効期限が切れました", "signInAgain": "再度サインイン", - "editJellyfinTitle": "Jellyfin接続を編集", - "editJellyfinIntro": "${serverName}のURLを追加または削除します。Plezyは接続可能なURLのうち遅延が最も少ないものを使用します。" + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "探す", @@ -1867,12 +1867,12 @@ } }, "addServer": { - "addJellyfinTitle": "Jellyfinサーバーを追加", + "addMediaBrowserTitle": "", "serverUrls": "サーバーURL", "serverUrlsHelper": "複数のURLをカンマ区切りで入力できます。", "findServer": "サーバーを検索", - "searchingLocalServers": "ローカルのJellyfinサーバーを検索中…", - "localServers": "ローカルのJellyfinサーバー", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "ユーザー名", "password": "パスワード", "signIn": "サインイン", @@ -1884,15 +1884,15 @@ "addPlexTitle": "Plexでサインイン", "pinExpired": "サインイン前にPINの有効期限が切れました。もう一度お試しください。", "failedToRegisterAccount": "アカウントの登録に失敗しました: ${error}", - "enterJellyfinUrlError": "JellyfinサーバーのURLを入力してください", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "接続を追加", "addConnectionTitleScoped": "${name}に追加", "signInWithPlexCard": "Plexでサインイン", "signInWithPlexCardSubtitle": "このデバイスを承認します。共有サーバーが追加されます。", "signInWithPlexCardSubtitleScoped": "Plexアカウントを承認します。Homeユーザーはプロフィールになります。", - "connectToJellyfinCard": "Jellyfinに接続", - "connectToJellyfinCardSubtitle": "サーバーURL、ユーザー名、パスワードを入力してください。", - "connectToJellyfinCardSubtitleScoped": "Jellyfinサーバーにサインインします。${name}にひも付けられます。", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "別のプロフィールの接続を利用", "borrowFromAnotherProfileSubtitle": "別のプロフィールの接続を再利用します。PINで保護されたプロフィールにはPINが必要です。" } diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 2f6ad86b..8f634355 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Растау күтілуде...\nБраузеріңізден кіріңіз.", "useBrowser": "Браузерді пайдалану", "or": "немесе", - "connectToJellyfin": "Jellyfin-ге қосылу", + "connectToMediaBrowser": "", "useQuickConnect": "Жылдам қосылуды пайдалану", "quickConnectInstructions": "Jellyfin-де Жылдам қосылуды ашып, осы кодты енгізіңіз.", "quickConnectWaiting": "Растау күтілуде…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "${name} үшін сеанс мерзімі өтті", "sessionExpiredMany": "${count} сервер үшін сеанс мерзімі өтті", "signInAgain": "Қайтадан кіру", - "editJellyfinTitle": "Jellyfin қосылымын өңдеу", - "editJellyfinIntro": "${serverName} үшін URL мекенжайын қосыңыз немесе өшіріңіз." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Шолу", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Jellyfin серверін қосу", + "addMediaBrowserTitle": "", "serverUrls": "Сервер URL-дері", "serverUrlsHelper": "Үтірмен бөлінген бірнеше URL мекенжайына рұқсат етіледі.", "findServer": "Серверді табу", - "searchingLocalServers": "Жергілікті Jellyfin серверлері ізделуде...", - "localServers": "Жергілікті Jellyfin серверлері", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Пайдаланушы аты", "password": "Құпия сөз", "signIn": "Кіру", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Plex арқылы кіру", "pinExpired": "PIN код мерзімі өтті.", "failedToRegisterAccount": "Тіркелгіні тіркеу қатесі: ${error}", - "enterJellyfinUrlError": "Jellyfin сервер URL-ін енгізіңіз", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Қосылым қосу", "addConnectionTitleScoped": "${name} профиліне қосу", "signInWithPlexCard": "Plex арқылы кіру", "signInWithPlexCardSubtitle": "Осы құрылғыны авторизациялау.", "signInWithPlexCardSubtitleScoped": "Plex тіркелгісін авторизациялау.", - "connectToJellyfinCard": "Jellyfin-ге қосылу", - "connectToJellyfinCardSubtitle": "Сервер URL-ін, пайдаланушы атын енгізіңіз.", - "connectToJellyfinCardSubtitleScoped": "Jellyfin серверіне кіру. ${name} профиліне жалғануда.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Басқа профильден алу", "borrowFromAnotherProfileSubtitle": "Басқа профильдің қосылымын қайта пайдалану." } diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 0ab23c0d..18ba9b36 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "인증 대기 중...\n브라우저에서 로그인하세요.", "useBrowser": "브라우저 사용", "or": "또는", - "connectToJellyfin": "Jellyfin에 연결", + "connectToMediaBrowser": "", "useQuickConnect": "Quick Connect 사용", "quickConnectInstructions": "Jellyfin에서 Quick Connect를 열고 이 코드를 입력하세요.", "quickConnectWaiting": "승인 대기 중…", @@ -826,8 +826,8 @@ "sessionExpiredOne": "${name}의 세션이 만료되었습니다", "sessionExpiredMany": "${count}개 서버의 세션이 만료되었습니다", "signInAgain": "다시 로그인", - "editJellyfinTitle": "Jellyfin 연결 편집", - "editJellyfinIntro": "${serverName}의 URL을 추가하거나 제거하세요. Plezy는 연결 가능한 URL 중 지연 시간이 가장 낮은 URL을 사용합니다." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "둘러보기", @@ -1867,12 +1867,12 @@ } }, "addServer": { - "addJellyfinTitle": "Jellyfin 서버 추가", + "addMediaBrowserTitle": "", "serverUrls": "서버 URL", "serverUrlsHelper": "쉼표로 구분하여 여러 URL을 입력할 수 있습니다.", "findServer": "서버 찾기", - "searchingLocalServers": "로컬 Jellyfin 서버 검색 중...", - "localServers": "로컬 Jellyfin 서버", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "사용자 이름", "password": "비밀번호", "signIn": "로그인", @@ -1884,15 +1884,15 @@ "addPlexTitle": "Plex로 로그인", "pinExpired": "로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.", "failedToRegisterAccount": "계정 등록 실패: ${error}", - "enterJellyfinUrlError": "Jellyfin 서버 URL을 입력하세요", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "연결 추가", "addConnectionTitleScoped": "${name}에 추가", "signInWithPlexCard": "Plex로 로그인", "signInWithPlexCardSubtitle": "이 기기를 승인합니다. 공유 서버가 추가됩니다.", "signInWithPlexCardSubtitleScoped": "Plex 계정을 승인합니다. Home 사용자는 프로필이 됩니다.", - "connectToJellyfinCard": "Jellyfin에 연결", - "connectToJellyfinCardSubtitle": "서버 URL, 사용자 이름, 비밀번호를 입력하세요.", - "connectToJellyfinCardSubtitleScoped": "Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "다른 프로필에서 빌리기", "borrowFromAnotherProfileSubtitle": "다른 프로필의 연결을 재사용합니다. PIN으로 보호된 프로필에는 PIN이 필요합니다." } diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 8baf28ad..b550b228 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Venter på autentisering...\nLogg inn fra nettleseren.", "useBrowser": "Bruk nettleser", "or": "eller", - "connectToJellyfin": "Koble til Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Bruk Quick Connect", "quickConnectInstructions": "Åpne Quick Connect i Jellyfin og skriv inn denne koden.", "quickConnectWaiting": "Venter på godkjenning…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Økten er utløpt for ${name}", "sessionExpiredMany": "Økten er utløpt for ${count} servere", "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." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Oppdag", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Legg til Jellyfin-server", + "addMediaBrowserTitle": "", "serverUrls": "Server-URL-er", "serverUrlsHelper": "Flere URL-er er tillatt, atskilt med komma.", "findServer": "Finn server", - "searchingLocalServers": "Søker etter lokale Jellyfin-servere...", - "localServers": "Lokale Jellyfin-servere", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Brukernavn", "password": "Passord", "signIn": "Logg inn", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Logg inn med Plex", "pinExpired": "PIN-koden utløp før innloggingen var fullført. Prøv igjen.", "failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}", - "enterJellyfinUrlError": "Oppgi URL-en til Jellyfin-serveren din", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Legg til tilkobling", "addConnectionTitleScoped": "Legg til for ${name}", "signInWithPlexCard": "Logg inn med Plex", "signInWithPlexCardSubtitle": "Autoriser denne enheten. Delte servere legges til.", "signInWithPlexCardSubtitleScoped": "Autoriser en Plex-konto. Home-brukere blir profiler.", - "connectToJellyfinCard": "Koble til Jellyfin", - "connectToJellyfinCardSubtitle": "Skriv inn server-URL, brukernavn og passord.", - "connectToJellyfinCardSubtitleScoped": "Logg på en Jellyfin-server. Knyttes til ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Lån fra en annen profil", "borrowFromAnotherProfileSubtitle": "Gjenbruk en annen profils tilkobling. PIN-beskyttede profiler krever PIN." } diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index f42a9ae4..38618aad 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Wachten op authenticatie...\nMeld je aan via je browser.", "useBrowser": "Gebruik browser", "or": "of", - "connectToJellyfin": "Verbinden met Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Quick Connect gebruiken", "quickConnectInstructions": "Open Quick Connect in Jellyfin en voer deze code in.", "quickConnectWaiting": "Wachten op goedkeuring…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Sessie verlopen voor ${name}", "sessionExpiredMany": "Sessie verlopen voor ${count} servers", "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." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Ontdekken", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Jellyfin-server toevoegen", + "addMediaBrowserTitle": "", "serverUrls": "Server-URL's", "serverUrlsHelper": "Meerdere URL's toegestaan, gescheiden door komma's.", "findServer": "Server zoeken", - "searchingLocalServers": "Lokale Jellyfin-servers zoeken...", - "localServers": "Lokale Jellyfin-servers", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Gebruikersnaam", "password": "Wachtwoord", "signIn": "Inloggen", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Inloggen met Plex", "pinExpired": "De pincode verliep voordat je kon inloggen. Probeer het opnieuw.", "failedToRegisterAccount": "Account registreren mislukt: ${error}", - "enterJellyfinUrlError": "Voer de URL van je Jellyfin-server in", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Verbinding toevoegen", "addConnectionTitleScoped": "Toevoegen aan ${name}", "signInWithPlexCard": "Inloggen met Plex", "signInWithPlexCardSubtitle": "Autoriseer dit apparaat. Gedeelde servers worden toegevoegd.", "signInWithPlexCardSubtitleScoped": "Autoriseer een Plex-account. Home-gebruikers worden profielen.", - "connectToJellyfinCard": "Verbinden met Jellyfin", - "connectToJellyfinCardSubtitle": "Voer je server-URL, gebruikersnaam en wachtwoord in.", - "connectToJellyfinCardSubtitleScoped": "Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Van een ander profiel lenen", "borrowFromAnotherProfileSubtitle": "Hergebruik de verbinding van een ander profiel. Voor profielen met pincodebeveiliging is een pincode vereist." } diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 5358ff85..40896f89 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Oczekiwanie na uwierzytelnienie...\nZaloguj się w przeglądarce.", "useBrowser": "Użyj przeglądarki", "or": "lub", - "connectToJellyfin": "Połącz z Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Użyj Quick Connect", "quickConnectInstructions": "Otwórz Quick Connect w Jellyfin i wpisz ten kod.", "quickConnectWaiting": "Oczekiwanie na zatwierdzenie…", @@ -829,8 +829,8 @@ "sessionExpiredOne": "Sesja wygasła dla ${name}", "sessionExpiredMany": "Sesja wygasła dla ${count} serwerów", "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." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Odkryj", @@ -1885,12 +1885,12 @@ } }, "addServer": { - "addJellyfinTitle": "Dodaj serwer Jellyfin", + "addMediaBrowserTitle": "", "serverUrls": "Adresy URL serwera", "serverUrlsHelper": "Można podać wiele adresów URL rozdzielonych przecinkami.", "findServer": "Znajdź serwer", - "searchingLocalServers": "Szukanie lokalnych serwerów Jellyfin...", - "localServers": "Lokalne serwery Jellyfin", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Nazwa użytkownika", "password": "Hasło", "signIn": "Zaloguj się", @@ -1902,15 +1902,15 @@ "addPlexTitle": "Zaloguj się przez Plex", "pinExpired": "PIN wygasł przed zalogowaniem. Spróbuj ponownie.", "failedToRegisterAccount": "Nie udało się zarejestrować konta: ${error}", - "enterJellyfinUrlError": "Podaj URL serwera Jellyfin", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Dodaj połączenie", "addConnectionTitleScoped": "Dodaj do ${name}", "signInWithPlexCard": "Zaloguj się przez Plex", "signInWithPlexCardSubtitle": "Autoryzuj to urządzenie. Serwery udostępnione zostaną dodane.", "signInWithPlexCardSubtitleScoped": "Autoryzuj konto Plex. Użytkownicy Home staną się profilami.", - "connectToJellyfinCard": "Połącz z Jellyfin", - "connectToJellyfinCardSubtitle": "Wpisz URL serwera, nazwę użytkownika i hasło.", - "connectToJellyfinCardSubtitleScoped": "Zaloguj się do serwera Jellyfin. Powiązane z ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Pożycz z innego profilu", "borrowFromAnotherProfileSubtitle": "Użyj połączenia innego profilu. Profile chronione PIN-em wymagają podania PIN-u." } diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index cf271b11..ce5b4b7b 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Aguardando autenticação...\nEntre pelo navegador.", "useBrowser": "Usar navegador", "or": "ou", - "connectToJellyfin": "Conectar ao Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Usar Quick Connect", "quickConnectInstructions": "Abra o Quick Connect no Jellyfin e insira este código.", "quickConnectWaiting": "Aguardando aprovação…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Sessão de ${name} expirada", "sessionExpiredMany": "Sessões expiradas em ${count} servidores", "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." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Descobrir", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Adicionar servidor Jellyfin", + "addMediaBrowserTitle": "", "serverUrls": "URLs do servidor", "serverUrlsHelper": "Várias URLs são permitidas, separadas por vírgulas.", "findServer": "Encontrar servidor", - "searchingLocalServers": "Procurando servidores Jellyfin locais...", - "localServers": "Servidores Jellyfin locais", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Usuário", "password": "Senha", "signIn": "Entrar", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Entrar com Plex", "pinExpired": "O PIN expirou antes de entrar. Tente novamente.", "failedToRegisterAccount": "Falha ao registrar a conta: ${error}", - "enterJellyfinUrlError": "Insira a URL do seu servidor Jellyfin", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Adicionar conexão", "addConnectionTitleScoped": "Adicionar a ${name}", "signInWithPlexCard": "Entrar com Plex", "signInWithPlexCardSubtitle": "Autorize este dispositivo. Servidores compartilhados são adicionados.", "signInWithPlexCardSubtitleScoped": "Autorize uma conta Plex. Os usuários do Plex Home se tornam perfis.", - "connectToJellyfinCard": "Conectar ao Jellyfin", - "connectToJellyfinCardSubtitle": "Insira URL do servidor, usuário e senha.", - "connectToJellyfinCardSubtitleScoped": "Entre em um servidor Jellyfin. A conexão será vinculada a ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Pegar emprestado de outro perfil", "borrowFromAnotherProfileSubtitle": "Reutilize a conexão de outro perfil. Perfis protegidos por PIN exigem PIN." } diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index f41e3ce0..4d8d399d 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Ожидание аутентификации...\nВыполните вход в браузере.", "useBrowser": "Использовать браузер", "or": "или", - "connectToJellyfin": "Подключиться к Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Использовать Quick Connect", "quickConnectInstructions": "Откройте Quick Connect в Jellyfin и введите этот код.", "quickConnectWaiting": "Ожидание подтверждения…", @@ -829,8 +829,8 @@ "sessionExpiredOne": "Сессия истекла для ${name}", "sessionExpiredMany": "Сессия истекла для ${count} серверов", "signInAgain": "Войти снова", - "editJellyfinTitle": "Изменить подключение Jellyfin", - "editJellyfinIntro": "Добавьте или удалите URL для ${serverName}. Plezy будет использовать доступный URL с минимальной задержкой." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Обзор", @@ -1885,12 +1885,12 @@ } }, "addServer": { - "addJellyfinTitle": "Добавить сервер Jellyfin", + "addMediaBrowserTitle": "", "serverUrls": "URL-адреса сервера", "serverUrlsHelper": "Можно указать несколько URL через запятую.", "findServer": "Найти сервер", - "searchingLocalServers": "Поиск локальных серверов Jellyfin...", - "localServers": "Локальные серверы Jellyfin", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Имя пользователя", "password": "Пароль", "signIn": "Войти", @@ -1902,15 +1902,15 @@ "addPlexTitle": "Войти через Plex", "pinExpired": "Срок действия PIN истёк до входа. Попробуйте снова.", "failedToRegisterAccount": "Не удалось зарегистрировать учётную запись: ${error}", - "enterJellyfinUrlError": "Введите URL вашего сервера Jellyfin", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Добавить подключение", "addConnectionTitleScoped": "Добавить в ${name}", "signInWithPlexCard": "Войти через Plex", "signInWithPlexCardSubtitle": "Авторизуйте это устройство. Общие серверы будут добавлены.", "signInWithPlexCardSubtitleScoped": "Авторизуйте аккаунт Plex. Пользователи Home станут профилями.", - "connectToJellyfinCard": "Подключиться к Jellyfin", - "connectToJellyfinCardSubtitle": "Введите URL сервера, имя пользователя и пароль.", - "connectToJellyfinCardSubtitleScoped": "Войдите на сервер Jellyfin. Привязывается к ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Использовать подключение другого профиля", "borrowFromAnotherProfileSubtitle": "Повторно используйте подключение другого профиля. Для защищённых профилей потребуется PIN." } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index b28c6fc9..906445ed 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 32946 (1497 per locale) +/// Strings: 32736 (1488 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_az.g.dart b/lib/i18n/strings_az.g.dart index 12681937..f6f2801a 100644 --- a/lib/i18n/strings_az.g.dart +++ b/lib/i18n/strings_az.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$az extends Translations$auth$en { @override String get waitingForAuth => 'Təsdiqləmə gözlənilir...\nSəyahətçinizdən (brauzer) daxil olun.'; @override String get useBrowser => 'Səyahətçini istifadə et'; @override String get or => 'və ya'; - @override String get connectToJellyfin => 'Jellyfin-ə qoşul'; @override String get useQuickConnect => 'Sürətli Qoşulmanı istifadə et'; @override String get quickConnectInstructions => 'Jellyfin-də Sürətli Qoşulmanı açın və bu kodu daxil edin.'; @override String get quickConnectWaiting => 'Təsdiq gözlənilir…'; @@ -922,8 +921,6 @@ class _Translations$connections$az extends Translations$connections$en { @override String sessionExpiredOne({required Object name}) => '${name} üçün seansın vaxtı bitdi'; @override String sessionExpiredMany({required Object count}) => '${count} server üçün seansın vaxtı bitdi'; @override String get signInAgain => 'Yenidən daxil ol'; - @override String get editJellyfinTitle => 'Jellyfin qoşulmasını dəyişdir'; - @override String editJellyfinIntro({required Object serverName}) => '${serverName} üçün URL əlavə edin və ya silin.'; } // Path: discover @@ -1814,12 +1811,9 @@ class _Translations$addServer$az extends Translations$addServer$en { final TranslationsAz _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Jellyfin serveri əlavə et'; @override String get serverUrls => 'Server URL-ləri'; @override String get serverUrlsHelper => 'Vergüllə ayrılmış bir neçə URL-ə icazə verilir.'; @override String get findServer => 'Server tap'; - @override String get searchingLocalServers => 'Yerli Jellyfin serverləri axtarılır...'; - @override String get localServers => 'Yerli Jellyfin serverləri'; @override String get username => 'İstifadəçi adı'; @override String get password => 'Şifrə'; @override String get signIn => 'Daxil ol'; @@ -1831,15 +1825,11 @@ class _Translations$addServer$az extends Translations$addServer$en { @override String get addPlexTitle => 'Plex ilə daxil ol'; @override String get pinExpired => 'PIN-in vaxtı bitdi. Lütfən təzədən cəhd edin.'; @override String failedToRegisterAccount({required Object error}) => 'Hesab qeydiyyatı uğursuz oldu: ${error}'; - @override String get enterJellyfinUrlError => 'Jellyfin server URL-inizi daxil edin'; @override String get addConnectionTitle => 'Qoşulma əlavə et'; @override String addConnectionTitleScoped({required Object name}) => '${name} profilinə əlavə et'; @override String get signInWithPlexCard => 'Plex ilə daxil ol'; @override String get signInWithPlexCardSubtitle => 'Bu cihazı səlahiyyətləndirin.'; @override String get signInWithPlexCardSubtitleScoped => 'Plex hesabını səlahiyyətləndirin.'; - @override String get connectToJellyfinCard => 'Jellyfin-ə qoşul'; - @override String get connectToJellyfinCardSubtitle => 'Server URL, istifadəçi adı və şifrənizi daxil edin.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfin serverinə daxil olun. ${name} profilinə bağlanır.'; @override String get borrowFromAnotherProfile => 'Başqa profildən götür'; @override String get borrowFromAnotherProfileSubtitle => 'Başqa profilin qoşulmasını yenidən istifadə edin.'; } @@ -2219,7 +2209,6 @@ extension on TranslationsAz { 'auth.waitingForAuth' => 'Təsdiqləmə gözlənilir...\nSəyahətçinizdən (brauzer) daxil olun.', 'auth.useBrowser' => 'Səyahətçini istifadə et', 'auth.or' => 'və ya', - 'auth.connectToJellyfin' => 'Jellyfin-ə qoşul', 'auth.useQuickConnect' => 'Sürətli Qoşulmanı istifadə et', 'auth.quickConnectInstructions' => 'Jellyfin-də Sürətli Qoşulmanı açın və bu kodu daxil edin.', 'auth.quickConnectWaiting' => 'Təsdiq gözlənilir…', @@ -2722,9 +2711,9 @@ extension on TranslationsAz { 'videoControls.noChaptersAvailable' => 'Hissələr əlçatan deyil', 'videoControls.queue' => 'Növbə', 'videoControls.noQueueItems' => 'Növbədə element yoxdur', + 'videoControls.searchSubtitles' => 'Altyazı axtar', _ => null, } ?? switch (path) { - 'videoControls.searchSubtitles' => 'Altyazı axtar', 'videoControls.language' => 'Dil', 'videoControls.noSubtitlesFound' => 'Altyazı tapılmadı', 'videoControls.subtitleDownloaded' => 'Altyazı yükləndi', @@ -2884,8 +2873,6 @@ extension on TranslationsAz { 'connections.sessionExpiredOne' => ({required Object name}) => '${name} üçün seansın vaxtı bitdi', 'connections.sessionExpiredMany' => ({required Object count}) => '${count} server üçün seansın vaxtı bitdi', 'connections.signInAgain' => 'Yenidən daxil ol', - 'connections.editJellyfinTitle' => 'Jellyfin qoşulmasını dəyişdir', - 'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName} üçün URL əlavə edin və ya silin.', 'discover.title' => 'Kəşf et', 'discover.noContentAvailable' => 'Məzmun əlçatan deyil', 'discover.addMediaToLibraries' => 'Kitabxanalarınıza bir az media əlavə edin', @@ -3236,11 +3223,11 @@ extension on TranslationsAz { 'watchTogether.enterCodeHint' => '5 rəqəmli/hərfli kodu daxil edin', 'watchTogether.pasteFromClipboard' => 'Buferdən yapışdır', 'watchTogether.pleaseEnterCode' => 'Lütfən seans kodunu daxil edin', - _ => null, - } ?? switch (path) { 'watchTogether.codeMustBe5Chars' => 'Seans kodu 5 simvol olmalıdır', 'watchTogether.joinInstructions' => 'Qoşulmaq üçün təşkilatçının seans kodunu daxil edin.', 'watchTogether.failedToCreate' => 'Seans yaradıla bilmədi', + _ => null, + } ?? switch (path) { 'watchTogether.failedToJoin' => 'Seansa qoşuluna bilmədi', 'watchTogether.sessionCodeCopied' => 'Seans kodu buferə kopyalandı', 'watchTogether.relayUnreachable' => 'Rele serverinə çatmaq olmur. İnternet provayderinin bloklaması Birlikdə İzləməyə mane ola bilər.', @@ -3678,12 +3665,9 @@ extension on TranslationsAz { 'services.libraryFilter.modeHintWhitelist' => 'Yalnız aşağıda seçilən kitabxanaları eyniləşdir.', 'services.libraryFilter.libraries' => 'Kitabxanalar', 'services.libraryFilter.noLibraries' => 'Kitabxana yoxdur', - 'addServer.addJellyfinTitle' => 'Jellyfin serveri əlavə et', 'addServer.serverUrls' => 'Server URL-ləri', 'addServer.serverUrlsHelper' => 'Vergüllə ayrılmış bir neçə URL-ə icazə verilir.', 'addServer.findServer' => 'Server tap', - 'addServer.searchingLocalServers' => 'Yerli Jellyfin serverləri axtarılır...', - 'addServer.localServers' => 'Yerli Jellyfin serverləri', 'addServer.username' => 'İstifadəçi adı', 'addServer.password' => 'Şifrə', 'addServer.signIn' => 'Daxil ol', @@ -3695,15 +3679,11 @@ extension on TranslationsAz { 'addServer.addPlexTitle' => 'Plex ilə daxil ol', 'addServer.pinExpired' => 'PIN-in vaxtı bitdi. Lütfən təzədən cəhd edin.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Hesab qeydiyyatı uğursuz oldu: ${error}', - 'addServer.enterJellyfinUrlError' => 'Jellyfin server URL-inizi daxil edin', 'addServer.addConnectionTitle' => 'Qoşulma əlavə et', 'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name} profilinə əlavə et', 'addServer.signInWithPlexCard' => 'Plex ilə daxil ol', 'addServer.signInWithPlexCardSubtitle' => 'Bu cihazı səlahiyyətləndirin.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Plex hesabını səlahiyyətləndirin.', - 'addServer.connectToJellyfinCard' => 'Jellyfin-ə qoşul', - 'addServer.connectToJellyfinCardSubtitle' => 'Server URL, istifadəçi adı və şifrənizi daxil edin.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfin serverinə daxil olun. ${name} profilinə bağlanır.', 'addServer.borrowFromAnotherProfile' => 'Başqa profildən götür', 'addServer.borrowFromAnotherProfileSubtitle' => 'Başqa profilin qoşulmasını yenidən istifadə edin.', _ => null, diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 63fa8076..5acf4aef 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$bg extends Translations$auth$en { @override String get waitingForAuth => 'Изчакване на удостоверяване...\nВлезте от браузъра си.'; @override String get useBrowser => 'Използвай браузър'; @override String get or => 'или'; - @override String get connectToJellyfin => 'Свържи се с Jellyfin'; @override String get useQuickConnect => 'Използвай Quick Connect'; @override String get quickConnectInstructions => 'Отворете Quick Connect в Jellyfin и въведете този код.'; @override String get quickConnectWaiting => 'Изчакване на одобрение…'; @@ -918,8 +917,6 @@ class _Translations$connections$bg extends Translations$connections$en { @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 @@ -1803,12 +1800,9 @@ class _Translations$addServer$bg extends Translations$addServer$en { final TranslationsBg _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Добави Jellyfin сървър'; @override String get serverUrls => 'URL адреси на сървъра'; @override String get serverUrlsHelper => 'Позволени са няколко URL адреса, разделени със запетаи.'; @override String get findServer => 'Намери сървър'; - @override String get searchingLocalServers => 'Търсене на локални Jellyfin сървъри...'; - @override String get localServers => 'Локални Jellyfin сървъри'; @override String get username => 'Потребителско име'; @override String get password => 'Парола'; @override String get signIn => 'Вход'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$bg extends Translations$addServer$en { @override String get addPlexTitle => 'Вход с Plex'; @override String get pinExpired => 'PIN-ът изтече преди вход. Моля, опитайте отново.'; @override String failedToRegisterAccount({required Object error}) => 'Неуспешна регистрация на акаунт: ${error}'; - @override String get enterJellyfinUrlError => 'Въведете URL адреса на вашия Jellyfin сървър'; @override String get addConnectionTitle => 'Добави връзка'; @override String addConnectionTitleScoped({required Object name}) => 'Добави към ${name}'; @override String get signInWithPlexCard => 'Вход с Plex'; @override String get signInWithPlexCardSubtitle => 'Удостоверете това устройство. Споделените сървъри се добавят.'; @override String get signInWithPlexCardSubtitleScoped => 'Удостоверете Plex акаунт. Домашните потребители стават профили.'; - @override String get connectToJellyfinCard => 'Свързване с Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Въведете URL адрес на сървъра, потребителско име и парола.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Вход в Jellyfin сървър. Свързва се с ${name}.'; @override String get borrowFromAnotherProfile => 'Използвай от друг профил'; @override String get borrowFromAnotherProfileSubtitle => 'Използвай връзка от друг профил. PIN-защитените профили изискват PIN.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsBg { 'auth.waitingForAuth' => 'Изчакване на удостоверяване...\nВлезте от браузъра си.', 'auth.useBrowser' => 'Използвай браузър', 'auth.or' => 'или', - 'auth.connectToJellyfin' => 'Свържи се с Jellyfin', 'auth.useQuickConnect' => 'Използвай Quick Connect', 'auth.quickConnectInstructions' => 'Отворете Quick Connect в Jellyfin и въведете този код.', 'auth.quickConnectWaiting' => 'Изчакване на одобрение…', @@ -2711,9 +2700,9 @@ extension on TranslationsBg { 'videoControls.searchSubtitles' => 'Търсене на субтитри', 'videoControls.language' => 'Език', 'videoControls.noSubtitlesFound' => 'Не са намерени субтитри', + 'videoControls.subtitleDownloaded' => 'Субтитърът е изтеглен', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Субтитърът е изтеглен', 'videoControls.subtitleDownloadedNotApplied' => 'Субтитрите са изтеглени, но не можаха да бъдат избрани', 'videoControls.subtitleDownloadFailed' => 'Неуспешно изтегляне на субтитър', 'videoControls.searchLanguages' => 'Търсене на езици...', @@ -2869,8 +2858,6 @@ extension on TranslationsBg { '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.noContentAvailable' => 'Няма налично съдържание', 'discover.addMediaToLibraries' => 'Добавете медия към библиотеките си', @@ -3225,11 +3212,11 @@ extension on TranslationsBg { 'watchTogether.failedToCreate' => 'Неуспешно създаване на сесия', 'watchTogether.failedToJoin' => 'Неуспешно присъединяване към сесия', 'watchTogether.sessionCodeCopied' => 'Кодът на сесията е копиран в клипборда', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Релейният сървър е недостъпен. Възможно е интернет доставчикът да блокира гледането заедно.', 'watchTogether.reconnectingToHost' => 'Повторно свързване с организатора...', 'watchTogether.currentPlayback' => 'Текущо възпроизвеждане', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Присъедини се към текущото възпроизвеждане', 'watchTogether.joinCurrentPlaybackDescription' => 'Върнете се към това, което организаторът гледа в момента', 'watchTogether.failedToOpenCurrentPlayback' => 'Неуспешно отваряне на текущото възпроизвеждане', @@ -3656,12 +3643,9 @@ extension on TranslationsBg { 'services.libraryFilter.modeHintWhitelist' => 'Синхронизирай само отметнатите по-долу библиотеки.', 'services.libraryFilter.libraries' => 'Библиотеки', 'services.libraryFilter.noLibraries' => 'Няма налични библиотеки', - 'addServer.addJellyfinTitle' => 'Добави Jellyfin сървър', 'addServer.serverUrls' => 'URL адреси на сървъра', 'addServer.serverUrlsHelper' => 'Позволени са няколко URL адреса, разделени със запетаи.', 'addServer.findServer' => 'Намери сървър', - 'addServer.searchingLocalServers' => 'Търсене на локални Jellyfin сървъри...', - 'addServer.localServers' => 'Локални Jellyfin сървъри', 'addServer.username' => 'Потребителско име', 'addServer.password' => 'Парола', 'addServer.signIn' => 'Вход', @@ -3673,15 +3657,11 @@ extension on TranslationsBg { 'addServer.addPlexTitle' => 'Вход с Plex', 'addServer.pinExpired' => 'PIN-ът изтече преди вход. Моля, опитайте отново.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Неуспешна регистрация на акаунт: ${error}', - 'addServer.enterJellyfinUrlError' => 'Въведете URL адреса на вашия Jellyfin сървър', 'addServer.addConnectionTitle' => 'Добави връзка', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Добави към ${name}', 'addServer.signInWithPlexCard' => 'Вход с Plex', 'addServer.signInWithPlexCardSubtitle' => 'Удостоверете това устройство. Споделените сървъри се добавят.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Удостоверете Plex акаунт. Домашните потребители стават профили.', - 'addServer.connectToJellyfinCard' => 'Свързване с Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Въведете URL адрес на сървъра, потребителско име и парола.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Вход в Jellyfin сървър. Свързва се с ${name}.', 'addServer.borrowFromAnotherProfile' => 'Използвай от друг профил', 'addServer.borrowFromAnotherProfileSubtitle' => 'Използвай връзка от друг профил. PIN-защитените профили изискват PIN.', _ => null, diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 4a26b12c..ce7fc418 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$da extends Translations$auth$en { @override String get waitingForAuth => 'Venter på godkendelse...\nLog ind fra din browser.'; @override String get useBrowser => 'Brug browseren'; @override String get or => 'eller'; - @override String get connectToJellyfin => 'Forbind til Jellyfin'; @override String get useQuickConnect => 'Brug Quick Connect'; @override String get quickConnectInstructions => 'Åbn Quick Connect i Jellyfin, og indtast denne kode.'; @override String get quickConnectWaiting => 'Venter på godkendelse…'; @@ -918,8 +917,6 @@ class _Translations$connections$da extends Translations$connections$en { @override String sessionExpiredOne({required Object name}) => 'Sessionen er udløbet for ${name}'; @override String sessionExpiredMany({required Object count}) => 'Sessionerne 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 @@ -1803,12 +1800,9 @@ class _Translations$addServer$da extends Translations$addServer$en { final TranslationsDa _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Tilføj Jellyfin-server'; @override String get serverUrls => 'Server-URL\'er'; @override String get serverUrlsHelper => 'Du kan angive flere URL\'er adskilt med komma.'; @override String get findServer => 'Find server'; - @override String get searchingLocalServers => 'Søger efter lokale Jellyfin-servere...'; - @override String get localServers => 'Lokale Jellyfin-servere'; @override String get username => 'Brugernavn'; @override String get password => 'Adgangskode'; @override String get signIn => 'Log ind'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$da extends Translations$addServer$en { @override String get addPlexTitle => 'Log ind med Plex'; @override String get pinExpired => 'PIN-koden udløb før login. Prøv igen.'; @override String failedToRegisterAccount({required Object error}) => 'Kunne ikke registrere kontoen: ${error}'; - @override String get enterJellyfinUrlError => 'Angiv URL\'en til din Jellyfin-server'; @override String get addConnectionTitle => 'Tilføj forbindelse'; @override String addConnectionTitleScoped({required Object name}) => 'Tilføj til ${name}'; @override String get signInWithPlexCard => 'Log ind med Plex'; @override String get signInWithPlexCardSubtitle => 'Godkend denne enhed. Delte servere tilføjes.'; @override String get signInWithPlexCardSubtitleScoped => 'Godkend en Plex-konto. Plex Home-brugere bliver til profiler.'; - @override String get connectToJellyfinCard => 'Forbind til Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Indtast din server-URL, dit brugernavn og din adgangskode.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Log ind på en Jellyfin-server. Serveren knyttes til ${name}.'; @override String get borrowFromAnotherProfile => 'Lån fra en anden profil'; @override String get borrowFromAnotherProfileSubtitle => 'Genbrug en anden profils forbindelse. PIN-beskyttede profiler kræver en PIN.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsDa { 'auth.waitingForAuth' => 'Venter på godkendelse...\nLog ind fra din browser.', 'auth.useBrowser' => 'Brug browseren', 'auth.or' => 'eller', - 'auth.connectToJellyfin' => 'Forbind til Jellyfin', 'auth.useQuickConnect' => 'Brug Quick Connect', 'auth.quickConnectInstructions' => 'Åbn Quick Connect i Jellyfin, og indtast denne kode.', 'auth.quickConnectWaiting' => 'Venter på godkendelse…', @@ -2711,9 +2700,9 @@ extension on TranslationsDa { 'videoControls.searchSubtitles' => 'Søg undertekster', 'videoControls.language' => 'Sprog', 'videoControls.noSubtitlesFound' => 'Ingen undertekster fundet', + 'videoControls.subtitleDownloaded' => 'Undertekst downloadet', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Undertekst downloadet', 'videoControls.subtitleDownloadedNotApplied' => 'Underteksten blev downloadet, men kunne ikke vælges', 'videoControls.subtitleDownloadFailed' => 'Kunne ikke downloade undertekst', 'videoControls.searchLanguages' => 'Søg sprog...', @@ -2869,8 +2858,6 @@ extension on TranslationsDa { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessionen er udløbet for ${name}', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessionerne 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.noContentAvailable' => 'Intet indhold tilgængeligt', 'discover.addMediaToLibraries' => 'Tilføj medier til dine biblioteker', @@ -3225,11 +3212,11 @@ extension on TranslationsDa { 'watchTogether.failedToCreate' => 'Kunne ikke oprette session', 'watchTogether.failedToJoin' => 'Kunne ikke deltage i session', 'watchTogether.sessionCodeCopied' => 'Sessionskode kopieret til udklipsholder', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Relayserveren kan ikke nås. Blokering hos internetudbyderen kan forhindre Se sammen.', 'watchTogether.reconnectingToHost' => 'Genopretter forbindelse til vært...', 'watchTogether.currentPlayback' => 'Nuværende afspilning', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Deltag i nuværende afspilning', 'watchTogether.joinCurrentPlaybackDescription' => 'Hop tilbage til det værten ser nu', 'watchTogether.failedToOpenCurrentPlayback' => 'Kunne ikke åbne nuværende afspilning', @@ -3656,12 +3643,9 @@ extension on TranslationsDa { 'services.libraryFilter.modeHintWhitelist' => 'Synkroniser kun de biblioteker, du markerer nedenfor.', 'services.libraryFilter.libraries' => 'Biblioteker', 'services.libraryFilter.noLibraries' => 'Ingen biblioteker tilgængelige', - 'addServer.addJellyfinTitle' => 'Tilføj Jellyfin-server', 'addServer.serverUrls' => 'Server-URL\'er', 'addServer.serverUrlsHelper' => 'Du kan angive flere URL\'er adskilt med komma.', 'addServer.findServer' => 'Find server', - 'addServer.searchingLocalServers' => 'Søger efter lokale Jellyfin-servere...', - 'addServer.localServers' => 'Lokale Jellyfin-servere', 'addServer.username' => 'Brugernavn', 'addServer.password' => 'Adgangskode', 'addServer.signIn' => 'Log ind', @@ -3673,15 +3657,11 @@ extension on TranslationsDa { 'addServer.addPlexTitle' => 'Log ind med Plex', 'addServer.pinExpired' => 'PIN-koden udløb før login. Prøv igen.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Kunne ikke registrere kontoen: ${error}', - 'addServer.enterJellyfinUrlError' => 'Angiv URL\'en til din Jellyfin-server', 'addServer.addConnectionTitle' => 'Tilføj forbindelse', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Tilføj til ${name}', 'addServer.signInWithPlexCard' => 'Log ind med Plex', 'addServer.signInWithPlexCardSubtitle' => 'Godkend denne enhed. Delte servere tilføjes.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Godkend en Plex-konto. Plex Home-brugere bliver til profiler.', - 'addServer.connectToJellyfinCard' => 'Forbind til Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Indtast din server-URL, dit brugernavn og din adgangskode.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Log ind på en Jellyfin-server. Serveren knyttes til ${name}.', 'addServer.borrowFromAnotherProfile' => 'Lån fra en anden profil', 'addServer.borrowFromAnotherProfileSubtitle' => 'Genbrug en anden profils forbindelse. PIN-beskyttede profiler kræver en PIN.', _ => null, diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 0667d943..99acab24 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$de extends Translations$auth$en { @override String get waitingForAuth => 'Warte auf die Authentifizierung …\nMelde dich über deinen Browser an.'; @override String get useBrowser => 'Browser verwenden'; @override String get or => 'oder'; - @override String get connectToJellyfin => 'Mit Jellyfin verbinden'; @override String get useQuickConnect => 'Quick Connect verwenden'; @override String get quickConnectInstructions => 'Öffne Quick Connect in Jellyfin und gib diesen Code ein.'; @override String get quickConnectWaiting => 'Warte auf Bestätigung…'; @@ -918,8 +917,6 @@ class _Translations$connections$de extends Translations$connections$en { @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 @@ -1803,12 +1800,9 @@ class _Translations$addServer$de extends Translations$addServer$en { final TranslationsDe _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Jellyfin-Server hinzufügen'; @override String get serverUrls => 'Server-URLs'; @override String get serverUrlsHelper => 'Mehrere URLs möglich, durch Kommas getrennt.'; @override String get findServer => 'Server finden'; - @override String get searchingLocalServers => 'Suche nach lokalen Jellyfin-Servern …'; - @override String get localServers => 'Lokale Jellyfin-Server'; @override String get username => 'Benutzername'; @override String get password => 'Passwort'; @override String get signIn => 'Anmelden'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$de extends Translations$addServer$en { @override String get addPlexTitle => 'Mit Plex anmelden'; @override String get pinExpired => 'PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.'; @override String failedToRegisterAccount({required Object error}) => 'Konto konnte nicht registriert werden: ${error}'; - @override String get enterJellyfinUrlError => 'Gib die URL deines Jellyfin-Servers ein'; @override String get addConnectionTitle => 'Verbindung hinzufügen'; @override String addConnectionTitleScoped({required Object name}) => 'Zu ${name} hinzufügen'; @override String get signInWithPlexCard => 'Mit Plex anmelden'; @override String get signInWithPlexCardSubtitle => 'Dieses Gerät autorisieren. Geteilte Server werden hinzugefügt.'; @override String get signInWithPlexCardSubtitleScoped => 'Ein Plex-Konto autorisieren. Home-Benutzer werden zu Profilen.'; - @override String get connectToJellyfinCard => 'Mit Jellyfin verbinden'; - @override String get connectToJellyfinCardSubtitle => 'Gib Server-URL, Benutzername und Passwort ein.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.'; @override String get borrowFromAnotherProfile => 'Von einem anderen Profil ausleihen'; @override String get borrowFromAnotherProfileSubtitle => 'Verbindung eines anderen Profils wiederverwenden. PIN-geschützte Profile erfordern eine PIN.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsDe { 'auth.waitingForAuth' => 'Warte auf die Authentifizierung …\nMelde dich über deinen Browser an.', 'auth.useBrowser' => 'Browser verwenden', 'auth.or' => 'oder', - 'auth.connectToJellyfin' => 'Mit Jellyfin verbinden', 'auth.useQuickConnect' => 'Quick Connect verwenden', 'auth.quickConnectInstructions' => 'Öffne Quick Connect in Jellyfin und gib diesen Code ein.', 'auth.quickConnectWaiting' => 'Warte auf Bestätigung…', @@ -2711,9 +2700,9 @@ extension on TranslationsDe { 'videoControls.searchSubtitles' => 'Untertitel suchen', 'videoControls.language' => 'Sprache', 'videoControls.noSubtitlesFound' => 'Keine Untertitel gefunden', + 'videoControls.subtitleDownloaded' => 'Untertitel heruntergeladen', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Untertitel heruntergeladen', 'videoControls.subtitleDownloadedNotApplied' => 'Der Untertitel wurde heruntergeladen, konnte aber nicht ausgewählt werden', 'videoControls.subtitleDownloadFailed' => 'Untertitel konnte nicht heruntergeladen werden', 'videoControls.searchLanguages' => 'Sprachen suchen...', @@ -2869,8 +2858,6 @@ 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.noContentAvailable' => 'Kein Inhalt verfügbar', 'discover.addMediaToLibraries' => 'Medien zur Mediathek hinzufügen', @@ -3225,11 +3212,11 @@ extension on TranslationsDe { 'watchTogether.failedToCreate' => 'Sitzung konnte nicht erstellt werden', 'watchTogether.failedToJoin' => 'Beitritt zur Sitzung fehlgeschlagen', 'watchTogether.sessionCodeCopied' => 'Sitzungscode in Zwischenablage kopiert', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Relay-Server nicht erreichbar. Eine Sperre durch den Internetanbieter kann gemeinsames Schauen verhindern.', 'watchTogether.reconnectingToHost' => 'Verbindung zum Host wird wiederhergestellt …', 'watchTogether.currentPlayback' => 'Aktuelle Wiedergabe', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Aktueller Wiedergabe beitreten', 'watchTogether.joinCurrentPlaybackDescription' => 'Zu dem Inhalt wechseln, den der Host gerade ansieht', 'watchTogether.failedToOpenCurrentPlayback' => 'Aktuelle Wiedergabe konnte nicht geöffnet werden', @@ -3656,12 +3643,9 @@ extension on TranslationsDe { 'services.libraryFilter.modeHintWhitelist' => 'Nur die unten markierten Mediatheken synchronisieren.', 'services.libraryFilter.libraries' => 'Mediatheken', 'services.libraryFilter.noLibraries' => 'Keine Mediatheken verfügbar', - 'addServer.addJellyfinTitle' => 'Jellyfin-Server hinzufügen', 'addServer.serverUrls' => 'Server-URLs', 'addServer.serverUrlsHelper' => 'Mehrere URLs möglich, durch Kommas getrennt.', 'addServer.findServer' => 'Server finden', - 'addServer.searchingLocalServers' => 'Suche nach lokalen Jellyfin-Servern …', - 'addServer.localServers' => 'Lokale Jellyfin-Server', 'addServer.username' => 'Benutzername', 'addServer.password' => 'Passwort', 'addServer.signIn' => 'Anmelden', @@ -3673,15 +3657,11 @@ extension on TranslationsDe { 'addServer.addPlexTitle' => 'Mit Plex anmelden', 'addServer.pinExpired' => 'PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Konto konnte nicht registriert werden: ${error}', - 'addServer.enterJellyfinUrlError' => 'Gib die URL deines Jellyfin-Servers ein', 'addServer.addConnectionTitle' => 'Verbindung hinzufügen', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Zu ${name} hinzufügen', 'addServer.signInWithPlexCard' => 'Mit Plex anmelden', 'addServer.signInWithPlexCardSubtitle' => 'Dieses Gerät autorisieren. Geteilte Server werden hinzugefügt.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Ein Plex-Konto autorisieren. Home-Benutzer werden zu Profilen.', - 'addServer.connectToJellyfinCard' => 'Mit Jellyfin verbinden', - 'addServer.connectToJellyfinCardSubtitle' => 'Gib Server-URL, Benutzername und Passwort ein.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.', 'addServer.borrowFromAnotherProfile' => 'Von einem anderen Profil ausleihen', 'addServer.borrowFromAnotherProfileSubtitle' => 'Verbindung eines anderen Profils wiederverwenden. PIN-geschützte Profile erfordern eine PIN.', _ => null, diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 59a7893b..732842fe 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -136,8 +136,8 @@ class Translations$auth$en { /// en: 'or' String get or => 'or'; - /// en: 'Connect to Jellyfin' - String get connectToJellyfin => 'Connect to Jellyfin'; + /// en: 'Connect to ${product}' + String connectToMediaBrowser({required Object product}) => 'Connect to ${product}'; /// en: 'Use Quick Connect' String get useQuickConnect => 'Use Quick Connect'; @@ -2413,8 +2413,8 @@ class Translations$profiles$en { /// en: 'Nothing to borrow yet.' String get borrowEmpty => 'Nothing to borrow yet.'; - /// en: 'Connect Plex or Jellyfin to another profile first.' - String get borrowEmptySubtitle => 'Connect Plex or Jellyfin to another profile first.'; + /// en: 'Connect Plex, Jellyfin, or Emby to another profile first.' + String get borrowEmptySubtitle => 'Connect Plex, Jellyfin, or Emby to another profile first.'; /// en: 'Available connections could not be loaded. Try again.' String get borrowLoadFailed => 'Available connections could not be loaded. Try again.'; @@ -2476,11 +2476,11 @@ class Translations$connections$en { /// en: 'Add connection' String get addConnection => 'Add connection'; - /// en: 'Sign in with Plex or connect a Jellyfin server' - String get addConnectionSubtitleNoProfile => 'Sign in with Plex or connect a Jellyfin server'; + /// en: 'Sign in with Plex or connect a Jellyfin or Emby server' + String get addConnectionSubtitleNoProfile => 'Sign in with Plex or connect a Jellyfin or Emby server'; - /// en: 'Add to ${displayName}: Plex, Jellyfin, or another profile connection' - String addConnectionSubtitleScoped({required Object displayName}) => 'Add to ${displayName}: Plex, Jellyfin, or another profile connection'; + /// en: 'Add to ${displayName}: Plex, Jellyfin, Emby, or another profile connection' + String addConnectionSubtitleScoped({required Object displayName}) => 'Add to ${displayName}: Plex, Jellyfin, Emby, or another profile connection'; /// en: 'Session expired for ${name}' String sessionExpiredOne({required Object name}) => 'Session expired for ${name}'; @@ -2491,11 +2491,11 @@ class Translations$connections$en { /// en: 'Sign in again' String get signInAgain => 'Sign in again'; - /// en: 'Edit Jellyfin connection' - String get editJellyfinTitle => 'Edit Jellyfin connection'; + /// en: 'Edit ${product} connection' + String editMediaBrowserTitle({required Object product}) => 'Edit ${product} 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.'; + String editMediaBrowserIntro({required Object serverName}) => 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.'; } // Path: discover @@ -2778,8 +2778,8 @@ class Translations$about$en { /// en: 'Version ${version}' String versionLabel({required Object version}) => 'Version ${version}'; - /// en: 'A beautiful Plex and Jellyfin client for Flutter' - String get appDescription => 'A beautiful Plex and Jellyfin client for Flutter'; + /// en: 'A beautiful Plex, Jellyfin, and Emby client for Flutter' + String get appDescription => 'A beautiful Plex, Jellyfin, and Emby client for Flutter'; /// en: 'View licenses of third-party libraries' String get viewLicensesDescription => 'View licenses of third-party libraries'; @@ -4747,8 +4747,8 @@ class Translations$addServer$en { // Translations - /// en: 'Add Jellyfin server' - String get addJellyfinTitle => 'Add Jellyfin server'; + /// en: 'Add ${product} server' + String addMediaBrowserTitle({required Object product}) => 'Add ${product} server'; /// en: 'Server URLs' String get serverUrls => 'Server URLs'; @@ -4759,11 +4759,11 @@ class Translations$addServer$en { /// en: 'Find server' String get findServer => 'Find server'; - /// en: 'Looking for local Jellyfin servers...' - String get searchingLocalServers => 'Looking for local Jellyfin servers...'; + /// en: 'Looking for local ${product} servers...' + String searchingLocalMediaBrowserServers({required Object product}) => 'Looking for local ${product} servers...'; - /// en: 'Local Jellyfin servers' - String get localServers => 'Local Jellyfin servers'; + /// en: 'Local ${product} servers' + String localMediaBrowserServers({required Object product}) => 'Local ${product} servers'; /// en: 'Username' String get username => 'Username'; @@ -4798,8 +4798,8 @@ class Translations$addServer$en { /// en: 'Failed to register account: ${error}' String failedToRegisterAccount({required Object error}) => 'Failed to register account: ${error}'; - /// en: 'Enter your Jellyfin server URL' - String get enterJellyfinUrlError => 'Enter your Jellyfin server URL'; + /// en: 'Enter your ${product} server URL' + String enterMediaBrowserUrlError({required Object product}) => 'Enter your ${product} server URL'; /// en: 'Add connection' String get addConnectionTitle => 'Add connection'; @@ -4816,14 +4816,14 @@ class Translations$addServer$en { /// en: 'Authorize a Plex account. Home users become profiles.' String get signInWithPlexCardSubtitleScoped => 'Authorize a Plex account. Home users become profiles.'; - /// en: 'Connect to Jellyfin' - String get connectToJellyfinCard => 'Connect to Jellyfin'; + /// en: 'Connect to ${product}' + String connectToMediaBrowserCard({required Object product}) => 'Connect to ${product}'; /// en: 'Enter your server URL, username, and password.' - String get connectToJellyfinCardSubtitle => 'Enter your server URL, username, and password.'; + String get connectToMediaBrowserCardSubtitle => 'Enter your server URL, username, and password.'; - /// en: 'Sign in to a Jellyfin server. Binds to ${name}.' - String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Sign in to a Jellyfin server. Binds to ${name}.'; + /// en: 'Sign in to your ${product} server. Binds to ${name}.' + String connectToMediaBrowserCardSubtitleScoped({required Object product, required Object name}) => 'Sign in to your ${product} server. Binds to ${name}.'; /// en: 'Borrow from another profile' String get borrowFromAnotherProfile => 'Borrow from another profile'; @@ -6043,7 +6043,7 @@ extension on Translations { 'auth.waitingForAuth' => 'Waiting for authentication...\nSign in from your browser.', 'auth.useBrowser' => 'Use browser', 'auth.or' => 'or', - 'auth.connectToJellyfin' => 'Connect to Jellyfin', + 'auth.connectToMediaBrowser' => ({required Object product}) => 'Connect to ${product}', 'auth.useQuickConnect' => 'Use Quick Connect', 'auth.quickConnectInstructions' => 'Open Quick Connect in Jellyfin and enter this code.', 'auth.quickConnectWaiting' => 'Waiting for approval…', @@ -6790,7 +6790,7 @@ extension on Translations { 'profiles.borrowAddTo' => ({required Object displayName}) => 'Add to ${displayName}', 'profiles.borrowExplain' => 'Borrow another profile\'s connection. PIN-protected profiles require a PIN.', 'profiles.borrowEmpty' => 'Nothing to borrow yet.', - 'profiles.borrowEmptySubtitle' => 'Connect Plex or Jellyfin to another profile first.', + 'profiles.borrowEmptySubtitle' => 'Connect Plex, Jellyfin, or Emby to another profile first.', 'profiles.borrowLoadFailed' => 'Available connections could not be loaded. Try again.', 'profiles.borrowFromProfile' => ({required Object displayName}) => 'From ${displayName}', 'profiles.borrowConnectionBorrowed' => 'Connection borrowed.', @@ -6808,13 +6808,13 @@ extension on Translations { 'profiles.pinsDontMatch' => 'PINs don\'t match', 'connections.sectionTitle' => 'Connections', 'connections.addConnection' => 'Add connection', - 'connections.addConnectionSubtitleNoProfile' => 'Sign in with Plex or connect a Jellyfin server', - 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Add to ${displayName}: Plex, Jellyfin, or another profile connection', + 'connections.addConnectionSubtitleNoProfile' => 'Sign in with Plex or connect a Jellyfin or Emby server', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Add to ${displayName}: Plex, Jellyfin, Emby, or another profile connection', '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.', + 'connections.editMediaBrowserTitle' => ({required Object product}) => 'Edit ${product} connection', + 'connections.editMediaBrowserIntro' => ({required Object serverName}) => 'Add or remove URLs for ${serverName}. Plezy will use the reachable URL with the lowest latency.', 'discover.title' => 'Discover', 'discover.noContentAvailable' => 'No content available', 'discover.addMediaToLibraries' => 'Add some media to your libraries', @@ -6936,7 +6936,7 @@ extension on Translations { 'about.title' => 'About', 'about.openSourceLicenses' => 'Open Source Licenses', 'about.versionLabel' => ({required Object version}) => 'Version ${version}', - 'about.appDescription' => 'A beautiful Plex and Jellyfin client for Flutter', + 'about.appDescription' => 'A beautiful Plex, Jellyfin, and Emby client for Flutter', 'about.viewLicensesDescription' => 'View licenses of third-party libraries', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'No servers found for ${username} (${email})', 'serverSelection.failedToLoadServers' => ({required Object error}) => 'Failed to load servers: ${error}', @@ -7744,12 +7744,12 @@ extension on Translations { 'services.libraryFilter.modeHintWhitelist' => 'Sync only the libraries checked below.', 'services.libraryFilter.libraries' => 'Libraries', 'services.libraryFilter.noLibraries' => 'No libraries available', - 'addServer.addJellyfinTitle' => 'Add Jellyfin server', + 'addServer.addMediaBrowserTitle' => ({required Object product}) => 'Add ${product} server', 'addServer.serverUrls' => 'Server URLs', 'addServer.serverUrlsHelper' => 'Multiple URLs allowed, separated by commas.', 'addServer.findServer' => 'Find server', - 'addServer.searchingLocalServers' => 'Looking for local Jellyfin servers...', - 'addServer.localServers' => 'Local Jellyfin servers', + 'addServer.searchingLocalMediaBrowserServers' => ({required Object product}) => 'Looking for local ${product} servers...', + 'addServer.localMediaBrowserServers' => ({required Object product}) => 'Local ${product} servers', 'addServer.username' => 'Username', 'addServer.password' => 'Password', 'addServer.signIn' => 'Sign in', @@ -7761,15 +7761,15 @@ extension on Translations { 'addServer.addPlexTitle' => 'Sign in with Plex', 'addServer.pinExpired' => 'PIN expired before sign-in. Please try again.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Failed to register account: ${error}', - 'addServer.enterJellyfinUrlError' => 'Enter your Jellyfin server URL', + 'addServer.enterMediaBrowserUrlError' => ({required Object product}) => 'Enter your ${product} server URL', 'addServer.addConnectionTitle' => 'Add connection', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Add to ${name}', 'addServer.signInWithPlexCard' => 'Sign in with Plex', 'addServer.signInWithPlexCardSubtitle' => 'Authorize this device. Shared servers are added.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Authorize a Plex account. Home users become profiles.', - 'addServer.connectToJellyfinCard' => 'Connect to Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Enter your server URL, username, and password.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Sign in to a Jellyfin server. Binds to ${name}.', + 'addServer.connectToMediaBrowserCard' => ({required Object product}) => 'Connect to ${product}', + 'addServer.connectToMediaBrowserCardSubtitle' => 'Enter your server URL, username, and password.', + 'addServer.connectToMediaBrowserCardSubtitleScoped' => ({required Object product, required Object name}) => 'Sign in to your ${product} server. Binds to ${name}.', 'addServer.borrowFromAnotherProfile' => 'Borrow from another profile', 'addServer.borrowFromAnotherProfileSubtitle' => 'Reuse another profile\'s connection. PIN-protected profiles require a PIN.', _ => null, diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index ff331fff..2971ee74 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$es extends Translations$auth$en { @override String get waitingForAuth => 'Esperando autenticación...\nInicia sesión desde tu navegador.'; @override String get useBrowser => 'Usar navegador'; @override String get or => 'o'; - @override String get connectToJellyfin => 'Conectar a Jellyfin'; @override String get useQuickConnect => 'Usar Quick Connect'; @override String get quickConnectInstructions => 'Abre Quick Connect en Jellyfin e introduce este código.'; @override String get quickConnectWaiting => 'Esperando aprobación…'; @@ -918,8 +917,6 @@ class _Translations$connections$es extends Translations$connections$en { @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 direcciones URL para ${serverName}. Plezy usará la dirección accesible con menor latencia.'; } // Path: discover @@ -1803,12 +1800,9 @@ class _Translations$addServer$es extends Translations$addServer$en { final TranslationsEs _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Añadir servidor Jellyfin'; @override String get serverUrls => 'Direcciones URL del servidor'; @override String get serverUrlsHelper => 'Se permiten varias URL, separadas por comas.'; @override String get findServer => 'Buscar servidor'; - @override String get searchingLocalServers => 'Buscando servidores Jellyfin locales...'; - @override String get localServers => 'Servidores Jellyfin locales'; @override String get username => 'Usuario'; @override String get password => 'Contraseña'; @override String get signIn => 'Iniciar sesión'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$es extends Translations$addServer$en { @override String get addPlexTitle => 'Iniciar sesión con Plex'; @override String get pinExpired => 'El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.'; @override String failedToRegisterAccount({required Object error}) => 'No se pudo registrar la cuenta: ${error}'; - @override String get enterJellyfinUrlError => 'Introduce la URL de tu servidor Jellyfin'; @override String get addConnectionTitle => 'Añadir conexión'; @override String addConnectionTitleScoped({required Object name}) => 'Añadir a ${name}'; @override String get signInWithPlexCard => 'Iniciar sesión con Plex'; @override String get signInWithPlexCardSubtitle => 'Autoriza este dispositivo. Se añaden servidores compartidos.'; @override String get signInWithPlexCardSubtitleScoped => 'Autoriza una cuenta Plex. Los usuarios de Home se convierten en perfiles.'; - @override String get connectToJellyfinCard => 'Conectar a Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Introduce la URL del servidor, usuario y contraseña.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Inicia sesión en un servidor Jellyfin. Se vincula a ${name}.'; @override String get borrowFromAnotherProfile => 'Tomar prestado de otro perfil'; @override String get borrowFromAnotherProfileSubtitle => 'Reutiliza la conexión de otro perfil. Los perfiles protegidos con PIN requieren un PIN.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsEs { 'auth.waitingForAuth' => 'Esperando autenticación...\nInicia sesión desde tu navegador.', 'auth.useBrowser' => 'Usar navegador', 'auth.or' => 'o', - 'auth.connectToJellyfin' => 'Conectar a Jellyfin', 'auth.useQuickConnect' => 'Usar Quick Connect', 'auth.quickConnectInstructions' => 'Abre Quick Connect en Jellyfin e introduce este código.', 'auth.quickConnectWaiting' => 'Esperando aprobación…', @@ -2711,9 +2700,9 @@ extension on TranslationsEs { 'videoControls.searchSubtitles' => 'Buscar subtítulos', 'videoControls.language' => 'Idioma', 'videoControls.noSubtitlesFound' => 'No se encontraron subtítulos', + 'videoControls.subtitleDownloaded' => 'Subtítulo descargado', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Subtítulo descargado', 'videoControls.subtitleDownloadedNotApplied' => 'El subtítulo se descargó, pero no se pudo seleccionar', 'videoControls.subtitleDownloadFailed' => 'Error al descargar subtítulo', 'videoControls.searchLanguages' => 'Buscar idiomas...', @@ -2869,8 +2858,6 @@ 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 direcciones URL para ${serverName}. Plezy usará la dirección accesible con menor latencia.', 'discover.title' => 'Descubrir', 'discover.noContentAvailable' => 'No hay contenido disponible', 'discover.addMediaToLibraries' => 'Añade contenido a tus bibliotecas', @@ -3225,11 +3212,11 @@ extension on TranslationsEs { 'watchTogether.failedToCreate' => 'Error al crear la sesión', 'watchTogether.failedToJoin' => 'Error al unirse a la sesión', 'watchTogether.sessionCodeCopied' => 'Código de sesión copiado al portapapeles', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'No se puede acceder al servidor de retransmisión. Es posible que tu proveedor de internet esté bloqueando Ver juntos.', 'watchTogether.reconnectingToHost' => 'Reconectando con el anfitrión...', 'watchTogether.currentPlayback' => 'Reproducción actual', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Unirse a la reproducción actual', 'watchTogether.joinCurrentPlaybackDescription' => 'Vuelve a lo que el anfitrión está viendo ahora mismo', 'watchTogether.failedToOpenCurrentPlayback' => 'No se pudo abrir la reproducción actual', @@ -3656,12 +3643,9 @@ extension on TranslationsEs { 'services.libraryFilter.modeHintWhitelist' => 'Sincronizar solo las bibliotecas seleccionadas abajo.', 'services.libraryFilter.libraries' => 'Bibliotecas', 'services.libraryFilter.noLibraries' => 'No hay bibliotecas disponibles', - 'addServer.addJellyfinTitle' => 'Añadir servidor Jellyfin', 'addServer.serverUrls' => 'Direcciones URL del servidor', 'addServer.serverUrlsHelper' => 'Se permiten varias URL, separadas por comas.', 'addServer.findServer' => 'Buscar servidor', - 'addServer.searchingLocalServers' => 'Buscando servidores Jellyfin locales...', - 'addServer.localServers' => 'Servidores Jellyfin locales', 'addServer.username' => 'Usuario', 'addServer.password' => 'Contraseña', 'addServer.signIn' => 'Iniciar sesión', @@ -3673,15 +3657,11 @@ extension on TranslationsEs { 'addServer.addPlexTitle' => 'Iniciar sesión con Plex', 'addServer.pinExpired' => 'El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'No se pudo registrar la cuenta: ${error}', - 'addServer.enterJellyfinUrlError' => 'Introduce la URL de tu servidor Jellyfin', 'addServer.addConnectionTitle' => 'Añadir conexión', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Añadir a ${name}', 'addServer.signInWithPlexCard' => 'Iniciar sesión con Plex', 'addServer.signInWithPlexCardSubtitle' => 'Autoriza este dispositivo. Se añaden servidores compartidos.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Autoriza una cuenta Plex. Los usuarios de Home se convierten en perfiles.', - 'addServer.connectToJellyfinCard' => 'Conectar a Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Introduce la URL del servidor, usuario y contraseña.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Inicia sesión en un servidor Jellyfin. Se vincula a ${name}.', 'addServer.borrowFromAnotherProfile' => 'Tomar prestado de otro perfil', 'addServer.borrowFromAnotherProfileSubtitle' => 'Reutiliza la conexión de otro perfil. Los perfiles protegidos con PIN requieren un PIN.', _ => null, diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index ada1cf80..f6be83e3 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$fr extends Translations$auth$en { @override String get waitingForAuth => 'En attente d\'authentification...\nConnectez-vous depuis votre navigateur.'; @override String get useBrowser => 'Utiliser le navigateur'; @override String get or => 'ou'; - @override String get connectToJellyfin => 'Se connecter à Jellyfin'; @override String get useQuickConnect => 'Utiliser Quick Connect'; @override String get quickConnectInstructions => 'Ouvrez Quick Connect dans Jellyfin et saisissez ce code.'; @override String get quickConnectWaiting => 'En attente d\'approbation…'; @@ -918,8 +917,6 @@ class _Translations$connections$fr extends Translations$connections$en { @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 @@ -1803,12 +1800,9 @@ class _Translations$addServer$fr extends Translations$addServer$en { final TranslationsFr _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Ajouter un serveur Jellyfin'; @override String get serverUrls => 'URL du serveur'; @override String get serverUrlsHelper => 'Plusieurs URL possibles, séparées par des virgules.'; @override String get findServer => 'Rechercher un serveur'; - @override String get searchingLocalServers => 'Recherche de serveurs Jellyfin locaux...'; - @override String get localServers => 'Serveurs Jellyfin locaux'; @override String get username => 'Nom d\'utilisateur'; @override String get password => 'Mot de passe'; @override String get signIn => 'Se connecter'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$fr extends Translations$addServer$en { @override String get addPlexTitle => 'Se connecter avec Plex'; @override String get pinExpired => 'Le PIN a expiré avant la connexion. Veuillez réessayer.'; @override String failedToRegisterAccount({required Object error}) => 'Échec de l\'enregistrement du compte : ${error}'; - @override String get enterJellyfinUrlError => 'Saisissez l\'URL de votre serveur Jellyfin'; @override String get addConnectionTitle => 'Ajouter une connexion'; @override String addConnectionTitleScoped({required Object name}) => 'Ajouter à ${name}'; @override String get signInWithPlexCard => 'Se connecter avec Plex'; @override String get signInWithPlexCardSubtitle => 'Autorisez cet appareil. Les serveurs partagés sont ajoutés.'; @override String get signInWithPlexCardSubtitleScoped => 'Autorisez un compte Plex. Les utilisateurs Home deviennent des profils.'; - @override String get connectToJellyfinCard => 'Se connecter à Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Saisissez l\'URL du serveur, le nom d\'utilisateur et le mot de passe.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Connectez-vous à un serveur Jellyfin. Cette connexion sera liée à ${name}.'; @override String get borrowFromAnotherProfile => 'Emprunter à un autre profil'; @override String get borrowFromAnotherProfileSubtitle => 'Réutiliser la connexion d\'un autre profil. Les profils protégés par PIN exigent un PIN.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsFr { 'auth.waitingForAuth' => 'En attente d\'authentification...\nConnectez-vous depuis votre navigateur.', 'auth.useBrowser' => 'Utiliser le navigateur', 'auth.or' => 'ou', - 'auth.connectToJellyfin' => 'Se connecter à Jellyfin', 'auth.useQuickConnect' => 'Utiliser Quick Connect', 'auth.quickConnectInstructions' => 'Ouvrez Quick Connect dans Jellyfin et saisissez ce code.', 'auth.quickConnectWaiting' => 'En attente d\'approbation…', @@ -2711,9 +2700,9 @@ extension on TranslationsFr { 'videoControls.searchSubtitles' => 'Rechercher des sous-titres', 'videoControls.language' => 'Langue', 'videoControls.noSubtitlesFound' => 'Aucun sous-titre trouvé', + 'videoControls.subtitleDownloaded' => 'Sous-titre téléchargé', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Sous-titre téléchargé', 'videoControls.subtitleDownloadedNotApplied' => 'Le sous-titre a été téléchargé, mais n’a pas pu être sélectionné', 'videoControls.subtitleDownloadFailed' => 'Échec du téléchargement du sous-titre', 'videoControls.searchLanguages' => 'Rechercher des langues...', @@ -2869,8 +2858,6 @@ 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écouvrir', 'discover.noContentAvailable' => 'Aucun contenu disponible', 'discover.addMediaToLibraries' => 'Ajoutez des médias à vos bibliothèques', @@ -3225,11 +3212,11 @@ extension on TranslationsFr { 'watchTogether.failedToCreate' => 'Échec de la création de la session', 'watchTogether.failedToJoin' => 'Échec de la connexion à la session', 'watchTogether.sessionCodeCopied' => 'Code de session copié dans le presse-papiers', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Serveur relais inaccessible. Un blocage par le fournisseur d’accès peut empêcher le fonctionnement de Regarder ensemble.', 'watchTogether.reconnectingToHost' => 'Reconnexion à l\'hôte...', 'watchTogether.currentPlayback' => 'Lecture en cours', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Rejoindre la lecture en cours', 'watchTogether.joinCurrentPlaybackDescription' => 'Reprendre le contenu que l’hôte regarde actuellement', 'watchTogether.failedToOpenCurrentPlayback' => 'Impossible d\'ouvrir la lecture en cours', @@ -3656,12 +3643,9 @@ extension on TranslationsFr { 'services.libraryFilter.modeHintWhitelist' => 'Synchroniser uniquement les bibliothèques cochées ci-dessous.', 'services.libraryFilter.libraries' => 'Bibliothèques', 'services.libraryFilter.noLibraries' => 'Aucune bibliothèque disponible', - 'addServer.addJellyfinTitle' => 'Ajouter un serveur Jellyfin', 'addServer.serverUrls' => 'URL du serveur', 'addServer.serverUrlsHelper' => 'Plusieurs URL possibles, séparées par des virgules.', 'addServer.findServer' => 'Rechercher un serveur', - 'addServer.searchingLocalServers' => 'Recherche de serveurs Jellyfin locaux...', - 'addServer.localServers' => 'Serveurs Jellyfin locaux', 'addServer.username' => 'Nom d\'utilisateur', 'addServer.password' => 'Mot de passe', 'addServer.signIn' => 'Se connecter', @@ -3673,15 +3657,11 @@ extension on TranslationsFr { 'addServer.addPlexTitle' => 'Se connecter avec Plex', 'addServer.pinExpired' => 'Le PIN a expiré avant la connexion. Veuillez réessayer.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Échec de l\'enregistrement du compte : ${error}', - 'addServer.enterJellyfinUrlError' => 'Saisissez l\'URL de votre serveur Jellyfin', 'addServer.addConnectionTitle' => 'Ajouter une connexion', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Ajouter à ${name}', 'addServer.signInWithPlexCard' => 'Se connecter avec Plex', 'addServer.signInWithPlexCardSubtitle' => 'Autorisez cet appareil. Les serveurs partagés sont ajoutés.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Autorisez un compte Plex. Les utilisateurs Home deviennent des profils.', - 'addServer.connectToJellyfinCard' => 'Se connecter à Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Saisissez l\'URL du serveur, le nom d\'utilisateur et le mot de passe.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Connectez-vous à un serveur Jellyfin. Cette connexion sera liée à ${name}.', 'addServer.borrowFromAnotherProfile' => 'Emprunter à un autre profil', 'addServer.borrowFromAnotherProfileSubtitle' => 'Réutiliser la connexion d\'un autre profil. Les profils protégés par PIN exigent un PIN.', _ => null, diff --git a/lib/i18n/strings_hu.g.dart b/lib/i18n/strings_hu.g.dart index f2b257f1..dedf1eb9 100644 --- a/lib/i18n/strings_hu.g.dart +++ b/lib/i18n/strings_hu.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$hu extends Translations$auth$en { @override String get waitingForAuth => 'Várakozás a hitelesítésre...\nJelentkezz be a böngésződben.'; @override String get useBrowser => 'Böngésző használata'; @override String get or => 'vagy'; - @override String get connectToJellyfin => 'Csatlakozás Jellyfinhez'; @override String get useQuickConnect => 'Quick Connect használata'; @override String get quickConnectInstructions => 'Nyisd meg a Quick Connect-et a Jellyfinben, és add meg ezt a kódot.'; @override String get quickConnectWaiting => 'Várakozás a jóváhagyásra…'; @@ -918,8 +917,6 @@ class _Translations$connections$hu extends Translations$connections$en { @override String sessionExpiredOne({required Object name}) => 'A(z) ${name} munkamenete lejárt'; @override String sessionExpiredMany({required Object count}) => '${count} szerver munkamenete lejárt'; @override String get signInAgain => 'Bejelentkezés újra'; - @override String get editJellyfinTitle => 'Jellyfin kapcsolat szerkesztése'; - @override String editJellyfinIntro({required Object serverName}) => 'URL-ek hozzáadása vagy eltávolítása ehhez: ${serverName}. A Plezy a legalacsonyabb késleltetésű, elérhető URL-t fogja használni.'; } // Path: discover @@ -1803,12 +1800,9 @@ class _Translations$addServer$hu extends Translations$addServer$en { final TranslationsHu _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Jellyfin szerver hozzáadása'; @override String get serverUrls => 'Szerver URL-címei'; @override String get serverUrlsHelper => 'Több URL is megadható, vesszővel elválasztva.'; @override String get findServer => 'Szerver keresése'; - @override String get searchingLocalServers => 'Helyi Jellyfin-szerverek keresése...'; - @override String get localServers => 'Helyi Jellyfin-szerverek'; @override String get username => 'Felhasználónév'; @override String get password => 'Jelszó'; @override String get signIn => 'Bejelentkezés'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$hu extends Translations$addServer$en { @override String get addPlexTitle => 'Bejelentkezés Plexszel'; @override String get pinExpired => 'A PIN-kód a bejelentkezés előtt lejárt. Próbáld újra.'; @override String failedToRegisterAccount({required Object error}) => 'Nem sikerült a fiók regisztrálása: ${error}'; - @override String get enterJellyfinUrlError => 'Add meg a Jellyfin szervered URL-jét'; @override String get addConnectionTitle => 'Kapcsolat hozzáadása'; @override String addConnectionTitleScoped({required Object name}) => 'Hozzáadás a következőhöz: ${name}'; @override String get signInWithPlexCard => 'Bejelentkezés Plexszel'; @override String get signInWithPlexCardSubtitle => 'Eszköz engedélyezése. A megosztott szerverek hozzáadásra kerülnek.'; @override String get signInWithPlexCardSubtitleScoped => 'Plex-fiók engedélyezése. A Plex Home-felhasználókból profilok lesznek.'; - @override String get connectToJellyfinCard => 'Csatlakozás Jellyfinhez'; - @override String get connectToJellyfinCardSubtitle => 'Add meg a szerver URL-jét, felhasználónevedet és jelszavadat.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Bejelentkezés egy Jellyfin-szerverre. Hozzárendelés ehhez: ${name}.'; @override String get borrowFromAnotherProfile => 'Kapcsolat használata másik profilból'; @override String get borrowFromAnotherProfileSubtitle => 'Egy másik profil kapcsolatának használata. A PIN-kóddal védett profilokhoz PIN-kód szükséges.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsHu { 'auth.waitingForAuth' => 'Várakozás a hitelesítésre...\nJelentkezz be a böngésződben.', 'auth.useBrowser' => 'Böngésző használata', 'auth.or' => 'vagy', - 'auth.connectToJellyfin' => 'Csatlakozás Jellyfinhez', 'auth.useQuickConnect' => 'Quick Connect használata', 'auth.quickConnectInstructions' => 'Nyisd meg a Quick Connect-et a Jellyfinben, és add meg ezt a kódot.', 'auth.quickConnectWaiting' => 'Várakozás a jóváhagyásra…', @@ -2711,9 +2700,9 @@ extension on TranslationsHu { 'videoControls.searchSubtitles' => 'Feliratok keresése', 'videoControls.language' => 'Nyelv', 'videoControls.noSubtitlesFound' => 'Nem találhatók feliratok', + 'videoControls.subtitleDownloaded' => 'Felirat letöltve', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Felirat letöltve', 'videoControls.subtitleDownloadedNotApplied' => 'Felirat letöltve, de nem sikerült kiválasztani', 'videoControls.subtitleDownloadFailed' => 'Nem sikerült a felirat letöltése', 'videoControls.searchLanguages' => 'Nyelvek keresése...', @@ -2869,8 +2858,6 @@ extension on TranslationsHu { 'connections.sessionExpiredOne' => ({required Object name}) => 'A(z) ${name} munkamenete lejárt', 'connections.sessionExpiredMany' => ({required Object count}) => '${count} szerver munkamenete lejárt', 'connections.signInAgain' => 'Bejelentkezés újra', - 'connections.editJellyfinTitle' => 'Jellyfin kapcsolat szerkesztése', - 'connections.editJellyfinIntro' => ({required Object serverName}) => 'URL-ek hozzáadása vagy eltávolítása ehhez: ${serverName}. A Plezy a legalacsonyabb késleltetésű, elérhető URL-t fogja használni.', 'discover.title' => 'Felfedezés', 'discover.noContentAvailable' => 'Nincs elérhető tartalom', 'discover.addMediaToLibraries' => 'Adj hozzá médiát a könyvtáraidhoz', @@ -3225,11 +3212,11 @@ extension on TranslationsHu { 'watchTogether.failedToCreate' => 'Nem sikerült a munkamenet létrehozása', 'watchTogether.failedToJoin' => 'Nem sikerült csatlakozni a munkamenethez', 'watchTogether.sessionCodeCopied' => 'A munkamenetkód a vágólapra másolva', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'A relészerver nem érhető el. Az internetszolgáltató blokkolása megakadályozhatja a közös nézést.', 'watchTogether.reconnectingToHost' => 'Újracsatlakozás a házigazdához...', 'watchTogether.currentPlayback' => 'Jelenlegi lejátszás', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Csatlakozás a jelenlegi lejátszáshoz', 'watchTogether.joinCurrentPlaybackDescription' => 'Visszatérés ahhoz, amit a házigazda éppen néz', 'watchTogether.failedToOpenCurrentPlayback' => 'Nem sikerült megnyitni a jelenlegi lejátszást', @@ -3656,12 +3643,9 @@ extension on TranslationsHu { 'services.libraryFilter.modeHintWhitelist' => 'Csak az alább bejelölt könyvtárak szinkronizálása.', 'services.libraryFilter.libraries' => 'Könyvtárak', 'services.libraryFilter.noLibraries' => 'Nincsenek elérhető könyvtárak', - 'addServer.addJellyfinTitle' => 'Jellyfin szerver hozzáadása', 'addServer.serverUrls' => 'Szerver URL-címei', 'addServer.serverUrlsHelper' => 'Több URL is megadható, vesszővel elválasztva.', 'addServer.findServer' => 'Szerver keresése', - 'addServer.searchingLocalServers' => 'Helyi Jellyfin-szerverek keresése...', - 'addServer.localServers' => 'Helyi Jellyfin-szerverek', 'addServer.username' => 'Felhasználónév', 'addServer.password' => 'Jelszó', 'addServer.signIn' => 'Bejelentkezés', @@ -3673,15 +3657,11 @@ extension on TranslationsHu { 'addServer.addPlexTitle' => 'Bejelentkezés Plexszel', 'addServer.pinExpired' => 'A PIN-kód a bejelentkezés előtt lejárt. Próbáld újra.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Nem sikerült a fiók regisztrálása: ${error}', - 'addServer.enterJellyfinUrlError' => 'Add meg a Jellyfin szervered URL-jét', 'addServer.addConnectionTitle' => 'Kapcsolat hozzáadása', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Hozzáadás a következőhöz: ${name}', 'addServer.signInWithPlexCard' => 'Bejelentkezés Plexszel', 'addServer.signInWithPlexCardSubtitle' => 'Eszköz engedélyezése. A megosztott szerverek hozzáadásra kerülnek.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Plex-fiók engedélyezése. A Plex Home-felhasználókból profilok lesznek.', - 'addServer.connectToJellyfinCard' => 'Csatlakozás Jellyfinhez', - 'addServer.connectToJellyfinCardSubtitle' => 'Add meg a szerver URL-jét, felhasználónevedet és jelszavadat.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Bejelentkezés egy Jellyfin-szerverre. Hozzárendelés ehhez: ${name}.', 'addServer.borrowFromAnotherProfile' => 'Kapcsolat használata másik profilból', 'addServer.borrowFromAnotherProfileSubtitle' => 'Egy másik profil kapcsolatának használata. A PIN-kóddal védett profilokhoz PIN-kód szükséges.', _ => null, diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index cb4ed4b7..9b28f29b 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$it extends Translations$auth$en { @override String get waitingForAuth => 'In attesa di autenticazione...\nAccedi dal browser.'; @override String get useBrowser => 'Usa il browser'; @override String get or => 'o'; - @override String get connectToJellyfin => 'Connettiti a Jellyfin'; @override String get useQuickConnect => 'Usa Quick Connect'; @override String get quickConnectInstructions => 'Apri Quick Connect in Jellyfin e inserisci questo codice.'; @override String get quickConnectWaiting => 'In attesa di approvazione…'; @@ -918,8 +917,6 @@ class _Translations$connections$it extends Translations$connections$en { @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 @@ -1803,12 +1800,9 @@ class _Translations$addServer$it extends Translations$addServer$en { final TranslationsIt _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Aggiungi server Jellyfin'; @override String get serverUrls => 'URL del server'; @override String get serverUrlsHelper => 'Sono consentiti più URL, separati da virgole.'; @override String get findServer => 'Trova il server'; - @override String get searchingLocalServers => 'Ricerca dei server Jellyfin locali...'; - @override String get localServers => 'Server Jellyfin locali'; @override String get username => 'Nome utente'; @override String get password => 'Password'; @override String get signIn => 'Accedi'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$it extends Translations$addServer$en { @override String get addPlexTitle => 'Accedi con Plex'; @override String get pinExpired => 'PIN scaduto prima dell\'accesso. Riprova.'; @override String failedToRegisterAccount({required Object error}) => 'Registrazione account non riuscita: ${error}'; - @override String get enterJellyfinUrlError => 'Inserisci l\'URL del tuo server Jellyfin'; @override String get addConnectionTitle => 'Aggiungi connessione'; @override String addConnectionTitleScoped({required Object name}) => 'Aggiungi a ${name}'; @override String get signInWithPlexCard => 'Accedi con Plex'; @override String get signInWithPlexCardSubtitle => 'Autorizza questo dispositivo. I server condivisi vengono aggiunti.'; @override String get signInWithPlexCardSubtitleScoped => 'Autorizza un account Plex. Gli utenti Home diventano profili.'; - @override String get connectToJellyfinCard => 'Connettiti a Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Inserisci l\'URL del server, il nome utente e la password.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Accedi a un server Jellyfin. Verrà associato a ${name}.'; @override String get borrowFromAnotherProfile => 'Prendi in prestito da un altro profilo'; @override String get borrowFromAnotherProfileSubtitle => 'Riutilizza la connessione di un altro profilo. I profili protetti da PIN richiedono un PIN.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsIt { 'auth.waitingForAuth' => 'In attesa di autenticazione...\nAccedi dal browser.', 'auth.useBrowser' => 'Usa il browser', 'auth.or' => 'o', - 'auth.connectToJellyfin' => 'Connettiti a Jellyfin', 'auth.useQuickConnect' => 'Usa Quick Connect', 'auth.quickConnectInstructions' => 'Apri Quick Connect in Jellyfin e inserisci questo codice.', 'auth.quickConnectWaiting' => 'In attesa di approvazione…', @@ -2711,9 +2700,9 @@ extension on TranslationsIt { 'videoControls.searchSubtitles' => 'Cerca sottotitoli', 'videoControls.language' => 'Lingua', 'videoControls.noSubtitlesFound' => 'Nessun sottotitolo trovato', + 'videoControls.subtitleDownloaded' => 'Sottotitolo scaricato', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Sottotitolo scaricato', 'videoControls.subtitleDownloadedNotApplied' => 'Il sottotitolo è stato scaricato, ma non è stato possibile selezionarlo', 'videoControls.subtitleDownloadFailed' => 'Impossibile scaricare il sottotitolo', 'videoControls.searchLanguages' => 'Cerca lingue...', @@ -2869,8 +2858,6 @@ 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.noContentAvailable' => 'Nessun contenuto disponibile', 'discover.addMediaToLibraries' => 'Aggiungi contenuti multimediali alle tue librerie', @@ -3225,11 +3212,11 @@ extension on TranslationsIt { 'watchTogether.failedToCreate' => 'Impossibile creare la sessione', 'watchTogether.failedToJoin' => 'Impossibile unirsi alla sessione', 'watchTogether.sessionCodeCopied' => 'Codice della sessione copiato negli appunti', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Il server relay non è raggiungibile. Eventuali blocchi dell\'ISP potrebbero impedire l\'uso di Guarda insieme.', 'watchTogether.reconnectingToHost' => 'Riconnessione all\'host...', 'watchTogether.currentPlayback' => 'Riproduzione corrente', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Unisciti alla riproduzione corrente', 'watchTogether.joinCurrentPlaybackDescription' => 'Torna a ciò che l\'host sta guardando in questo momento', 'watchTogether.failedToOpenCurrentPlayback' => 'Impossibile aprire la riproduzione corrente', @@ -3656,12 +3643,9 @@ extension on TranslationsIt { 'services.libraryFilter.modeHintWhitelist' => 'Sincronizza solo le librerie selezionate qui sotto.', 'services.libraryFilter.libraries' => 'Librerie', 'services.libraryFilter.noLibraries' => 'Nessuna libreria disponibile', - 'addServer.addJellyfinTitle' => 'Aggiungi server Jellyfin', 'addServer.serverUrls' => 'URL del server', 'addServer.serverUrlsHelper' => 'Sono consentiti più URL, separati da virgole.', 'addServer.findServer' => 'Trova il server', - 'addServer.searchingLocalServers' => 'Ricerca dei server Jellyfin locali...', - 'addServer.localServers' => 'Server Jellyfin locali', 'addServer.username' => 'Nome utente', 'addServer.password' => 'Password', 'addServer.signIn' => 'Accedi', @@ -3673,15 +3657,11 @@ extension on TranslationsIt { 'addServer.addPlexTitle' => 'Accedi con Plex', 'addServer.pinExpired' => 'PIN scaduto prima dell\'accesso. Riprova.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Registrazione account non riuscita: ${error}', - 'addServer.enterJellyfinUrlError' => 'Inserisci l\'URL del tuo server Jellyfin', 'addServer.addConnectionTitle' => 'Aggiungi connessione', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Aggiungi a ${name}', 'addServer.signInWithPlexCard' => 'Accedi con Plex', 'addServer.signInWithPlexCardSubtitle' => 'Autorizza questo dispositivo. I server condivisi vengono aggiunti.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Autorizza un account Plex. Gli utenti Home diventano profili.', - 'addServer.connectToJellyfinCard' => 'Connettiti a Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Inserisci l\'URL del server, il nome utente e la password.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Accedi a un server Jellyfin. Verrà associato a ${name}.', 'addServer.borrowFromAnotherProfile' => 'Prendi in prestito da un altro profilo', 'addServer.borrowFromAnotherProfileSubtitle' => 'Riutilizza la connessione di un altro profilo. I profili protetti da PIN richiedono un PIN.', _ => null, diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 8adc8b1b..9edf58d2 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$ja extends Translations$auth$en { @override String get waitingForAuth => '認証を待っています…\nブラウザでサインインしてください。'; @override String get useBrowser => 'ブラウザを使用'; @override String get or => 'または'; - @override String get connectToJellyfin => 'Jellyfinに接続'; @override String get useQuickConnect => 'Quick Connect を使う'; @override String get quickConnectInstructions => 'JellyfinでQuick Connectを開き、このコードを入力してください。'; @override String get quickConnectWaiting => '承認を待っています…'; @@ -917,8 +916,6 @@ class _Translations$connections$ja extends Translations$connections$en { @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 @@ -1800,12 +1797,9 @@ class _Translations$addServer$ja extends Translations$addServer$en { final TranslationsJa _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Jellyfinサーバーを追加'; @override String get serverUrls => 'サーバーURL'; @override String get serverUrlsHelper => '複数のURLをカンマ区切りで入力できます。'; @override String get findServer => 'サーバーを検索'; - @override String get searchingLocalServers => 'ローカルのJellyfinサーバーを検索中…'; - @override String get localServers => 'ローカルのJellyfinサーバー'; @override String get username => 'ユーザー名'; @override String get password => 'パスワード'; @override String get signIn => 'サインイン'; @@ -1817,15 +1811,11 @@ class _Translations$addServer$ja extends Translations$addServer$en { @override String get addPlexTitle => 'Plexでサインイン'; @override String get pinExpired => 'サインイン前にPINの有効期限が切れました。もう一度お試しください。'; @override String failedToRegisterAccount({required Object error}) => 'アカウントの登録に失敗しました: ${error}'; - @override String get enterJellyfinUrlError => 'JellyfinサーバーのURLを入力してください'; @override String get addConnectionTitle => '接続を追加'; @override String addConnectionTitleScoped({required Object name}) => '${name}に追加'; @override String get signInWithPlexCard => 'Plexでサインイン'; @override String get signInWithPlexCardSubtitle => 'このデバイスを承認します。共有サーバーが追加されます。'; @override String get signInWithPlexCardSubtitleScoped => 'Plexアカウントを承認します。Homeユーザーはプロフィールになります。'; - @override String get connectToJellyfinCard => 'Jellyfinに接続'; - @override String get connectToJellyfinCardSubtitle => 'サーバーURL、ユーザー名、パスワードを入力してください。'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfinサーバーにサインインします。${name}にひも付けられます。'; @override String get borrowFromAnotherProfile => '別のプロフィールの接続を利用'; @override String get borrowFromAnotherProfileSubtitle => '別のプロフィールの接続を再利用します。PINで保護されたプロフィールにはPINが必要です。'; } @@ -2205,7 +2195,6 @@ extension on TranslationsJa { 'auth.waitingForAuth' => '認証を待っています…\nブラウザでサインインしてください。', 'auth.useBrowser' => 'ブラウザを使用', 'auth.or' => 'または', - 'auth.connectToJellyfin' => 'Jellyfinに接続', 'auth.useQuickConnect' => 'Quick Connect を使う', 'auth.quickConnectInstructions' => 'JellyfinでQuick Connectを開き、このコードを入力してください。', 'auth.quickConnectWaiting' => '承認を待っています…', @@ -2708,9 +2697,9 @@ extension on TranslationsJa { 'videoControls.searchSubtitles' => '字幕を検索', 'videoControls.language' => '言語', 'videoControls.noSubtitlesFound' => '字幕が見つかりません', + 'videoControls.subtitleDownloaded' => '字幕をダウンロードしました', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => '字幕をダウンロードしました', 'videoControls.subtitleDownloadedNotApplied' => '字幕はダウンロードされましたが、選択できませんでした', 'videoControls.subtitleDownloadFailed' => '字幕のダウンロードに失敗しました', 'videoControls.searchLanguages' => '言語を検索…', @@ -2866,8 +2855,6 @@ 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.noContentAvailable' => 'コンテンツがありません', 'discover.addMediaToLibraries' => 'ライブラリにメディアを追加してください', @@ -3222,11 +3209,11 @@ extension on TranslationsJa { 'watchTogether.failedToCreate' => 'セッションの作成に失敗しました', 'watchTogether.failedToJoin' => 'セッションへの参加に失敗しました', 'watchTogether.sessionCodeCopied' => 'セッションコードをクリップボードにコピーしました', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'リレーサーバーに接続できません。ISPによるブロックのため「一緒に見る」を利用できない可能性があります。', 'watchTogether.reconnectingToHost' => 'ホストに再接続中…', 'watchTogether.currentPlayback' => '現在の再生', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => '現在の再生に参加', 'watchTogether.joinCurrentPlaybackDescription' => 'ホストが現在視聴中のコンテンツに戻る', 'watchTogether.failedToOpenCurrentPlayback' => '現在の再生を開けませんでした', @@ -3653,12 +3640,9 @@ extension on TranslationsJa { 'services.libraryFilter.modeHintWhitelist' => '下でチェックしたライブラリのみ同期します。', 'services.libraryFilter.libraries' => 'ライブラリ', 'services.libraryFilter.noLibraries' => '利用できるライブラリがありません', - 'addServer.addJellyfinTitle' => 'Jellyfinサーバーを追加', 'addServer.serverUrls' => 'サーバーURL', 'addServer.serverUrlsHelper' => '複数のURLをカンマ区切りで入力できます。', 'addServer.findServer' => 'サーバーを検索', - 'addServer.searchingLocalServers' => 'ローカルのJellyfinサーバーを検索中…', - 'addServer.localServers' => 'ローカルのJellyfinサーバー', 'addServer.username' => 'ユーザー名', 'addServer.password' => 'パスワード', 'addServer.signIn' => 'サインイン', @@ -3670,15 +3654,11 @@ extension on TranslationsJa { 'addServer.addPlexTitle' => 'Plexでサインイン', 'addServer.pinExpired' => 'サインイン前にPINの有効期限が切れました。もう一度お試しください。', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'アカウントの登録に失敗しました: ${error}', - 'addServer.enterJellyfinUrlError' => 'JellyfinサーバーのURLを入力してください', 'addServer.addConnectionTitle' => '接続を追加', 'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name}に追加', 'addServer.signInWithPlexCard' => 'Plexでサインイン', 'addServer.signInWithPlexCardSubtitle' => 'このデバイスを承認します。共有サーバーが追加されます。', 'addServer.signInWithPlexCardSubtitleScoped' => 'Plexアカウントを承認します。Homeユーザーはプロフィールになります。', - 'addServer.connectToJellyfinCard' => 'Jellyfinに接続', - 'addServer.connectToJellyfinCardSubtitle' => 'サーバーURL、ユーザー名、パスワードを入力してください。', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfinサーバーにサインインします。${name}にひも付けられます。', 'addServer.borrowFromAnotherProfile' => '別のプロフィールの接続を利用', 'addServer.borrowFromAnotherProfileSubtitle' => '別のプロフィールの接続を再利用します。PINで保護されたプロフィールにはPINが必要です。', _ => null, diff --git a/lib/i18n/strings_kk.g.dart b/lib/i18n/strings_kk.g.dart index d7596d53..72460908 100644 --- a/lib/i18n/strings_kk.g.dart +++ b/lib/i18n/strings_kk.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$kk extends Translations$auth$en { @override String get waitingForAuth => 'Растау күтілуде...\nБраузеріңізден кіріңіз.'; @override String get useBrowser => 'Браузерді пайдалану'; @override String get or => 'немесе'; - @override String get connectToJellyfin => 'Jellyfin-ге қосылу'; @override String get useQuickConnect => 'Жылдам қосылуды пайдалану'; @override String get quickConnectInstructions => 'Jellyfin-де Жылдам қосылуды ашып, осы кодты енгізіңіз.'; @override String get quickConnectWaiting => 'Растау күтілуде…'; @@ -922,8 +921,6 @@ class _Translations$connections$kk extends Translations$connections$en { @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 мекенжайын қосыңыз немесе өшіріңіз.'; } // Path: discover @@ -1814,12 +1811,9 @@ class _Translations$addServer$kk extends Translations$addServer$en { final TranslationsKk _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Jellyfin серверін қосу'; @override String get serverUrls => 'Сервер URL-дері'; @override String get serverUrlsHelper => 'Үтірмен бөлінген бірнеше URL мекенжайына рұқсат етіледі.'; @override String get findServer => 'Серверді табу'; - @override String get searchingLocalServers => 'Жергілікті Jellyfin серверлері ізделуде...'; - @override String get localServers => 'Жергілікті Jellyfin серверлері'; @override String get username => 'Пайдаланушы аты'; @override String get password => 'Құпия сөз'; @override String get signIn => 'Кіру'; @@ -1831,15 +1825,11 @@ class _Translations$addServer$kk extends Translations$addServer$en { @override String get addPlexTitle => 'Plex арқылы кіру'; @override String get pinExpired => 'PIN код мерзімі өтті.'; @override String failedToRegisterAccount({required Object error}) => 'Тіркелгіні тіркеу қатесі: ${error}'; - @override String get enterJellyfinUrlError => 'Jellyfin сервер URL-ін енгізіңіз'; @override String get addConnectionTitle => 'Қосылым қосу'; @override String addConnectionTitleScoped({required Object name}) => '${name} профиліне қосу'; @override String get signInWithPlexCard => 'Plex арқылы кіру'; @override String get signInWithPlexCardSubtitle => 'Осы құрылғыны авторизациялау.'; @override String get signInWithPlexCardSubtitleScoped => 'Plex тіркелгісін авторизациялау.'; - @override String get connectToJellyfinCard => 'Jellyfin-ге қосылу'; - @override String get connectToJellyfinCardSubtitle => 'Сервер URL-ін, пайдаланушы атын енгізіңіз.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfin серверіне кіру. ${name} профиліне жалғануда.'; @override String get borrowFromAnotherProfile => 'Басқа профильден алу'; @override String get borrowFromAnotherProfileSubtitle => 'Басқа профильдің қосылымын қайта пайдалану.'; } @@ -2219,7 +2209,6 @@ extension on TranslationsKk { 'auth.waitingForAuth' => 'Растау күтілуде...\nБраузеріңізден кіріңіз.', 'auth.useBrowser' => 'Браузерді пайдалану', 'auth.or' => 'немесе', - 'auth.connectToJellyfin' => 'Jellyfin-ге қосылу', 'auth.useQuickConnect' => 'Жылдам қосылуды пайдалану', 'auth.quickConnectInstructions' => 'Jellyfin-де Жылдам қосылуды ашып, осы кодты енгізіңіз.', 'auth.quickConnectWaiting' => 'Растау күтілуде…', @@ -2722,9 +2711,9 @@ extension on TranslationsKk { 'videoControls.noChaptersAvailable' => 'Бөлімдер қолжетімсіз', 'videoControls.queue' => 'Кезек', 'videoControls.noQueueItems' => 'Кезекте элементтер жоқ', + 'videoControls.searchSubtitles' => 'Субтитр іздеу', _ => null, } ?? switch (path) { - 'videoControls.searchSubtitles' => 'Субтитр іздеу', 'videoControls.language' => 'Тіл', 'videoControls.noSubtitlesFound' => 'Субтитр табылмады', 'videoControls.subtitleDownloaded' => 'Субтитр жүктелді', @@ -2884,8 +2873,6 @@ extension on TranslationsKk { 'connections.sessionExpiredOne' => ({required Object name}) => '${name} үшін сеанс мерзімі өтті', 'connections.sessionExpiredMany' => ({required Object count}) => '${count} сервер үшін сеанс мерзімі өтті', 'connections.signInAgain' => 'Қайтадан кіру', - 'connections.editJellyfinTitle' => 'Jellyfin қосылымын өңдеу', - 'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName} үшін URL мекенжайын қосыңыз немесе өшіріңіз.', 'discover.title' => 'Шолу', 'discover.noContentAvailable' => 'Мазмұн қолжетімсіз', 'discover.addMediaToLibraries' => 'Кітапханаларыңызға медиа қосыңыз', @@ -3236,11 +3223,11 @@ extension on TranslationsKk { 'watchTogether.enterCodeHint' => '5 таңбалы кодты енгізіңіз', 'watchTogether.pasteFromClipboard' => 'Алмасу буферінен қою', 'watchTogether.pleaseEnterCode' => 'Сеанс кодын енгізіңіз', - _ => null, - } ?? switch (path) { 'watchTogether.codeMustBe5Chars' => 'Сеанс коды 5 таңбадан тұруы керек', 'watchTogether.joinInstructions' => 'Ұйымдастырушының сеанс кодын енгізіңіз.', 'watchTogether.failedToCreate' => 'Сеансты жасау мүмкін болмады', + _ => null, + } ?? switch (path) { 'watchTogether.failedToJoin' => 'Сеансқа қосылу мүмкін болмады', 'watchTogether.sessionCodeCopied' => 'Сеанс коды көшірілді', 'watchTogether.relayUnreachable' => 'Реле сервері қолжетімсіз.', @@ -3678,12 +3665,9 @@ extension on TranslationsKk { 'services.libraryFilter.modeHintWhitelist' => 'Тек төменде таңдалған кітапханаларды синхрондау.', 'services.libraryFilter.libraries' => 'Кітапханалар', 'services.libraryFilter.noLibraries' => 'Кітапханалар жоқ', - 'addServer.addJellyfinTitle' => 'Jellyfin серверін қосу', 'addServer.serverUrls' => 'Сервер URL-дері', 'addServer.serverUrlsHelper' => 'Үтірмен бөлінген бірнеше URL мекенжайына рұқсат етіледі.', 'addServer.findServer' => 'Серверді табу', - 'addServer.searchingLocalServers' => 'Жергілікті Jellyfin серверлері ізделуде...', - 'addServer.localServers' => 'Жергілікті Jellyfin серверлері', 'addServer.username' => 'Пайдаланушы аты', 'addServer.password' => 'Құпия сөз', 'addServer.signIn' => 'Кіру', @@ -3695,15 +3679,11 @@ extension on TranslationsKk { 'addServer.addPlexTitle' => 'Plex арқылы кіру', 'addServer.pinExpired' => 'PIN код мерзімі өтті.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Тіркелгіні тіркеу қатесі: ${error}', - 'addServer.enterJellyfinUrlError' => 'Jellyfin сервер URL-ін енгізіңіз', 'addServer.addConnectionTitle' => 'Қосылым қосу', 'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name} профиліне қосу', 'addServer.signInWithPlexCard' => 'Plex арқылы кіру', 'addServer.signInWithPlexCardSubtitle' => 'Осы құрылғыны авторизациялау.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Plex тіркелгісін авторизациялау.', - 'addServer.connectToJellyfinCard' => 'Jellyfin-ге қосылу', - 'addServer.connectToJellyfinCardSubtitle' => 'Сервер URL-ін, пайдаланушы атын енгізіңіз.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfin серверіне кіру. ${name} профиліне жалғануда.', 'addServer.borrowFromAnotherProfile' => 'Басқа профильден алу', 'addServer.borrowFromAnotherProfileSubtitle' => 'Басқа профильдің қосылымын қайта пайдалану.', _ => null, diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 0086c47c..2b7666b5 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$ko extends Translations$auth$en { @override String get waitingForAuth => '인증 대기 중...\n브라우저에서 로그인하세요.'; @override String get useBrowser => '브라우저 사용'; @override String get or => '또는'; - @override String get connectToJellyfin => 'Jellyfin에 연결'; @override String get useQuickConnect => 'Quick Connect 사용'; @override String get quickConnectInstructions => 'Jellyfin에서 Quick Connect를 열고 이 코드를 입력하세요.'; @override String get quickConnectWaiting => '승인 대기 중…'; @@ -917,8 +916,6 @@ class _Translations$connections$ko extends Translations$connections$en { @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 @@ -1800,12 +1797,9 @@ class _Translations$addServer$ko extends Translations$addServer$en { final TranslationsKo _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Jellyfin 서버 추가'; @override String get serverUrls => '서버 URL'; @override String get serverUrlsHelper => '쉼표로 구분하여 여러 URL을 입력할 수 있습니다.'; @override String get findServer => '서버 찾기'; - @override String get searchingLocalServers => '로컬 Jellyfin 서버 검색 중...'; - @override String get localServers => '로컬 Jellyfin 서버'; @override String get username => '사용자 이름'; @override String get password => '비밀번호'; @override String get signIn => '로그인'; @@ -1817,15 +1811,11 @@ class _Translations$addServer$ko extends Translations$addServer$en { @override String get addPlexTitle => 'Plex로 로그인'; @override String get pinExpired => '로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.'; @override String failedToRegisterAccount({required Object error}) => '계정 등록 실패: ${error}'; - @override String get enterJellyfinUrlError => 'Jellyfin 서버 URL을 입력하세요'; @override String get addConnectionTitle => '연결 추가'; @override String addConnectionTitleScoped({required Object name}) => '${name}에 추가'; @override String get signInWithPlexCard => 'Plex로 로그인'; @override String get signInWithPlexCardSubtitle => '이 기기를 승인합니다. 공유 서버가 추가됩니다.'; @override String get signInWithPlexCardSubtitleScoped => 'Plex 계정을 승인합니다. Home 사용자는 프로필이 됩니다.'; - @override String get connectToJellyfinCard => 'Jellyfin에 연결'; - @override String get connectToJellyfinCardSubtitle => '서버 URL, 사용자 이름, 비밀번호를 입력하세요.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.'; @override String get borrowFromAnotherProfile => '다른 프로필에서 빌리기'; @override String get borrowFromAnotherProfileSubtitle => '다른 프로필의 연결을 재사용합니다. PIN으로 보호된 프로필에는 PIN이 필요합니다.'; } @@ -2205,7 +2195,6 @@ extension on TranslationsKo { 'auth.waitingForAuth' => '인증 대기 중...\n브라우저에서 로그인하세요.', 'auth.useBrowser' => '브라우저 사용', 'auth.or' => '또는', - 'auth.connectToJellyfin' => 'Jellyfin에 연결', 'auth.useQuickConnect' => 'Quick Connect 사용', 'auth.quickConnectInstructions' => 'Jellyfin에서 Quick Connect를 열고 이 코드를 입력하세요.', 'auth.quickConnectWaiting' => '승인 대기 중…', @@ -2708,9 +2697,9 @@ extension on TranslationsKo { 'videoControls.searchSubtitles' => '자막 검색', 'videoControls.language' => '언어', 'videoControls.noSubtitlesFound' => '자막을 찾을 수 없습니다', + 'videoControls.subtitleDownloaded' => '자막이 다운로드되었습니다', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => '자막이 다운로드되었습니다', 'videoControls.subtitleDownloadedNotApplied' => '자막을 다운로드했지만 선택할 수 없습니다', 'videoControls.subtitleDownloadFailed' => '자막 다운로드에 실패했습니다', 'videoControls.searchLanguages' => '언어 검색...', @@ -2866,8 +2855,6 @@ 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.noContentAvailable' => '사용 가능한 콘텐츠가 없습니다', 'discover.addMediaToLibraries' => '미디어 라이브러리에 미디어를 추가해 주세요', @@ -3222,11 +3209,11 @@ extension on TranslationsKo { 'watchTogether.failedToCreate' => '세션 생성 실패', 'watchTogether.failedToJoin' => '세션 참여 실패', 'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. ISP 차단으로 함께 보기를 사용하지 못할 수 있습니다.', 'watchTogether.reconnectingToHost' => '호스트에 재연결 중...', 'watchTogether.currentPlayback' => '현재 재생', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => '현재 재생 참여', 'watchTogether.joinCurrentPlaybackDescription' => '호스트가 현재 시청 중인 콘텐츠로 이동합니다', 'watchTogether.failedToOpenCurrentPlayback' => '현재 재생을 열 수 없습니다', @@ -3653,12 +3640,9 @@ extension on TranslationsKo { 'services.libraryFilter.modeHintWhitelist' => '아래에 선택한 라이브러리만 동기화합니다.', 'services.libraryFilter.libraries' => '라이브러리', 'services.libraryFilter.noLibraries' => '사용 가능한 라이브러리가 없습니다', - 'addServer.addJellyfinTitle' => 'Jellyfin 서버 추가', 'addServer.serverUrls' => '서버 URL', 'addServer.serverUrlsHelper' => '쉼표로 구분하여 여러 URL을 입력할 수 있습니다.', 'addServer.findServer' => '서버 찾기', - 'addServer.searchingLocalServers' => '로컬 Jellyfin 서버 검색 중...', - 'addServer.localServers' => '로컬 Jellyfin 서버', 'addServer.username' => '사용자 이름', 'addServer.password' => '비밀번호', 'addServer.signIn' => '로그인', @@ -3670,15 +3654,11 @@ extension on TranslationsKo { 'addServer.addPlexTitle' => 'Plex로 로그인', 'addServer.pinExpired' => '로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.', 'addServer.failedToRegisterAccount' => ({required Object error}) => '계정 등록 실패: ${error}', - 'addServer.enterJellyfinUrlError' => 'Jellyfin 서버 URL을 입력하세요', 'addServer.addConnectionTitle' => '연결 추가', 'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name}에 추가', 'addServer.signInWithPlexCard' => 'Plex로 로그인', 'addServer.signInWithPlexCardSubtitle' => '이 기기를 승인합니다. 공유 서버가 추가됩니다.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Plex 계정을 승인합니다. Home 사용자는 프로필이 됩니다.', - 'addServer.connectToJellyfinCard' => 'Jellyfin에 연결', - 'addServer.connectToJellyfinCardSubtitle' => '서버 URL, 사용자 이름, 비밀번호를 입력하세요.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.', 'addServer.borrowFromAnotherProfile' => '다른 프로필에서 빌리기', 'addServer.borrowFromAnotherProfileSubtitle' => '다른 프로필의 연결을 재사용합니다. PIN으로 보호된 프로필에는 PIN이 필요합니다.', _ => null, diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index faa8907e..20847307 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$nb extends Translations$auth$en { @override String get waitingForAuth => 'Venter på autentisering...\nLogg inn fra nettleseren.'; @override String get useBrowser => 'Bruk nettleser'; @override String get or => 'eller'; - @override String get connectToJellyfin => 'Koble til Jellyfin'; @override String get useQuickConnect => 'Bruk Quick Connect'; @override String get quickConnectInstructions => 'Åpne Quick Connect i Jellyfin og skriv inn denne koden.'; @override String get quickConnectWaiting => 'Venter på godkjenning…'; @@ -918,8 +917,6 @@ class _Translations$connections$nb extends Translations$connections$en { @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 @@ -1803,12 +1800,9 @@ class _Translations$addServer$nb extends Translations$addServer$en { final TranslationsNb _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Legg til Jellyfin-server'; @override String get serverUrls => 'Server-URL-er'; @override String get serverUrlsHelper => 'Flere URL-er er tillatt, atskilt med komma.'; @override String get findServer => 'Finn server'; - @override String get searchingLocalServers => 'Søker etter lokale Jellyfin-servere...'; - @override String get localServers => 'Lokale Jellyfin-servere'; @override String get username => 'Brukernavn'; @override String get password => 'Passord'; @override String get signIn => 'Logg inn'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$nb extends Translations$addServer$en { @override String get addPlexTitle => 'Logg inn med Plex'; @override String get pinExpired => 'PIN-koden utløp før innloggingen var fullført. Prøv igjen.'; @override String failedToRegisterAccount({required Object error}) => 'Kunne ikke registrere kontoen: ${error}'; - @override String get enterJellyfinUrlError => 'Oppgi URL-en til Jellyfin-serveren din'; @override String get addConnectionTitle => 'Legg til tilkobling'; @override String addConnectionTitleScoped({required Object name}) => 'Legg til for ${name}'; @override String get signInWithPlexCard => 'Logg inn med Plex'; @override String get signInWithPlexCardSubtitle => 'Autoriser denne enheten. Delte servere legges til.'; @override String get signInWithPlexCardSubtitleScoped => 'Autoriser en Plex-konto. Home-brukere blir profiler.'; - @override String get connectToJellyfinCard => 'Koble til Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Skriv inn server-URL, brukernavn og passord.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Logg på en Jellyfin-server. Knyttes til ${name}.'; @override String get borrowFromAnotherProfile => 'Lån fra en annen profil'; @override String get borrowFromAnotherProfileSubtitle => 'Gjenbruk en annen profils tilkobling. PIN-beskyttede profiler krever PIN.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsNb { 'auth.waitingForAuth' => 'Venter på autentisering...\nLogg inn fra nettleseren.', 'auth.useBrowser' => 'Bruk nettleser', 'auth.or' => 'eller', - 'auth.connectToJellyfin' => 'Koble til Jellyfin', 'auth.useQuickConnect' => 'Bruk Quick Connect', 'auth.quickConnectInstructions' => 'Åpne Quick Connect i Jellyfin og skriv inn denne koden.', 'auth.quickConnectWaiting' => 'Venter på godkjenning…', @@ -2711,9 +2700,9 @@ extension on TranslationsNb { 'videoControls.searchSubtitles' => 'Søk etter undertekster', 'videoControls.language' => 'Språk', 'videoControls.noSubtitlesFound' => 'Ingen undertekster funnet', + 'videoControls.subtitleDownloaded' => 'Undertekst lastet ned', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Undertekst lastet ned', 'videoControls.subtitleDownloadedNotApplied' => 'Underteksten ble lastet ned, men kunne ikke velges', 'videoControls.subtitleDownloadFailed' => 'Kunne ikke laste ned undertekst', 'videoControls.searchLanguages' => 'Søk etter språk...', @@ -2869,8 +2858,6 @@ 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.noContentAvailable' => 'Ikke noe innhold tilgjengelig', 'discover.addMediaToLibraries' => 'Legg til medier i bibliotekene dine', @@ -3225,11 +3212,11 @@ extension on TranslationsNb { 'watchTogether.failedToCreate' => 'Kunne ikke opprette økt', 'watchTogether.failedToJoin' => 'Kunne ikke bli med i økt', 'watchTogether.sessionCodeCopied' => 'Øktkode kopiert til utklippstavle', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Reléserveren kan ikke nås. Blokkering hos internettleverandøren kan hindre Se sammen.', 'watchTogether.reconnectingToHost' => 'Kobler til verten på nytt...', 'watchTogether.currentPlayback' => 'Gjeldende avspilling', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Bli med i gjeldende avspilling', 'watchTogether.joinCurrentPlaybackDescription' => 'Hopp tilbake til det verten ser på nå', 'watchTogether.failedToOpenCurrentPlayback' => 'Kunne ikke åpne gjeldende avspilling', @@ -3656,12 +3643,9 @@ extension on TranslationsNb { 'services.libraryFilter.modeHintWhitelist' => 'Synkroniser kun bibliotekene du markerer nedenfor.', 'services.libraryFilter.libraries' => 'Biblioteker', 'services.libraryFilter.noLibraries' => 'Ingen biblioteker tilgjengelige', - 'addServer.addJellyfinTitle' => 'Legg til Jellyfin-server', 'addServer.serverUrls' => 'Server-URL-er', 'addServer.serverUrlsHelper' => 'Flere URL-er er tillatt, atskilt med komma.', 'addServer.findServer' => 'Finn server', - 'addServer.searchingLocalServers' => 'Søker etter lokale Jellyfin-servere...', - 'addServer.localServers' => 'Lokale Jellyfin-servere', 'addServer.username' => 'Brukernavn', 'addServer.password' => 'Passord', 'addServer.signIn' => 'Logg inn', @@ -3673,15 +3657,11 @@ extension on TranslationsNb { 'addServer.addPlexTitle' => 'Logg inn med Plex', 'addServer.pinExpired' => 'PIN-koden utløp før innloggingen var fullført. Prøv igjen.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Kunne ikke registrere kontoen: ${error}', - 'addServer.enterJellyfinUrlError' => 'Oppgi URL-en til Jellyfin-serveren din', 'addServer.addConnectionTitle' => 'Legg til tilkobling', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Legg til for ${name}', 'addServer.signInWithPlexCard' => 'Logg inn med Plex', 'addServer.signInWithPlexCardSubtitle' => 'Autoriser denne enheten. Delte servere legges til.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Autoriser en Plex-konto. Home-brukere blir profiler.', - 'addServer.connectToJellyfinCard' => 'Koble til Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Skriv inn server-URL, brukernavn og passord.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Logg på en Jellyfin-server. Knyttes til ${name}.', 'addServer.borrowFromAnotherProfile' => 'Lån fra en annen profil', 'addServer.borrowFromAnotherProfileSubtitle' => 'Gjenbruk en annen profils tilkobling. PIN-beskyttede profiler krever PIN.', _ => null, diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 2809c2d7..4df32332 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$nl extends Translations$auth$en { @override String get waitingForAuth => 'Wachten op authenticatie...\nMeld je aan via je browser.'; @override String get useBrowser => 'Gebruik browser'; @override String get or => 'of'; - @override String get connectToJellyfin => 'Verbinden met Jellyfin'; @override String get useQuickConnect => 'Quick Connect gebruiken'; @override String get quickConnectInstructions => 'Open Quick Connect in Jellyfin en voer deze code in.'; @override String get quickConnectWaiting => 'Wachten op goedkeuring…'; @@ -918,8 +917,6 @@ class _Translations$connections$nl extends Translations$connections$en { @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 @@ -1803,12 +1800,9 @@ class _Translations$addServer$nl extends Translations$addServer$en { final TranslationsNl _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Jellyfin-server toevoegen'; @override String get serverUrls => 'Server-URL\'s'; @override String get serverUrlsHelper => 'Meerdere URL\'s toegestaan, gescheiden door komma\'s.'; @override String get findServer => 'Server zoeken'; - @override String get searchingLocalServers => 'Lokale Jellyfin-servers zoeken...'; - @override String get localServers => 'Lokale Jellyfin-servers'; @override String get username => 'Gebruikersnaam'; @override String get password => 'Wachtwoord'; @override String get signIn => 'Inloggen'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$nl extends Translations$addServer$en { @override String get addPlexTitle => 'Inloggen met Plex'; @override String get pinExpired => 'De pincode verliep voordat je kon inloggen. Probeer het opnieuw.'; @override String failedToRegisterAccount({required Object error}) => 'Account registreren mislukt: ${error}'; - @override String get enterJellyfinUrlError => 'Voer de URL van je Jellyfin-server in'; @override String get addConnectionTitle => 'Verbinding toevoegen'; @override String addConnectionTitleScoped({required Object name}) => 'Toevoegen aan ${name}'; @override String get signInWithPlexCard => 'Inloggen met Plex'; @override String get signInWithPlexCardSubtitle => 'Autoriseer dit apparaat. Gedeelde servers worden toegevoegd.'; @override String get signInWithPlexCardSubtitleScoped => 'Autoriseer een Plex-account. Home-gebruikers worden profielen.'; - @override String get connectToJellyfinCard => 'Verbinden met Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Voer je server-URL, gebruikersnaam en wachtwoord in.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.'; @override String get borrowFromAnotherProfile => 'Van een ander profiel lenen'; @override String get borrowFromAnotherProfileSubtitle => 'Hergebruik de verbinding van een ander profiel. Voor profielen met pincodebeveiliging is een pincode vereist.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsNl { 'auth.waitingForAuth' => 'Wachten op authenticatie...\nMeld je aan via je browser.', 'auth.useBrowser' => 'Gebruik browser', 'auth.or' => 'of', - 'auth.connectToJellyfin' => 'Verbinden met Jellyfin', 'auth.useQuickConnect' => 'Quick Connect gebruiken', 'auth.quickConnectInstructions' => 'Open Quick Connect in Jellyfin en voer deze code in.', 'auth.quickConnectWaiting' => 'Wachten op goedkeuring…', @@ -2711,9 +2700,9 @@ extension on TranslationsNl { 'videoControls.searchSubtitles' => 'Ondertitels zoeken', 'videoControls.language' => 'Taal', 'videoControls.noSubtitlesFound' => 'Geen ondertitels gevonden', + 'videoControls.subtitleDownloaded' => 'Ondertitel gedownload', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Ondertitel gedownload', 'videoControls.subtitleDownloadedNotApplied' => 'De ondertiteling is gedownload, maar kon niet worden geselecteerd', 'videoControls.subtitleDownloadFailed' => 'Ondertitel downloaden mislukt', 'videoControls.searchLanguages' => 'Talen zoeken...', @@ -2869,8 +2858,6 @@ 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.noContentAvailable' => 'Geen inhoud beschikbaar', 'discover.addMediaToLibraries' => 'Voeg wat media toe aan je bibliotheken', @@ -3225,11 +3212,11 @@ extension on TranslationsNl { 'watchTogether.failedToCreate' => 'Sessie maken mislukt', 'watchTogether.failedToJoin' => 'Deelnemen aan sessie mislukt', 'watchTogether.sessionCodeCopied' => 'Sessiecode naar het klembord gekopieerd', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'De relayserver is onbereikbaar. Een blokkering door je internetprovider kan Samen kijken verhinderen.', 'watchTogether.reconnectingToHost' => 'Opnieuw verbinden met host...', 'watchTogether.currentPlayback' => 'Wat nu wordt afgespeeld', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Deelnemen aan huidige weergave', 'watchTogether.joinCurrentPlaybackDescription' => 'Ga terug naar wat de host nu kijkt', 'watchTogether.failedToOpenCurrentPlayback' => 'Wat nu wordt afgespeeld kon niet worden geopend', @@ -3656,12 +3643,9 @@ extension on TranslationsNl { 'services.libraryFilter.modeHintWhitelist' => 'Synchroniseer alleen de hieronder aangevinkte bibliotheken.', 'services.libraryFilter.libraries' => 'Bibliotheken', 'services.libraryFilter.noLibraries' => 'Geen bibliotheken beschikbaar', - 'addServer.addJellyfinTitle' => 'Jellyfin-server toevoegen', 'addServer.serverUrls' => 'Server-URL\'s', 'addServer.serverUrlsHelper' => 'Meerdere URL\'s toegestaan, gescheiden door komma\'s.', 'addServer.findServer' => 'Server zoeken', - 'addServer.searchingLocalServers' => 'Lokale Jellyfin-servers zoeken...', - 'addServer.localServers' => 'Lokale Jellyfin-servers', 'addServer.username' => 'Gebruikersnaam', 'addServer.password' => 'Wachtwoord', 'addServer.signIn' => 'Inloggen', @@ -3673,15 +3657,11 @@ extension on TranslationsNl { 'addServer.addPlexTitle' => 'Inloggen met Plex', 'addServer.pinExpired' => 'De pincode verliep voordat je kon inloggen. Probeer het opnieuw.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Account registreren mislukt: ${error}', - 'addServer.enterJellyfinUrlError' => 'Voer de URL van je Jellyfin-server in', 'addServer.addConnectionTitle' => 'Verbinding toevoegen', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Toevoegen aan ${name}', 'addServer.signInWithPlexCard' => 'Inloggen met Plex', 'addServer.signInWithPlexCardSubtitle' => 'Autoriseer dit apparaat. Gedeelde servers worden toegevoegd.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Autoriseer een Plex-account. Home-gebruikers worden profielen.', - 'addServer.connectToJellyfinCard' => 'Verbinden met Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Voer je server-URL, gebruikersnaam en wachtwoord in.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.', 'addServer.borrowFromAnotherProfile' => 'Van een ander profiel lenen', 'addServer.borrowFromAnotherProfileSubtitle' => 'Hergebruik de verbinding van een ander profiel. Voor profielen met pincodebeveiliging is een pincode vereist.', _ => null, diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index a3768ca9..4c65cc2d 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$pl extends Translations$auth$en { @override String get waitingForAuth => 'Oczekiwanie na uwierzytelnienie...\nZaloguj się w przeglądarce.'; @override String get useBrowser => 'Użyj przeglądarki'; @override String get or => 'lub'; - @override String get connectToJellyfin => 'Połącz z Jellyfin'; @override String get useQuickConnect => 'Użyj Quick Connect'; @override String get quickConnectInstructions => 'Otwórz Quick Connect w Jellyfin i wpisz ten kod.'; @override String get quickConnectWaiting => 'Oczekiwanie na zatwierdzenie…'; @@ -920,8 +919,6 @@ class _Translations$connections$pl extends Translations$connections$en { @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 @@ -1809,12 +1806,9 @@ class _Translations$addServer$pl extends Translations$addServer$en { final TranslationsPl _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Dodaj serwer Jellyfin'; @override String get serverUrls => 'Adresy URL serwera'; @override String get serverUrlsHelper => 'Można podać wiele adresów URL rozdzielonych przecinkami.'; @override String get findServer => 'Znajdź serwer'; - @override String get searchingLocalServers => 'Szukanie lokalnych serwerów Jellyfin...'; - @override String get localServers => 'Lokalne serwery Jellyfin'; @override String get username => 'Nazwa użytkownika'; @override String get password => 'Hasło'; @override String get signIn => 'Zaloguj się'; @@ -1826,15 +1820,11 @@ class _Translations$addServer$pl extends Translations$addServer$en { @override String get addPlexTitle => 'Zaloguj się przez Plex'; @override String get pinExpired => 'PIN wygasł przed zalogowaniem. Spróbuj ponownie.'; @override String failedToRegisterAccount({required Object error}) => 'Nie udało się zarejestrować konta: ${error}'; - @override String get enterJellyfinUrlError => 'Podaj URL serwera Jellyfin'; @override String get addConnectionTitle => 'Dodaj połączenie'; @override String addConnectionTitleScoped({required Object name}) => 'Dodaj do ${name}'; @override String get signInWithPlexCard => 'Zaloguj się przez Plex'; @override String get signInWithPlexCardSubtitle => 'Autoryzuj to urządzenie. Serwery udostępnione zostaną dodane.'; @override String get signInWithPlexCardSubtitleScoped => 'Autoryzuj konto Plex. Użytkownicy Home staną się profilami.'; - @override String get connectToJellyfinCard => 'Połącz z Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Wpisz URL serwera, nazwę użytkownika i hasło.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Zaloguj się do serwera Jellyfin. Powiązane z ${name}.'; @override String get borrowFromAnotherProfile => 'Pożycz z innego profilu'; @override String get borrowFromAnotherProfileSubtitle => 'Użyj połączenia innego profilu. Profile chronione PIN-em wymagają podania PIN-u.'; } @@ -2214,7 +2204,6 @@ extension on TranslationsPl { 'auth.waitingForAuth' => 'Oczekiwanie na uwierzytelnienie...\nZaloguj się w przeglądarce.', 'auth.useBrowser' => 'Użyj przeglądarki', 'auth.or' => 'lub', - 'auth.connectToJellyfin' => 'Połącz z Jellyfin', 'auth.useQuickConnect' => 'Użyj Quick Connect', 'auth.quickConnectInstructions' => 'Otwórz Quick Connect w Jellyfin i wpisz ten kod.', 'auth.quickConnectWaiting' => 'Oczekiwanie na zatwierdzenie…', @@ -2717,9 +2706,9 @@ extension on TranslationsPl { 'videoControls.searchSubtitles' => 'Szukaj napisów', 'videoControls.language' => 'Język', 'videoControls.noSubtitlesFound' => 'Nie znaleziono napisów', + 'videoControls.subtitleDownloaded' => 'Napisy pobrane', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Napisy pobrane', 'videoControls.subtitleDownloadedNotApplied' => 'Napisy zostały pobrane, ale nie można ich było wybrać', 'videoControls.subtitleDownloadFailed' => 'Nie udało się pobrać napisów', 'videoControls.searchLanguages' => 'Szukaj języków...', @@ -2875,8 +2864,6 @@ 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.noContentAvailable' => 'Brak dostępnych treści', 'discover.addMediaToLibraries' => 'Dodaj multimedia do swoich bibliotek', @@ -3231,11 +3218,11 @@ extension on TranslationsPl { 'watchTogether.failedToCreate' => 'Nie udało się utworzyć sesji', 'watchTogether.failedToJoin' => 'Nie udało się dołączyć do sesji', 'watchTogether.sessionCodeCopied' => 'Kod sesji skopiowany do schowka', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Serwer pośredniczący jest nieosiągalny. Blokada operatora internetowego może uniemożliwiać korzystanie z funkcji „Oglądaj razem”.', 'watchTogether.reconnectingToHost' => 'Ponowne łączenie z gospodarzem...', 'watchTogether.currentPlayback' => 'Bieżące odtwarzanie', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Dołącz do bieżącego odtwarzania', 'watchTogether.joinCurrentPlaybackDescription' => 'Wróć do treści oglądanej obecnie przez gospodarza', 'watchTogether.failedToOpenCurrentPlayback' => 'Nie udało się otworzyć bieżącego odtwarzania', @@ -3662,12 +3649,9 @@ extension on TranslationsPl { 'services.libraryFilter.modeHintWhitelist' => 'Synchronizuj tylko biblioteki zaznaczone poniżej.', 'services.libraryFilter.libraries' => 'Biblioteki', 'services.libraryFilter.noLibraries' => 'Brak dostępnych bibliotek', - 'addServer.addJellyfinTitle' => 'Dodaj serwer Jellyfin', 'addServer.serverUrls' => 'Adresy URL serwera', 'addServer.serverUrlsHelper' => 'Można podać wiele adresów URL rozdzielonych przecinkami.', 'addServer.findServer' => 'Znajdź serwer', - 'addServer.searchingLocalServers' => 'Szukanie lokalnych serwerów Jellyfin...', - 'addServer.localServers' => 'Lokalne serwery Jellyfin', 'addServer.username' => 'Nazwa użytkownika', 'addServer.password' => 'Hasło', 'addServer.signIn' => 'Zaloguj się', @@ -3679,15 +3663,11 @@ extension on TranslationsPl { 'addServer.addPlexTitle' => 'Zaloguj się przez Plex', 'addServer.pinExpired' => 'PIN wygasł przed zalogowaniem. Spróbuj ponownie.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Nie udało się zarejestrować konta: ${error}', - 'addServer.enterJellyfinUrlError' => 'Podaj URL serwera Jellyfin', 'addServer.addConnectionTitle' => 'Dodaj połączenie', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Dodaj do ${name}', 'addServer.signInWithPlexCard' => 'Zaloguj się przez Plex', 'addServer.signInWithPlexCardSubtitle' => 'Autoryzuj to urządzenie. Serwery udostępnione zostaną dodane.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Autoryzuj konto Plex. Użytkownicy Home staną się profilami.', - 'addServer.connectToJellyfinCard' => 'Połącz z Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Wpisz URL serwera, nazwę użytkownika i hasło.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Zaloguj się do serwera Jellyfin. Powiązane z ${name}.', 'addServer.borrowFromAnotherProfile' => 'Pożycz z innego profilu', 'addServer.borrowFromAnotherProfileSubtitle' => 'Użyj połączenia innego profilu. Profile chronione PIN-em wymagają podania PIN-u.', _ => null, diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 19cca20c..99042fc5 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$pt extends Translations$auth$en { @override String get waitingForAuth => 'Aguardando autenticação...\nEntre pelo navegador.'; @override String get useBrowser => 'Usar navegador'; @override String get or => 'ou'; - @override String get connectToJellyfin => 'Conectar ao Jellyfin'; @override String get useQuickConnect => 'Usar Quick Connect'; @override String get quickConnectInstructions => 'Abra o Quick Connect no Jellyfin e insira este código.'; @override String get quickConnectWaiting => 'Aguardando aprovação…'; @@ -918,8 +917,6 @@ class _Translations$connections$pt extends Translations$connections$en { @override String sessionExpiredOne({required Object name}) => 'Sessão de ${name} expirada'; @override String sessionExpiredMany({required Object count}) => 'Sessões expiradas em ${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 @@ -1803,12 +1800,9 @@ class _Translations$addServer$pt extends Translations$addServer$en { final TranslationsPt _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Adicionar servidor Jellyfin'; @override String get serverUrls => 'URLs do servidor'; @override String get serverUrlsHelper => 'Várias URLs são permitidas, separadas por vírgulas.'; @override String get findServer => 'Encontrar servidor'; - @override String get searchingLocalServers => 'Procurando servidores Jellyfin locais...'; - @override String get localServers => 'Servidores Jellyfin locais'; @override String get username => 'Usuário'; @override String get password => 'Senha'; @override String get signIn => 'Entrar'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$pt extends Translations$addServer$en { @override String get addPlexTitle => 'Entrar com Plex'; @override String get pinExpired => 'O PIN expirou antes de entrar. Tente novamente.'; @override String failedToRegisterAccount({required Object error}) => 'Falha ao registrar a conta: ${error}'; - @override String get enterJellyfinUrlError => 'Insira a URL do seu servidor Jellyfin'; @override String get addConnectionTitle => 'Adicionar conexão'; @override String addConnectionTitleScoped({required Object name}) => 'Adicionar a ${name}'; @override String get signInWithPlexCard => 'Entrar com Plex'; @override String get signInWithPlexCardSubtitle => 'Autorize este dispositivo. Servidores compartilhados são adicionados.'; @override String get signInWithPlexCardSubtitleScoped => 'Autorize uma conta Plex. Os usuários do Plex Home se tornam perfis.'; - @override String get connectToJellyfinCard => 'Conectar ao Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Insira URL do servidor, usuário e senha.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Entre em um servidor Jellyfin. A conexão será vinculada a ${name}.'; @override String get borrowFromAnotherProfile => 'Pegar emprestado de outro perfil'; @override String get borrowFromAnotherProfileSubtitle => 'Reutilize a conexão de outro perfil. Perfis protegidos por PIN exigem PIN.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsPt { 'auth.waitingForAuth' => 'Aguardando autenticação...\nEntre pelo navegador.', 'auth.useBrowser' => 'Usar navegador', 'auth.or' => 'ou', - 'auth.connectToJellyfin' => 'Conectar ao Jellyfin', 'auth.useQuickConnect' => 'Usar Quick Connect', 'auth.quickConnectInstructions' => 'Abra o Quick Connect no Jellyfin e insira este código.', 'auth.quickConnectWaiting' => 'Aguardando aprovação…', @@ -2711,9 +2700,9 @@ extension on TranslationsPt { 'videoControls.searchSubtitles' => 'Pesquisar legendas', 'videoControls.language' => 'Idioma', 'videoControls.noSubtitlesFound' => 'Nenhuma legenda encontrada', + 'videoControls.subtitleDownloaded' => 'Legenda baixada', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Legenda baixada', 'videoControls.subtitleDownloadedNotApplied' => 'A legenda foi baixada, mas não foi possível selecioná-la', 'videoControls.subtitleDownloadFailed' => 'Falha ao baixar legenda', 'videoControls.searchLanguages' => 'Pesquisar idiomas...', @@ -2869,8 +2858,6 @@ extension on TranslationsPt { 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessão de ${name} expirada', 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessões expiradas em ${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.noContentAvailable' => 'Nenhum conteúdo disponível', 'discover.addMediaToLibraries' => 'Adicione mídias às suas bibliotecas', @@ -3225,11 +3212,11 @@ extension on TranslationsPt { 'watchTogether.failedToCreate' => 'Falha ao criar sessão', 'watchTogether.failedToJoin' => 'Falha ao entrar na sessão', 'watchTogether.sessionCodeCopied' => 'Código da sessão copiado para a área de transferência', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Servidor de retransmissão inacessível. O bloqueio pelo provedor de internet pode impedir o uso do Assistir Juntos.', 'watchTogether.reconnectingToHost' => 'Reconectando ao anfitrião...', 'watchTogether.currentPlayback' => 'Reprodução atual', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Entrar na reprodução atual', 'watchTogether.joinCurrentPlaybackDescription' => 'Voltar ao conteúdo que o anfitrião está assistindo agora', 'watchTogether.failedToOpenCurrentPlayback' => 'Falha ao abrir a reprodução atual', @@ -3656,12 +3643,9 @@ extension on TranslationsPt { 'services.libraryFilter.modeHintWhitelist' => 'Sincronizar apenas as bibliotecas marcadas abaixo.', 'services.libraryFilter.libraries' => 'Bibliotecas', 'services.libraryFilter.noLibraries' => 'Nenhuma biblioteca disponível', - 'addServer.addJellyfinTitle' => 'Adicionar servidor Jellyfin', 'addServer.serverUrls' => 'URLs do servidor', 'addServer.serverUrlsHelper' => 'Várias URLs são permitidas, separadas por vírgulas.', 'addServer.findServer' => 'Encontrar servidor', - 'addServer.searchingLocalServers' => 'Procurando servidores Jellyfin locais...', - 'addServer.localServers' => 'Servidores Jellyfin locais', 'addServer.username' => 'Usuário', 'addServer.password' => 'Senha', 'addServer.signIn' => 'Entrar', @@ -3673,15 +3657,11 @@ extension on TranslationsPt { 'addServer.addPlexTitle' => 'Entrar com Plex', 'addServer.pinExpired' => 'O PIN expirou antes de entrar. Tente novamente.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Falha ao registrar a conta: ${error}', - 'addServer.enterJellyfinUrlError' => 'Insira a URL do seu servidor Jellyfin', 'addServer.addConnectionTitle' => 'Adicionar conexão', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Adicionar a ${name}', 'addServer.signInWithPlexCard' => 'Entrar com Plex', 'addServer.signInWithPlexCardSubtitle' => 'Autorize este dispositivo. Servidores compartilhados são adicionados.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Autorize uma conta Plex. Os usuários do Plex Home se tornam perfis.', - 'addServer.connectToJellyfinCard' => 'Conectar ao Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Insira URL do servidor, usuário e senha.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Entre em um servidor Jellyfin. A conexão será vinculada a ${name}.', 'addServer.borrowFromAnotherProfile' => 'Pegar emprestado de outro perfil', 'addServer.borrowFromAnotherProfileSubtitle' => 'Reutilize a conexão de outro perfil. Perfis protegidos por PIN exigem PIN.', _ => null, diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index f3975558..e7207693 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$ru extends Translations$auth$en { @override String get waitingForAuth => 'Ожидание аутентификации...\nВыполните вход в браузере.'; @override String get useBrowser => 'Использовать браузер'; @override String get or => 'или'; - @override String get connectToJellyfin => 'Подключиться к Jellyfin'; @override String get useQuickConnect => 'Использовать Quick Connect'; @override String get quickConnectInstructions => 'Откройте Quick Connect в Jellyfin и введите этот код.'; @override String get quickConnectWaiting => 'Ожидание подтверждения…'; @@ -920,8 +919,6 @@ class _Translations$connections$ru extends Translations$connections$en { @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 @@ -1809,12 +1806,9 @@ class _Translations$addServer$ru extends Translations$addServer$en { final TranslationsRu _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Добавить сервер Jellyfin'; @override String get serverUrls => 'URL-адреса сервера'; @override String get serverUrlsHelper => 'Можно указать несколько URL через запятую.'; @override String get findServer => 'Найти сервер'; - @override String get searchingLocalServers => 'Поиск локальных серверов Jellyfin...'; - @override String get localServers => 'Локальные серверы Jellyfin'; @override String get username => 'Имя пользователя'; @override String get password => 'Пароль'; @override String get signIn => 'Войти'; @@ -1826,15 +1820,11 @@ class _Translations$addServer$ru extends Translations$addServer$en { @override String get addPlexTitle => 'Войти через Plex'; @override String get pinExpired => 'Срок действия PIN истёк до входа. Попробуйте снова.'; @override String failedToRegisterAccount({required Object error}) => 'Не удалось зарегистрировать учётную запись: ${error}'; - @override String get enterJellyfinUrlError => 'Введите URL вашего сервера Jellyfin'; @override String get addConnectionTitle => 'Добавить подключение'; @override String addConnectionTitleScoped({required Object name}) => 'Добавить в ${name}'; @override String get signInWithPlexCard => 'Войти через Plex'; @override String get signInWithPlexCardSubtitle => 'Авторизуйте это устройство. Общие серверы будут добавлены.'; @override String get signInWithPlexCardSubtitleScoped => 'Авторизуйте аккаунт Plex. Пользователи Home станут профилями.'; - @override String get connectToJellyfinCard => 'Подключиться к Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Введите URL сервера, имя пользователя и пароль.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Войдите на сервер Jellyfin. Привязывается к ${name}.'; @override String get borrowFromAnotherProfile => 'Использовать подключение другого профиля'; @override String get borrowFromAnotherProfileSubtitle => 'Повторно используйте подключение другого профиля. Для защищённых профилей потребуется PIN.'; } @@ -2214,7 +2204,6 @@ extension on TranslationsRu { 'auth.waitingForAuth' => 'Ожидание аутентификации...\nВыполните вход в браузере.', 'auth.useBrowser' => 'Использовать браузер', 'auth.or' => 'или', - 'auth.connectToJellyfin' => 'Подключиться к Jellyfin', 'auth.useQuickConnect' => 'Использовать Quick Connect', 'auth.quickConnectInstructions' => 'Откройте Quick Connect в Jellyfin и введите этот код.', 'auth.quickConnectWaiting' => 'Ожидание подтверждения…', @@ -2717,9 +2706,9 @@ extension on TranslationsRu { 'videoControls.searchSubtitles' => 'Поиск субтитров', 'videoControls.language' => 'Язык', 'videoControls.noSubtitlesFound' => 'Субтитры не найдены', + 'videoControls.subtitleDownloaded' => 'Субтитры загружены', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Субтитры загружены', 'videoControls.subtitleDownloadedNotApplied' => 'Субтитры загружены, но их не удалось выбрать', 'videoControls.subtitleDownloadFailed' => 'Не удалось загрузить субтитры', 'videoControls.searchLanguages' => 'Поиск языков...', @@ -2875,8 +2864,6 @@ 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.noContentAvailable' => 'Контент недоступен', 'discover.addMediaToLibraries' => 'Добавьте медиафайлы в ваши библиотеки', @@ -3231,11 +3218,11 @@ extension on TranslationsRu { 'watchTogether.failedToCreate' => 'Не удалось создать сессию', 'watchTogether.failedToJoin' => 'Не удалось присоединиться к сессии', 'watchTogether.sessionCodeCopied' => 'Код сессии скопирован в буфер обмена', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Сервер ретрансляции недоступен. Блокировка интернет-провайдером может помешать совместному просмотру.', 'watchTogether.reconnectingToHost' => 'Повторное подключение к организатору...', 'watchTogether.currentPlayback' => 'Текущее воспроизведение', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Присоединиться к текущему воспроизведению', 'watchTogether.joinCurrentPlaybackDescription' => 'Вернуться к материалу, который сейчас смотрит организатор', 'watchTogether.failedToOpenCurrentPlayback' => 'Не удалось открыть текущее воспроизведение', @@ -3662,12 +3649,9 @@ extension on TranslationsRu { 'services.libraryFilter.modeHintWhitelist' => 'Синхронизировать только библиотеки, отмеченные ниже.', 'services.libraryFilter.libraries' => 'Библиотеки', 'services.libraryFilter.noLibraries' => 'Библиотеки недоступны', - 'addServer.addJellyfinTitle' => 'Добавить сервер Jellyfin', 'addServer.serverUrls' => 'URL-адреса сервера', 'addServer.serverUrlsHelper' => 'Можно указать несколько URL через запятую.', 'addServer.findServer' => 'Найти сервер', - 'addServer.searchingLocalServers' => 'Поиск локальных серверов Jellyfin...', - 'addServer.localServers' => 'Локальные серверы Jellyfin', 'addServer.username' => 'Имя пользователя', 'addServer.password' => 'Пароль', 'addServer.signIn' => 'Войти', @@ -3679,15 +3663,11 @@ extension on TranslationsRu { 'addServer.addPlexTitle' => 'Войти через Plex', 'addServer.pinExpired' => 'Срок действия PIN истёк до входа. Попробуйте снова.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Не удалось зарегистрировать учётную запись: ${error}', - 'addServer.enterJellyfinUrlError' => 'Введите URL вашего сервера Jellyfin', 'addServer.addConnectionTitle' => 'Добавить подключение', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Добавить в ${name}', 'addServer.signInWithPlexCard' => 'Войти через Plex', 'addServer.signInWithPlexCardSubtitle' => 'Авторизуйте это устройство. Общие серверы будут добавлены.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Авторизуйте аккаунт Plex. Пользователи Home станут профилями.', - 'addServer.connectToJellyfinCard' => 'Подключиться к Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Введите URL сервера, имя пользователя и пароль.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Войдите на сервер Jellyfin. Привязывается к ${name}.', 'addServer.borrowFromAnotherProfile' => 'Использовать подключение другого профиля', 'addServer.borrowFromAnotherProfileSubtitle' => 'Повторно используйте подключение другого профиля. Для защищённых профилей потребуется PIN.', _ => null, diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 712e63c9..934c11a5 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$sv extends Translations$auth$en { @override String get waitingForAuth => 'Väntar på autentisering...\nLogga in från din webbläsare.'; @override String get useBrowser => 'Använd webbläsare'; @override String get or => 'eller'; - @override String get connectToJellyfin => 'Anslut till Jellyfin'; @override String get useQuickConnect => 'Använd Quick Connect'; @override String get quickConnectInstructions => 'Öppna Quick Connect i Jellyfin och ange den här koden.'; @override String get quickConnectWaiting => 'Väntar på godkännande…'; @@ -918,8 +917,6 @@ class _Translations$connections$sv extends Translations$connections$en { @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 som har lägst latens.'; } // Path: discover @@ -1803,12 +1800,9 @@ class _Translations$addServer$sv extends Translations$addServer$en { final TranslationsSv _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Lägg till Jellyfin-server'; @override String get serverUrls => 'Server-URL:er'; @override String get serverUrlsHelper => 'Du kan ange flera URL:er avgränsade med kommatecken.'; @override String get findServer => 'Hitta server'; - @override String get searchingLocalServers => 'Söker efter lokala Jellyfin-servrar...'; - @override String get localServers => 'Lokala Jellyfin-servrar'; @override String get username => 'Användarnamn'; @override String get password => 'Lösenord'; @override String get signIn => 'Logga in'; @@ -1820,15 +1814,11 @@ class _Translations$addServer$sv extends Translations$addServer$en { @override String get addPlexTitle => 'Logga in med Plex'; @override String get pinExpired => 'PIN-koden gick ut innan inloggning. Försök igen.'; @override String failedToRegisterAccount({required Object error}) => 'Kunde inte registrera kontot: ${error}'; - @override String get enterJellyfinUrlError => 'Ange URL till din Jellyfin-server'; @override String get addConnectionTitle => 'Lägg till anslutning'; @override String addConnectionTitleScoped({required Object name}) => 'Lägg till i ${name}'; @override String get signInWithPlexCard => 'Logga in med Plex'; @override String get signInWithPlexCardSubtitle => 'Auktorisera den här enheten. Delade servrar läggs till.'; @override String get signInWithPlexCardSubtitleScoped => 'Auktorisera ett Plex-konto. Home-användare blir profiler.'; - @override String get connectToJellyfinCard => 'Anslut till Jellyfin'; - @override String get connectToJellyfinCardSubtitle => 'Ange server-URL, användarnamn och lösenord.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Logga in på en Jellyfin-server. Kopplas till ${name}.'; @override String get borrowFromAnotherProfile => 'Låna från en annan profil'; @override String get borrowFromAnotherProfileSubtitle => 'Återanvänd en annan profils anslutning. PIN-skyddade profiler kräver en PIN.'; } @@ -2208,7 +2198,6 @@ extension on TranslationsSv { 'auth.waitingForAuth' => 'Väntar på autentisering...\nLogga in från din webbläsare.', 'auth.useBrowser' => 'Använd webbläsare', 'auth.or' => 'eller', - 'auth.connectToJellyfin' => 'Anslut till Jellyfin', 'auth.useQuickConnect' => 'Använd Quick Connect', 'auth.quickConnectInstructions' => 'Öppna Quick Connect i Jellyfin och ange den här koden.', 'auth.quickConnectWaiting' => 'Väntar på godkännande…', @@ -2711,9 +2700,9 @@ extension on TranslationsSv { 'videoControls.searchSubtitles' => 'Sök undertexter', 'videoControls.language' => 'Språk', 'videoControls.noSubtitlesFound' => 'Inga undertexter hittades', + 'videoControls.subtitleDownloaded' => 'Undertexten har laddats ned', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => 'Undertexten har laddats ned', 'videoControls.subtitleDownloadedNotApplied' => 'Undertexten laddades ned men kunde inte väljas', 'videoControls.subtitleDownloadFailed' => 'Det gick inte att ladda ned undertexten', 'videoControls.searchLanguages' => 'Sök språk...', @@ -2869,8 +2858,6 @@ 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 som har lägst latens.', 'discover.title' => 'Upptäck', 'discover.noContentAvailable' => 'Inget innehåll tillgängligt', 'discover.addMediaToLibraries' => 'Lägg till medieinnehåll i dina bibliotek', @@ -3225,11 +3212,11 @@ extension on TranslationsSv { 'watchTogether.failedToCreate' => 'Det gick inte att skapa sessionen', 'watchTogether.failedToJoin' => 'Det gick inte att gå med i sessionen', 'watchTogether.sessionCodeCopied' => 'Sessionskoden har kopierats till urklipp', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => 'Reläservern kan inte nås. Din internetleverantör kan blockera Titta tillsammans.', 'watchTogether.reconnectingToHost' => 'Återansluter till värden...', 'watchTogether.currentPlayback' => 'Aktuell uppspelning', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => 'Gå med i aktuell uppspelning', 'watchTogether.joinCurrentPlaybackDescription' => 'Hoppa tillbaka till det värden tittar på just nu', 'watchTogether.failedToOpenCurrentPlayback' => 'Kunde inte öppna aktuell uppspelning', @@ -3656,12 +3643,9 @@ extension on TranslationsSv { 'services.libraryFilter.modeHintWhitelist' => 'Synkronisera endast de bibliotek som markeras nedan.', 'services.libraryFilter.libraries' => 'Bibliotek', 'services.libraryFilter.noLibraries' => 'Inga bibliotek tillgängliga', - 'addServer.addJellyfinTitle' => 'Lägg till Jellyfin-server', 'addServer.serverUrls' => 'Server-URL:er', 'addServer.serverUrlsHelper' => 'Du kan ange flera URL:er avgränsade med kommatecken.', 'addServer.findServer' => 'Hitta server', - 'addServer.searchingLocalServers' => 'Söker efter lokala Jellyfin-servrar...', - 'addServer.localServers' => 'Lokala Jellyfin-servrar', 'addServer.username' => 'Användarnamn', 'addServer.password' => 'Lösenord', 'addServer.signIn' => 'Logga in', @@ -3673,15 +3657,11 @@ extension on TranslationsSv { 'addServer.addPlexTitle' => 'Logga in med Plex', 'addServer.pinExpired' => 'PIN-koden gick ut innan inloggning. Försök igen.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Kunde inte registrera kontot: ${error}', - 'addServer.enterJellyfinUrlError' => 'Ange URL till din Jellyfin-server', 'addServer.addConnectionTitle' => 'Lägg till anslutning', 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Lägg till i ${name}', 'addServer.signInWithPlexCard' => 'Logga in med Plex', 'addServer.signInWithPlexCardSubtitle' => 'Auktorisera den här enheten. Delade servrar läggs till.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Auktorisera ett Plex-konto. Home-användare blir profiler.', - 'addServer.connectToJellyfinCard' => 'Anslut till Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => 'Ange server-URL, användarnamn och lösenord.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Logga in på en Jellyfin-server. Kopplas till ${name}.', 'addServer.borrowFromAnotherProfile' => 'Låna från en annan profil', 'addServer.borrowFromAnotherProfileSubtitle' => 'Återanvänd en annan profils anslutning. PIN-skyddade profiler kräver en PIN.', _ => null, diff --git a/lib/i18n/strings_tr.g.dart b/lib/i18n/strings_tr.g.dart index 0bf8e92f..260f10f9 100644 --- a/lib/i18n/strings_tr.g.dart +++ b/lib/i18n/strings_tr.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$tr extends Translations$auth$en { @override String get waitingForAuth => 'Doğrulama bekleniyor...\nTarayıcınızdan giriş yapın.'; @override String get useBrowser => 'Tarayıcı kullan'; @override String get or => 'veya'; - @override String get connectToJellyfin => 'Jellyfin\'e Bağlan'; @override String get useQuickConnect => 'Hızlı Bağlantıyı Kullan'; @override String get quickConnectInstructions => 'Jellyfin\'de Hızlı Bağlantı\'yı açın ve bu kodu girin.'; @override String get quickConnectWaiting => 'Onay bekleniyor…'; @@ -922,8 +921,6 @@ class _Translations$connections$tr extends Translations$connections$en { @override String sessionExpiredOne({required Object name}) => '${name} için oturum süresi doldu'; @override String sessionExpiredMany({required Object count}) => '${count} sunucu için oturum süresi doldu'; @override String get signInAgain => 'Tekrar giriş yap'; - @override String get editJellyfinTitle => 'Jellyfin bağlantısını düzenle'; - @override String editJellyfinIntro({required Object serverName}) => '${serverName} için URL\'ler ekleyin veya kaldırın. Plezy, en düşük gecikmeye sahip ulaşılabilir URL\'yi kullanacaktır.'; } // Path: discover @@ -1814,12 +1811,9 @@ class _Translations$addServer$tr extends Translations$addServer$en { final TranslationsTr _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Jellyfin sunucusu ekle'; @override String get serverUrls => 'Sunucu URL\'leri'; @override String get serverUrlsHelper => 'Virgülle ayrılmış birden fazla URL\'ye izin verilir.'; @override String get findServer => 'Sunucu bul'; - @override String get searchingLocalServers => 'Yerel Jellyfin sunucuları aranıyor...'; - @override String get localServers => 'Yerel Jellyfin sunucuları'; @override String get username => 'Kullanıcı adı'; @override String get password => 'Şifre'; @override String get signIn => 'Giriş Yap'; @@ -1831,15 +1825,11 @@ class _Translations$addServer$tr extends Translations$addServer$en { @override String get addPlexTitle => 'Plex ile Giriş Yap'; @override String get pinExpired => 'Giriş yapmadan önce PIN süresi doldu. Lütfen tekrar deneyin.'; @override String failedToRegisterAccount({required Object error}) => 'Hesap kaydı başarısız oldu: ${error}'; - @override String get enterJellyfinUrlError => 'Jellyfin sunucu URL\'nizi girin'; @override String get addConnectionTitle => 'Bağlantı ekle'; @override String addConnectionTitleScoped({required Object name}) => '${name} profiline ekle'; @override String get signInWithPlexCard => 'Plex ile Giriş Yap'; @override String get signInWithPlexCardSubtitle => 'Bu cihazı yetkilendirin. Paylaşılan sunucular eklenir.'; @override String get signInWithPlexCardSubtitleScoped => 'Bir Plex hesabını yetkilendirin. Ev kullanıcıları profile dönüşür.'; - @override String get connectToJellyfinCard => 'Jellyfin\'e Bağlan'; - @override String get connectToJellyfinCardSubtitle => 'Sunucu URL\'nizi, kullanıcı adınızı ve şifrenizi girin.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Bir Jellyfin sunucusuna giriş yapın. ${name} profiline bağlanır.'; @override String get borrowFromAnotherProfile => 'Başka bir profilden ödünç al'; @override String get borrowFromAnotherProfileSubtitle => 'Başka bir profilin bağlantısını yeniden kullanın. PIN korumalı profiller bir PIN gerektirir.'; } @@ -2219,7 +2209,6 @@ extension on TranslationsTr { 'auth.waitingForAuth' => 'Doğrulama bekleniyor...\nTarayıcınızdan giriş yapın.', 'auth.useBrowser' => 'Tarayıcı kullan', 'auth.or' => 'veya', - 'auth.connectToJellyfin' => 'Jellyfin\'e Bağlan', 'auth.useQuickConnect' => 'Hızlı Bağlantıyı Kullan', 'auth.quickConnectInstructions' => 'Jellyfin\'de Hızlı Bağlantı\'yı açın ve bu kodu girin.', 'auth.quickConnectWaiting' => 'Onay bekleniyor…', @@ -2722,9 +2711,9 @@ extension on TranslationsTr { 'videoControls.noChaptersAvailable' => 'Kısım bulunmuyor', 'videoControls.queue' => 'Kuyruk', 'videoControls.noQueueItems' => 'Kuyrukta öge yok', + 'videoControls.searchSubtitles' => 'Altyazı Ara', _ => null, } ?? switch (path) { - 'videoControls.searchSubtitles' => 'Altyazı Ara', 'videoControls.language' => 'Dil', 'videoControls.noSubtitlesFound' => 'Altyazı bulunamadı', 'videoControls.subtitleDownloaded' => 'Altyazı indirildi', @@ -2884,8 +2873,6 @@ extension on TranslationsTr { 'connections.sessionExpiredOne' => ({required Object name}) => '${name} için oturum süresi doldu', 'connections.sessionExpiredMany' => ({required Object count}) => '${count} sunucu için oturum süresi doldu', 'connections.signInAgain' => 'Tekrar giriş yap', - 'connections.editJellyfinTitle' => 'Jellyfin bağlantısını düzenle', - 'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName} için URL\'ler ekleyin veya kaldırın. Plezy, en düşük gecikmeye sahip ulaşılabilir URL\'yi kullanacaktır.', 'discover.title' => 'Keşfet', 'discover.noContentAvailable' => 'İçerik bulunmuyor', 'discover.addMediaToLibraries' => 'Kitaplıklarınıza biraz medya ekleyin', @@ -3236,11 +3223,11 @@ extension on TranslationsTr { 'watchTogether.enterCodeHint' => '5 karakterlik kodu girin', 'watchTogether.pasteFromClipboard' => 'Panodan yapıştır', 'watchTogether.pleaseEnterCode' => 'Lütfen bir oturum kodu girin', - _ => null, - } ?? switch (path) { 'watchTogether.codeMustBe5Chars' => 'Oturum kodu 5 karakter olmalıdır', 'watchTogether.joinInstructions' => 'Katılmak için kurucunun oturum kodunu girin.', 'watchTogether.failedToCreate' => 'Oturum oluşturulamadı', + _ => null, + } ?? switch (path) { 'watchTogether.failedToJoin' => 'Oturuma katılınamadı', 'watchTogether.sessionCodeCopied' => 'Oturum kodu panoya kopyalandı', 'watchTogether.relayUnreachable' => 'Aktarıcı sunucusuna ulaşılamıyor. İSS engellemesi Birlikte İzle\'yi önleyebilir.', @@ -3678,12 +3665,9 @@ extension on TranslationsTr { 'services.libraryFilter.modeHintWhitelist' => 'Yalnızca aşağıda işaretlenen kitaplıkları eşitle.', 'services.libraryFilter.libraries' => 'Kitaplıklar', 'services.libraryFilter.noLibraries' => 'Kitaplık bulunmuyor', - 'addServer.addJellyfinTitle' => 'Jellyfin sunucusu ekle', 'addServer.serverUrls' => 'Sunucu URL\'leri', 'addServer.serverUrlsHelper' => 'Virgülle ayrılmış birden fazla URL\'ye izin verilir.', 'addServer.findServer' => 'Sunucu bul', - 'addServer.searchingLocalServers' => 'Yerel Jellyfin sunucuları aranıyor...', - 'addServer.localServers' => 'Yerel Jellyfin sunucuları', 'addServer.username' => 'Kullanıcı adı', 'addServer.password' => 'Şifre', 'addServer.signIn' => 'Giriş Yap', @@ -3695,15 +3679,11 @@ extension on TranslationsTr { 'addServer.addPlexTitle' => 'Plex ile Giriş Yap', 'addServer.pinExpired' => 'Giriş yapmadan önce PIN süresi doldu. Lütfen tekrar deneyin.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Hesap kaydı başarısız oldu: ${error}', - 'addServer.enterJellyfinUrlError' => 'Jellyfin sunucu URL\'nizi girin', 'addServer.addConnectionTitle' => 'Bağlantı ekle', 'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name} profiline ekle', 'addServer.signInWithPlexCard' => 'Plex ile Giriş Yap', 'addServer.signInWithPlexCardSubtitle' => 'Bu cihazı yetkilendirin. Paylaşılan sunucular eklenir.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Bir Plex hesabını yetkilendirin. Ev kullanıcıları profile dönüşür.', - 'addServer.connectToJellyfinCard' => 'Jellyfin\'e Bağlan', - 'addServer.connectToJellyfinCardSubtitle' => 'Sunucu URL\'nizi, kullanıcı adınızı ve şifrenizi girin.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Bir Jellyfin sunucusuna giriş yapın. ${name} profiline bağlanır.', 'addServer.borrowFromAnotherProfile' => 'Başka bir profilden ödünç al', 'addServer.borrowFromAnotherProfileSubtitle' => 'Başka bir profilin bağlantısını yeniden kullanın. PIN korumalı profiller bir PIN gerektirir.', _ => null, diff --git a/lib/i18n/strings_uz.g.dart b/lib/i18n/strings_uz.g.dart index 66c1cb09..6cc42650 100644 --- a/lib/i18n/strings_uz.g.dart +++ b/lib/i18n/strings_uz.g.dart @@ -115,7 +115,6 @@ class _Translations$auth$uz extends Translations$auth$en { @override String get waitingForAuth => 'Tasdiqlanish kutilmoqda...\nBrauzeringizdan kiring.'; @override String get useBrowser => 'Brauzerdan foydalanish'; @override String get or => 'yoki'; - @override String get connectToJellyfin => 'Jellyfin-ga ulanish'; @override String get useQuickConnect => 'Tezkor ulanishdan foydalanish'; @override String get quickConnectInstructions => 'Jellyfin-da Tezkor ulanishni oching va ushbu kodni kiriting.'; @override String get quickConnectWaiting => 'Tasdiq kutilmoqda…'; @@ -922,8 +921,6 @@ class _Translations$connections$uz extends Translations$connections$en { @override String sessionExpiredOne({required Object name}) => '${name} uchun seans vaqti tugadi'; @override String sessionExpiredMany({required Object count}) => '${count} server uchun seans vaqti tugadi'; @override String get signInAgain => 'Qaytadan kirish'; - @override String get editJellyfinTitle => 'Jellyfin ulanishini tahrirlash'; - @override String editJellyfinIntro({required Object serverName}) => '${serverName} uchun URL manzilini qoʻshing yoki oʻchiring.'; } // Path: discover @@ -1814,12 +1811,9 @@ class _Translations$addServer$uz extends Translations$addServer$en { final TranslationsUz _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => 'Jellyfin serverini qoʻshish'; @override String get serverUrls => 'Server URL-lari'; @override String get serverUrlsHelper => 'Vergul bilan ajratilgan bir nechta URL manziliga ruxsat beriladi.'; @override String get findServer => 'Serverni topish'; - @override String get searchingLocalServers => 'Mahalliy Jellyfin serverlari qidirilmoqda...'; - @override String get localServers => 'Mahalliy Jellyfin serverlari'; @override String get username => 'Foydalanuvchi nomi'; @override String get password => 'Parol'; @override String get signIn => 'Kirish'; @@ -1831,15 +1825,11 @@ class _Translations$addServer$uz extends Translations$addServer$en { @override String get addPlexTitle => 'Plex orqali kirish'; @override String get pinExpired => 'PIN kod vaqti tugadi.'; @override String failedToRegisterAccount({required Object error}) => 'Hisobni roʻyxatdan oʻtkazishda xatolik: ${error}'; - @override String get enterJellyfinUrlError => 'Jellyfin server URL-ini kiriting'; @override String get addConnectionTitle => 'Ulanish qoʻshish'; @override String addConnectionTitleScoped({required Object name}) => '${name} profiliga qoʻshish'; @override String get signInWithPlexCard => 'Plex orqali kirish'; @override String get signInWithPlexCardSubtitle => 'Ushbu qurilmani avtorizatsiya qiling.'; @override String get signInWithPlexCardSubtitleScoped => 'Plex hisobini avtorizatsiya qiling.'; - @override String get connectToJellyfinCard => 'Jellyfin-ga ulanish'; - @override String get connectToJellyfinCardSubtitle => 'Server URL, foydalanuvchi nomi va parolingizni kiriting.'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfin serveriga kiring. ${name} profiliga ulanmoqda.'; @override String get borrowFromAnotherProfile => 'Boshqa profildan olish'; @override String get borrowFromAnotherProfileSubtitle => 'Boshqa profilning ulanishidan qayta foydalaning.'; } @@ -2219,7 +2209,6 @@ extension on TranslationsUz { 'auth.waitingForAuth' => 'Tasdiqlanish kutilmoqda...\nBrauzeringizdan kiring.', 'auth.useBrowser' => 'Brauzerdan foydalanish', 'auth.or' => 'yoki', - 'auth.connectToJellyfin' => 'Jellyfin-ga ulanish', 'auth.useQuickConnect' => 'Tezkor ulanishdan foydalanish', 'auth.quickConnectInstructions' => 'Jellyfin-da Tezkor ulanishni oching va ushbu kodni kiriting.', 'auth.quickConnectWaiting' => 'Tasdiq kutilmoqda…', @@ -2722,9 +2711,9 @@ extension on TranslationsUz { 'videoControls.noChaptersAvailable' => 'Boʻlimlar mavjud emas', 'videoControls.queue' => 'Navbat', 'videoControls.noQueueItems' => 'Navbatda elementlar yoʻq', + 'videoControls.searchSubtitles' => 'Subtitr qidirish', _ => null, } ?? switch (path) { - 'videoControls.searchSubtitles' => 'Subtitr qidirish', 'videoControls.language' => 'Til', 'videoControls.noSubtitlesFound' => 'Subtitr topilmadi', 'videoControls.subtitleDownloaded' => 'Subtitr yuklab olindi', @@ -2884,8 +2873,6 @@ extension on TranslationsUz { 'connections.sessionExpiredOne' => ({required Object name}) => '${name} uchun seans vaqti tugadi', 'connections.sessionExpiredMany' => ({required Object count}) => '${count} server uchun seans vaqti tugadi', 'connections.signInAgain' => 'Qaytadan kirish', - 'connections.editJellyfinTitle' => 'Jellyfin ulanishini tahrirlash', - 'connections.editJellyfinIntro' => ({required Object serverName}) => '${serverName} uchun URL manzilini qoʻshing yoki oʻchiring.', 'discover.title' => 'Kashf qilish', 'discover.noContentAvailable' => 'Kontent mavjud emas', 'discover.addMediaToLibraries' => 'Kutubxonalaringizga media qoʻshing', @@ -3236,11 +3223,11 @@ extension on TranslationsUz { 'watchTogether.enterCodeHint' => '5 xonali kodni kiriting', 'watchTogether.pasteFromClipboard' => 'Xotiradan joylash', 'watchTogether.pleaseEnterCode' => 'Seans kodini kiriting', - _ => null, - } ?? switch (path) { 'watchTogether.codeMustBe5Chars' => 'Seans kodi 5 ta belgidan iborat boʻlishi kerak', 'watchTogether.joinInstructions' => 'Tashkilotchining seans kodini kiriting.', 'watchTogether.failedToCreate' => 'Seansni yaratib boʻlmadi', + _ => null, + } ?? switch (path) { 'watchTogether.failedToJoin' => 'Seansga qoʻshilib boʻlmadi', 'watchTogether.sessionCodeCopied' => 'Seans kodi nusxalandi', 'watchTogether.relayUnreachable' => 'Rele serveriga ulanib boʻlmadi.', @@ -3678,12 +3665,9 @@ extension on TranslationsUz { 'services.libraryFilter.modeHintWhitelist' => 'Faqat quyida tanlangan kutubxonalarni sinxronlash.', 'services.libraryFilter.libraries' => 'Kutubxonalar', 'services.libraryFilter.noLibraries' => 'Kutubxonalar yoʻq', - 'addServer.addJellyfinTitle' => 'Jellyfin serverini qoʻshish', 'addServer.serverUrls' => 'Server URL-lari', 'addServer.serverUrlsHelper' => 'Vergul bilan ajratilgan bir nechta URL manziliga ruxsat beriladi.', 'addServer.findServer' => 'Serverni topish', - 'addServer.searchingLocalServers' => 'Mahalliy Jellyfin serverlari qidirilmoqda...', - 'addServer.localServers' => 'Mahalliy Jellyfin serverlari', 'addServer.username' => 'Foydalanuvchi nomi', 'addServer.password' => 'Parol', 'addServer.signIn' => 'Kirish', @@ -3695,15 +3679,11 @@ extension on TranslationsUz { 'addServer.addPlexTitle' => 'Plex orqali kirish', 'addServer.pinExpired' => 'PIN kod vaqti tugadi.', 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Hisobni roʻyxatdan oʻtkazishda xatolik: ${error}', - 'addServer.enterJellyfinUrlError' => 'Jellyfin server URL-ini kiriting', 'addServer.addConnectionTitle' => 'Ulanish qoʻshish', 'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name} profiliga qoʻshish', 'addServer.signInWithPlexCard' => 'Plex orqali kirish', 'addServer.signInWithPlexCardSubtitle' => 'Ushbu qurilmani avtorizatsiya qiling.', 'addServer.signInWithPlexCardSubtitleScoped' => 'Plex hisobini avtorizatsiya qiling.', - 'addServer.connectToJellyfinCard' => 'Jellyfin-ga ulanish', - 'addServer.connectToJellyfinCardSubtitle' => 'Server URL, foydalanuvchi nomi va parolingizni kiriting.', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfin serveriga kiring. ${name} profiliga ulanmoqda.', 'addServer.borrowFromAnotherProfile' => 'Boshqa profildan olish', 'addServer.borrowFromAnotherProfileSubtitle' => 'Boshqa profilning ulanishidan qayta foydalaning.', _ => null, diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 3db5702b..907da77b 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -115,7 +115,6 @@ class Translations$auth$zh extends Translations$auth$en { @override String get waitingForAuth => '正在等待身份验证…\n请在浏览器中登录。'; @override String get useBrowser => '使用浏览器'; @override String get or => '或'; - @override String get connectToJellyfin => '连接到 Jellyfin'; @override String get useQuickConnect => '使用 Quick Connect'; @override String get quickConnectInstructions => '在 Jellyfin 中打开 Quick Connect 并输入此代码。'; @override String get quickConnectWaiting => '等待批准…'; @@ -917,8 +916,6 @@ class Translations$connections$zh extends Translations$connections$en { @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 会使用可访问且延迟最低的地址。'; } // Path: discover @@ -1800,12 +1797,9 @@ class Translations$addServer$zh extends Translations$addServer$en { final TranslationsZh _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => '添加 Jellyfin 服务器'; @override String get serverUrls => '服务器 URL'; @override String get serverUrlsHelper => '可输入多个 URL,并用逗号分隔。'; @override String get findServer => '查找服务器'; - @override String get searchingLocalServers => '正在查找本地 Jellyfin 服务器…'; - @override String get localServers => '本地 Jellyfin 服务器'; @override String get username => '用户名'; @override String get password => '密码'; @override String get signIn => '登录'; @@ -1817,15 +1811,11 @@ class Translations$addServer$zh extends Translations$addServer$en { @override String get addPlexTitle => '使用 Plex 登录'; @override String get pinExpired => 'PIN 在登录前已过期。请重试。'; @override String failedToRegisterAccount({required Object error}) => '注册账户失败:${error}'; - @override String get enterJellyfinUrlError => '请输入 Jellyfin 服务器 URL'; @override String get addConnectionTitle => '添加连接'; @override String addConnectionTitleScoped({required Object name}) => '添加到 ${name}'; @override String get signInWithPlexCard => '使用 Plex 登录'; @override String get signInWithPlexCardSubtitle => '授权此设备。共享服务器会被添加。'; @override String get signInWithPlexCardSubtitleScoped => '授权一个 Plex 账户。Plex Home 用户将成为 Plezy 用户资料。'; - @override String get connectToJellyfinCard => '连接到 Jellyfin'; - @override String get connectToJellyfinCardSubtitle => '输入服务器 URL、用户名和密码。'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => '登录到 Jellyfin 服务器。绑定到 ${name}。'; @override String get borrowFromAnotherProfile => '使用其他用户资料的连接'; @override String get borrowFromAnotherProfileSubtitle => '复用另一个用户资料的连接。受 PIN 保护的用户资料需要输入 PIN。'; } @@ -2205,7 +2195,6 @@ extension on TranslationsZh { 'auth.waitingForAuth' => '正在等待身份验证…\n请在浏览器中登录。', 'auth.useBrowser' => '使用浏览器', 'auth.or' => '或', - 'auth.connectToJellyfin' => '连接到 Jellyfin', 'auth.useQuickConnect' => '使用 Quick Connect', 'auth.quickConnectInstructions' => '在 Jellyfin 中打开 Quick Connect 并输入此代码。', 'auth.quickConnectWaiting' => '等待批准…', @@ -2708,9 +2697,9 @@ extension on TranslationsZh { 'videoControls.searchSubtitles' => '搜索字幕', 'videoControls.language' => '语言', 'videoControls.noSubtitlesFound' => '未找到字幕', + 'videoControls.subtitleDownloaded' => '字幕已下载', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => '字幕已下载', 'videoControls.subtitleDownloadedNotApplied' => '字幕已下载,但无法选择', 'videoControls.subtitleDownloadFailed' => '字幕下载失败', 'videoControls.searchLanguages' => '搜索语言…', @@ -2866,8 +2855,6 @@ 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 会使用可访问且延迟最低的地址。', 'discover.title' => '发现', 'discover.noContentAvailable' => '没有可用内容', 'discover.addMediaToLibraries' => '请向你的媒体库添加一些媒体', @@ -3222,11 +3209,11 @@ extension on TranslationsZh { 'watchTogether.failedToCreate' => '创建会话失败', 'watchTogether.failedToJoin' => '加入会话失败', 'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => '无法访问中继服务器。网络运营商的屏蔽可能导致“一起看”不可用。', 'watchTogether.reconnectingToHost' => '正在重新连接到主持人…', 'watchTogether.currentPlayback' => '当前播放', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => '加入当前播放', 'watchTogether.joinCurrentPlaybackDescription' => '加入主持人当前正在观看的内容', 'watchTogether.failedToOpenCurrentPlayback' => '无法打开当前播放', @@ -3653,12 +3640,9 @@ extension on TranslationsZh { 'services.libraryFilter.modeHintWhitelist' => '仅同步下方勾选的媒体库。', 'services.libraryFilter.libraries' => '媒体库', 'services.libraryFilter.noLibraries' => '没有可用的媒体库', - 'addServer.addJellyfinTitle' => '添加 Jellyfin 服务器', 'addServer.serverUrls' => '服务器 URL', 'addServer.serverUrlsHelper' => '可输入多个 URL,并用逗号分隔。', 'addServer.findServer' => '查找服务器', - 'addServer.searchingLocalServers' => '正在查找本地 Jellyfin 服务器…', - 'addServer.localServers' => '本地 Jellyfin 服务器', 'addServer.username' => '用户名', 'addServer.password' => '密码', 'addServer.signIn' => '登录', @@ -3670,15 +3654,11 @@ extension on TranslationsZh { 'addServer.addPlexTitle' => '使用 Plex 登录', 'addServer.pinExpired' => 'PIN 在登录前已过期。请重试。', 'addServer.failedToRegisterAccount' => ({required Object error}) => '注册账户失败:${error}', - 'addServer.enterJellyfinUrlError' => '请输入 Jellyfin 服务器 URL', 'addServer.addConnectionTitle' => '添加连接', 'addServer.addConnectionTitleScoped' => ({required Object name}) => '添加到 ${name}', 'addServer.signInWithPlexCard' => '使用 Plex 登录', 'addServer.signInWithPlexCardSubtitle' => '授权此设备。共享服务器会被添加。', 'addServer.signInWithPlexCardSubtitleScoped' => '授权一个 Plex 账户。Plex Home 用户将成为 Plezy 用户资料。', - 'addServer.connectToJellyfinCard' => '连接到 Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => '输入服务器 URL、用户名和密码。', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => '登录到 Jellyfin 服务器。绑定到 ${name}。', 'addServer.borrowFromAnotherProfile' => '使用其他用户资料的连接', 'addServer.borrowFromAnotherProfileSubtitle' => '复用另一个用户资料的连接。受 PIN 保护的用户资料需要输入 PIN。', _ => null, diff --git a/lib/i18n/strings_zh_Hant.g.dart b/lib/i18n/strings_zh_Hant.g.dart index 17b8735c..9c695774 100644 --- a/lib/i18n/strings_zh_Hant.g.dart +++ b/lib/i18n/strings_zh_Hant.g.dart @@ -116,7 +116,6 @@ class _Translations$auth$zh_Hant extends Translations$auth$zh { @override String get waitingForAuth => '正在等待驗證…\n請在瀏覽器中登入。'; @override String get useBrowser => '使用瀏覽器'; @override String get or => '或'; - @override String get connectToJellyfin => '連線至 Jellyfin'; @override String get useQuickConnect => '使用快速連線(Quick Connect)'; @override String get quickConnectInstructions => '在 Jellyfin 中開啟快速連線並輸入此代碼。'; @override String get quickConnectWaiting => '等待核准…'; @@ -918,8 +917,6 @@ class _Translations$connections$zh_Hant extends Translations$connections$zh { @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 會自動選擇可連線且延遲最低的網址。'; } // Path: discover @@ -1801,12 +1798,9 @@ class _Translations$addServer$zh_Hant extends Translations$addServer$zh { final TranslationsZhHant _root; // ignore: unused_field // Translations - @override String get addJellyfinTitle => '新增 Jellyfin 伺服器'; @override String get serverUrls => '伺服器 URL'; @override String get serverUrlsHelper => '可輸入多個連線網址,以逗號區隔。'; @override String get findServer => '尋找伺服器'; - @override String get searchingLocalServers => '正在尋找本地 Jellyfin 伺服器…'; - @override String get localServers => '本地 Jellyfin 伺服器'; @override String get username => '使用者名稱'; @override String get password => '密碼'; @override String get signIn => '登入'; @@ -1818,15 +1812,11 @@ class _Translations$addServer$zh_Hant extends Translations$addServer$zh { @override String get addPlexTitle => '使用 Plex 登入'; @override String get pinExpired => 'PIN 碼在登入前已過期。請重試。'; @override String failedToRegisterAccount({required Object error}) => '註冊帳戶失敗:${error}'; - @override String get enterJellyfinUrlError => '請輸入您的 Jellyfin 伺服器 URL'; @override String get addConnectionTitle => '新增連線'; @override String addConnectionTitleScoped({required Object name}) => '新增連線至 ${name}'; @override String get signInWithPlexCard => '使用 Plex 登入'; @override String get signInWithPlexCardSubtitle => '授權此裝置。將會新增共享的伺服器連線。'; @override String get signInWithPlexCardSubtitleScoped => '授權 Plex 帳戶。Home 使用者會建立為個別的使用者設定檔。'; - @override String get connectToJellyfinCard => '連線至 Jellyfin'; - @override String get connectToJellyfinCardSubtitle => '輸入伺服器 URL、使用者名稱與密碼。'; - @override String connectToJellyfinCardSubtitleScoped({required Object name}) => '登入 Jellyfin 伺服器,並綁定至 ${name} 使用者設定檔。'; @override String get borrowFromAnotherProfile => '從另一個使用者設定檔共用'; @override String get borrowFromAnotherProfileSubtitle => '重複使用另一個使用者設定檔的連線資訊。受 PIN 碼保護的使用者設定檔需輸入 PIN 碼。'; } @@ -2206,7 +2196,6 @@ extension on TranslationsZhHant { 'auth.waitingForAuth' => '正在等待驗證…\n請在瀏覽器中登入。', 'auth.useBrowser' => '使用瀏覽器', 'auth.or' => '或', - 'auth.connectToJellyfin' => '連線至 Jellyfin', 'auth.useQuickConnect' => '使用快速連線(Quick Connect)', 'auth.quickConnectInstructions' => '在 Jellyfin 中開啟快速連線並輸入此代碼。', 'auth.quickConnectWaiting' => '等待核准…', @@ -2709,9 +2698,9 @@ extension on TranslationsZhHant { 'videoControls.searchSubtitles' => '搜尋字幕', 'videoControls.language' => '語言', 'videoControls.noSubtitlesFound' => '找不到字幕', + 'videoControls.subtitleDownloaded' => '字幕下載成功', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloaded' => '字幕下載成功', 'videoControls.subtitleDownloadedNotApplied' => '字幕已下載,但無法套用', 'videoControls.subtitleDownloadFailed' => '字幕下載失敗', 'videoControls.searchLanguages' => '搜尋語言…', @@ -2867,8 +2856,6 @@ extension on TranslationsZhHant { '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 會自動選擇可連線且延遲最低的網址。', 'discover.title' => '發現', 'discover.noContentAvailable' => '沒有可用內容', 'discover.addMediaToLibraries' => '請向您的媒體庫新增一些媒體內容', @@ -3223,11 +3210,11 @@ extension on TranslationsZhHant { 'watchTogether.failedToCreate' => '建立工作階段失敗', 'watchTogether.failedToJoin' => '加入工作階段失敗', 'watchTogether.sessionCodeCopied' => '工作階段代碼已複製到剪貼簿', - _ => null, - } ?? switch (path) { 'watchTogether.relayUnreachable' => '無法連線至中繼伺服器。ISP 封鎖可能會導致「一起看」無法使用。', 'watchTogether.reconnectingToHost' => '正在重新連線至主持人…', 'watchTogether.currentPlayback' => '目前播放內容', + _ => null, + } ?? switch (path) { 'watchTogether.joinCurrentPlayback' => '加入目前播放點', 'watchTogether.joinCurrentPlaybackDescription' => '同步至主持人目前的觀看進度', 'watchTogether.failedToOpenCurrentPlayback' => '無法開啟目前播放點', @@ -3654,12 +3641,9 @@ extension on TranslationsZhHant { 'services.libraryFilter.modeHintWhitelist' => '僅同步下方已勾選的媒體庫。', 'services.libraryFilter.libraries' => '媒體庫', 'services.libraryFilter.noLibraries' => '沒有可用的媒體庫', - 'addServer.addJellyfinTitle' => '新增 Jellyfin 伺服器', 'addServer.serverUrls' => '伺服器 URL', 'addServer.serverUrlsHelper' => '可輸入多個連線網址,以逗號區隔。', 'addServer.findServer' => '尋找伺服器', - 'addServer.searchingLocalServers' => '正在尋找本地 Jellyfin 伺服器…', - 'addServer.localServers' => '本地 Jellyfin 伺服器', 'addServer.username' => '使用者名稱', 'addServer.password' => '密碼', 'addServer.signIn' => '登入', @@ -3671,15 +3655,11 @@ extension on TranslationsZhHant { 'addServer.addPlexTitle' => '使用 Plex 登入', 'addServer.pinExpired' => 'PIN 碼在登入前已過期。請重試。', 'addServer.failedToRegisterAccount' => ({required Object error}) => '註冊帳戶失敗:${error}', - 'addServer.enterJellyfinUrlError' => '請輸入您的 Jellyfin 伺服器 URL', 'addServer.addConnectionTitle' => '新增連線', 'addServer.addConnectionTitleScoped' => ({required Object name}) => '新增連線至 ${name}', 'addServer.signInWithPlexCard' => '使用 Plex 登入', 'addServer.signInWithPlexCardSubtitle' => '授權此裝置。將會新增共享的伺服器連線。', 'addServer.signInWithPlexCardSubtitleScoped' => '授權 Plex 帳戶。Home 使用者會建立為個別的使用者設定檔。', - 'addServer.connectToJellyfinCard' => '連線至 Jellyfin', - 'addServer.connectToJellyfinCardSubtitle' => '輸入伺服器 URL、使用者名稱與密碼。', - 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => '登入 Jellyfin 伺服器,並綁定至 ${name} 使用者設定檔。', 'addServer.borrowFromAnotherProfile' => '從另一個使用者設定檔共用', 'addServer.borrowFromAnotherProfileSubtitle' => '重複使用另一個使用者設定檔的連線資訊。受 PIN 碼保護的使用者設定檔需輸入 PIN 碼。', _ => null, diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index d2785cac..e22ab0dc 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Väntar på autentisering...\nLogga in från din webbläsare.", "useBrowser": "Använd webbläsare", "or": "eller", - "connectToJellyfin": "Anslut till Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "Använd Quick Connect", "quickConnectInstructions": "Öppna Quick Connect i Jellyfin och ange den här koden.", "quickConnectWaiting": "Väntar på godkännande…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "Sessionen har gått ut för ${name}", "sessionExpiredMany": "Sessionen har gått ut för ${count} servrar", "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 som har lägst latens." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Upptäck", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Lägg till Jellyfin-server", + "addMediaBrowserTitle": "", "serverUrls": "Server-URL:er", "serverUrlsHelper": "Du kan ange flera URL:er avgränsade med kommatecken.", "findServer": "Hitta server", - "searchingLocalServers": "Söker efter lokala Jellyfin-servrar...", - "localServers": "Lokala Jellyfin-servrar", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Användarnamn", "password": "Lösenord", "signIn": "Logga in", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Logga in med Plex", "pinExpired": "PIN-koden gick ut innan inloggning. Försök igen.", "failedToRegisterAccount": "Kunde inte registrera kontot: ${error}", - "enterJellyfinUrlError": "Ange URL till din Jellyfin-server", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Lägg till anslutning", "addConnectionTitleScoped": "Lägg till i ${name}", "signInWithPlexCard": "Logga in med Plex", "signInWithPlexCardSubtitle": "Auktorisera den här enheten. Delade servrar läggs till.", "signInWithPlexCardSubtitleScoped": "Auktorisera ett Plex-konto. Home-användare blir profiler.", - "connectToJellyfinCard": "Anslut till Jellyfin", - "connectToJellyfinCardSubtitle": "Ange server-URL, användarnamn och lösenord.", - "connectToJellyfinCardSubtitleScoped": "Logga in på en Jellyfin-server. Kopplas till ${name}.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Låna från en annan profil", "borrowFromAnotherProfileSubtitle": "Återanvänd en annan profils anslutning. PIN-skyddade profiler kräver en PIN." } diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index 9cdffbbe..5ecdc84e 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Doğrulama bekleniyor...\nTarayıcınızdan giriş yapın.", "useBrowser": "Tarayıcı kullan", "or": "veya", - "connectToJellyfin": "Jellyfin'e Bağlan", + "connectToMediaBrowser": "", "useQuickConnect": "Hızlı Bağlantıyı Kullan", "quickConnectInstructions": "Jellyfin'de Hızlı Bağlantı'yı açın ve bu kodu girin.", "quickConnectWaiting": "Onay bekleniyor…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "${name} için oturum süresi doldu", "sessionExpiredMany": "${count} sunucu için oturum süresi doldu", "signInAgain": "Tekrar giriş yap", - "editJellyfinTitle": "Jellyfin bağlantısını düzenle", - "editJellyfinIntro": "${serverName} için URL'ler ekleyin veya kaldırın. Plezy, en düşük gecikmeye sahip ulaşılabilir URL'yi kullanacaktır." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Keşfet", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Jellyfin sunucusu ekle", + "addMediaBrowserTitle": "", "serverUrls": "Sunucu URL'leri", "serverUrlsHelper": "Virgülle ayrılmış birden fazla URL'ye izin verilir.", "findServer": "Sunucu bul", - "searchingLocalServers": "Yerel Jellyfin sunucuları aranıyor...", - "localServers": "Yerel Jellyfin sunucuları", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Kullanıcı adı", "password": "Şifre", "signIn": "Giriş Yap", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Plex ile Giriş Yap", "pinExpired": "Giriş yapmadan önce PIN süresi doldu. Lütfen tekrar deneyin.", "failedToRegisterAccount": "Hesap kaydı başarısız oldu: ${error}", - "enterJellyfinUrlError": "Jellyfin sunucu URL'nizi girin", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Bağlantı ekle", "addConnectionTitleScoped": "${name} profiline ekle", "signInWithPlexCard": "Plex ile Giriş Yap", "signInWithPlexCardSubtitle": "Bu cihazı yetkilendirin. Paylaşılan sunucular eklenir.", "signInWithPlexCardSubtitleScoped": "Bir Plex hesabını yetkilendirin. Ev kullanıcıları profile dönüşür.", - "connectToJellyfinCard": "Jellyfin'e Bağlan", - "connectToJellyfinCardSubtitle": "Sunucu URL'nizi, kullanıcı adınızı ve şifrenizi girin.", - "connectToJellyfinCardSubtitleScoped": "Bir Jellyfin sunucusuna giriş yapın. ${name} profiline bağlanır.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Başka bir profilden ödünç al", "borrowFromAnotherProfileSubtitle": "Başka bir profilin bağlantısını yeniden kullanın. PIN korumalı profiller bir PIN gerektirir." } diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 6ed3c89f..12e27649 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "Tasdiqlanish kutilmoqda...\nBrauzeringizdan kiring.", "useBrowser": "Brauzerdan foydalanish", "or": "yoki", - "connectToJellyfin": "Jellyfin-ga ulanish", + "connectToMediaBrowser": "", "useQuickConnect": "Tezkor ulanishdan foydalanish", "quickConnectInstructions": "Jellyfin-da Tezkor ulanishni oching va ushbu kodni kiriting.", "quickConnectWaiting": "Tasdiq kutilmoqda…", @@ -827,8 +827,8 @@ "sessionExpiredOne": "${name} uchun seans vaqti tugadi", "sessionExpiredMany": "${count} server uchun seans vaqti tugadi", "signInAgain": "Qaytadan kirish", - "editJellyfinTitle": "Jellyfin ulanishini tahrirlash", - "editJellyfinIntro": "${serverName} uchun URL manzilini qoʻshing yoki oʻchiring." + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "Kashf qilish", @@ -1873,12 +1873,12 @@ } }, "addServer": { - "addJellyfinTitle": "Jellyfin serverini qoʻshish", + "addMediaBrowserTitle": "", "serverUrls": "Server URL-lari", "serverUrlsHelper": "Vergul bilan ajratilgan bir nechta URL manziliga ruxsat beriladi.", "findServer": "Serverni topish", - "searchingLocalServers": "Mahalliy Jellyfin serverlari qidirilmoqda...", - "localServers": "Mahalliy Jellyfin serverlari", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "Foydalanuvchi nomi", "password": "Parol", "signIn": "Kirish", @@ -1890,15 +1890,15 @@ "addPlexTitle": "Plex orqali kirish", "pinExpired": "PIN kod vaqti tugadi.", "failedToRegisterAccount": "Hisobni roʻyxatdan oʻtkazishda xatolik: ${error}", - "enterJellyfinUrlError": "Jellyfin server URL-ini kiriting", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "Ulanish qoʻshish", "addConnectionTitleScoped": "${name} profiliga qoʻshish", "signInWithPlexCard": "Plex orqali kirish", "signInWithPlexCardSubtitle": "Ushbu qurilmani avtorizatsiya qiling.", "signInWithPlexCardSubtitleScoped": "Plex hisobini avtorizatsiya qiling.", - "connectToJellyfinCard": "Jellyfin-ga ulanish", - "connectToJellyfinCardSubtitle": "Server URL, foydalanuvchi nomi va parolingizni kiriting.", - "connectToJellyfinCardSubtitleScoped": "Jellyfin serveriga kiring. ${name} profiliga ulanmoqda.", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "Boshqa profildan olish", "borrowFromAnotherProfileSubtitle": "Boshqa profilning ulanishidan qayta foydalaning." } diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 8070e582..37a36730 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "正在等待驗證…\n請在瀏覽器中登入。", "useBrowser": "使用瀏覽器", "or": "或", - "connectToJellyfin": "連線至 Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "使用快速連線(Quick Connect)", "quickConnectInstructions": "在 Jellyfin 中開啟快速連線並輸入此代碼。", "quickConnectWaiting": "等待核准…", @@ -826,8 +826,8 @@ "sessionExpiredOne": "${name} 的工作階段已過期", "sessionExpiredMany": "${count} 個伺服器的工作階段已過期", "signInAgain": "重新登入", - "editJellyfinTitle": "編輯 Jellyfin 連線", - "editJellyfinIntro": "新增或移除 ${serverName} 的 URL。Plezy 會自動選擇可連線且延遲最低的網址。" + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "發現", @@ -1867,12 +1867,12 @@ } }, "addServer": { - "addJellyfinTitle": "新增 Jellyfin 伺服器", + "addMediaBrowserTitle": "", "serverUrls": "伺服器 URL", "serverUrlsHelper": "可輸入多個連線網址,以逗號區隔。", "findServer": "尋找伺服器", - "searchingLocalServers": "正在尋找本地 Jellyfin 伺服器…", - "localServers": "本地 Jellyfin 伺服器", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "使用者名稱", "password": "密碼", "signIn": "登入", @@ -1884,15 +1884,15 @@ "addPlexTitle": "使用 Plex 登入", "pinExpired": "PIN 碼在登入前已過期。請重試。", "failedToRegisterAccount": "註冊帳戶失敗:${error}", - "enterJellyfinUrlError": "請輸入您的 Jellyfin 伺服器 URL", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "新增連線", "addConnectionTitleScoped": "新增連線至 ${name}", "signInWithPlexCard": "使用 Plex 登入", "signInWithPlexCardSubtitle": "授權此裝置。將會新增共享的伺服器連線。", "signInWithPlexCardSubtitleScoped": "授權 Plex 帳戶。Home 使用者會建立為個別的使用者設定檔。", - "connectToJellyfinCard": "連線至 Jellyfin", - "connectToJellyfinCardSubtitle": "輸入伺服器 URL、使用者名稱與密碼。", - "connectToJellyfinCardSubtitleScoped": "登入 Jellyfin 伺服器,並綁定至 ${name} 使用者設定檔。", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "從另一個使用者設定檔共用", "borrowFromAnotherProfileSubtitle": "重複使用另一個使用者設定檔的連線資訊。受 PIN 碼保護的使用者設定檔需輸入 PIN 碼。" } diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 4a6e7917..6d791645 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -11,7 +11,7 @@ "waitingForAuth": "正在等待身份验证…\n请在浏览器中登录。", "useBrowser": "使用浏览器", "or": "或", - "connectToJellyfin": "连接到 Jellyfin", + "connectToMediaBrowser": "", "useQuickConnect": "使用 Quick Connect", "quickConnectInstructions": "在 Jellyfin 中打开 Quick Connect 并输入此代码。", "quickConnectWaiting": "等待批准…", @@ -826,8 +826,8 @@ "sessionExpiredOne": "${name} 的会话已过期", "sessionExpiredMany": "${count} 个服务器的会话已过期", "signInAgain": "重新登录", - "editJellyfinTitle": "编辑 Jellyfin 连接", - "editJellyfinIntro": "添加或移除 ${serverName} 的 URL。Plezy 会使用可访问且延迟最低的地址。" + "editMediaBrowserTitle": "", + "editMediaBrowserIntro": "" }, "discover": { "title": "发现", @@ -1867,12 +1867,12 @@ } }, "addServer": { - "addJellyfinTitle": "添加 Jellyfin 服务器", + "addMediaBrowserTitle": "", "serverUrls": "服务器 URL", "serverUrlsHelper": "可输入多个 URL,并用逗号分隔。", "findServer": "查找服务器", - "searchingLocalServers": "正在查找本地 Jellyfin 服务器…", - "localServers": "本地 Jellyfin 服务器", + "searchingLocalMediaBrowserServers": "", + "localMediaBrowserServers": "", "username": "用户名", "password": "密码", "signIn": "登录", @@ -1884,15 +1884,15 @@ "addPlexTitle": "使用 Plex 登录", "pinExpired": "PIN 在登录前已过期。请重试。", "failedToRegisterAccount": "注册账户失败:${error}", - "enterJellyfinUrlError": "请输入 Jellyfin 服务器 URL", + "enterMediaBrowserUrlError": "", "addConnectionTitle": "添加连接", "addConnectionTitleScoped": "添加到 ${name}", "signInWithPlexCard": "使用 Plex 登录", "signInWithPlexCardSubtitle": "授权此设备。共享服务器会被添加。", "signInWithPlexCardSubtitleScoped": "授权一个 Plex 账户。Plex Home 用户将成为 Plezy 用户资料。", - "connectToJellyfinCard": "连接到 Jellyfin", - "connectToJellyfinCardSubtitle": "输入服务器 URL、用户名和密码。", - "connectToJellyfinCardSubtitleScoped": "登录到 Jellyfin 服务器。绑定到 ${name}。", + "connectToMediaBrowserCard": "", + "connectToMediaBrowserCardSubtitle": "", + "connectToMediaBrowserCardSubtitleScoped": "", "borrowFromAnotherProfile": "使用其他用户资料的连接", "borrowFromAnotherProfileSubtitle": "复用另一个用户资料的连接。受 PIN 保护的用户资料需要输入 PIN。" } diff --git a/lib/main.dart b/lib/main.dart index a71e6c7b..5d0fbaff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1857,7 +1857,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { serverManager: serverManager, ).pruneUnreferencedJellyfinConnections(); if (pruned > 0) { - appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}'); + appLogger.i('Setup: pruned $pruned unreferenced MediaBrowser connection${pruned == 1 ? '' : 's'}'); } // Provider initialization starts before this screen runs the legacy // migration. Reload after bootstrap so copied Plex Home users and the @@ -1943,11 +1943,11 @@ class _SetupScreenState extends State with MountedSetStateMixin { } final plexCount = allConnections.whereType().fold(0, (n, c) => n + c.servers.length); - final jellyfinCount = allConnections.whereType().length; + final mediaBrowserCount = allConnections.whereType().length; unawaited( Sentry.addBreadcrumb( Breadcrumb( - message: 'Handing off to MainScreen with $plexCount Plex server(s) + $jellyfinCount Jellyfin', + message: 'Handing off to MainScreen with $plexCount Plex + $mediaBrowserCount MediaBrowser server(s)', category: 'setup', ), ), @@ -2003,7 +2003,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { if (!mounted) return; bindingSucceeded = activeProfile.active != null && activeProfile.lastBindingSucceeded; } else { - // Now wait for the binder to settle. This is the Plex/Jellyfin server + // Now wait for the binder to settle. This is the media-server client // race: per-server status flips on the splash list as each client comes // online, and we don't push MainScreen until they're all done (success // or fail). Eliminates the "Failed to load discover content: No servers @@ -2023,7 +2023,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { } // Repopulate metadata for downloaded items now that per-backend caches - // are resolvable (the Connections row + live JellyfinClient are in + // are resolvable (the Connections row + live MediaBrowser client are in // place). Without this the downloads list and sync-rule titles render // empty until something forces a later refresh. await downloadProvider.refreshMetadataFromCache(); diff --git a/lib/media/media_backend.dart b/lib/media/media_backend.dart index 98aa325f..d26c42aa 100644 --- a/lib/media/media_backend.dart +++ b/lib/media/media_backend.dart @@ -1,4 +1,5 @@ import '../utils/app_logger.dart'; +import 'media_browser_dialect.dart'; /// Backend identifier for a media item, library, or server. /// @@ -7,16 +8,19 @@ import '../utils/app_logger.dart'; /// in v1) and so persisted records can round-trip the source of an item. enum MediaBackend { plex, - jellyfin; + jellyfin, + emby; String get id => switch (this) { MediaBackend.plex => 'plex', MediaBackend.jellyfin => 'jellyfin', + MediaBackend.emby => 'emby', }; static MediaBackend fromId(String id) => switch (id) { 'plex' => MediaBackend.plex, 'jellyfin' => MediaBackend.jellyfin, + 'emby' => MediaBackend.emby, _ => throw ArgumentError('Unknown MediaBackend id: $id'), }; @@ -27,12 +31,26 @@ enum MediaBackend { /// surfaces corrupted cache rows or schema drift instead of silently /// misclassifying Jellyfin items as Plex. static MediaBackend fromString(String? id) { - if (id != null && id != 'plex' && id != 'jellyfin') { + if (id != null && id != 'plex' && id != 'jellyfin' && id != 'emby') { appLogger.w('Unknown MediaBackend id "$id"; defaulting to plex'); } return switch (id) { 'jellyfin' => MediaBackend.jellyfin, + 'emby' => MediaBackend.emby, _ => MediaBackend.plex, }; } + + /// True for backends served by the MediaBrowser HTTP API — Jellyfin and its + /// Emby ancestor. They share one client stack, one query grammar and one set + /// of DTO shapes, so behaviour keyed to "not Plex" should test this instead + /// of comparing against [MediaBackend.jellyfin]. + bool get usesMediaBrowserApi => dialect != null; + + /// The MediaBrowser dialect this backend speaks, or `null` for Plex. + MediaBrowserDialect? get dialect => switch (this) { + MediaBackend.plex => null, + MediaBackend.jellyfin => MediaBrowserDialect.jellyfin, + MediaBackend.emby => MediaBrowserDialect.emby, + }; } diff --git a/lib/media/media_browser_dialect.dart b/lib/media/media_browser_dialect.dart new file mode 100644 index 00000000..341b1580 --- /dev/null +++ b/lib/media/media_browser_dialect.dart @@ -0,0 +1,189 @@ +import 'media_backend.dart'; + +/// Which flavour of the MediaBrowser HTTP API a server speaks. +/// +/// Jellyfin forked from Emby 3.5.2, so the two still share almost their entire +/// wire contract: identical `BaseItemDto` shapes, the same `/Items` query +/// grammar, the `MediaBrowser` Authorization scheme, the `X-Emby-Token` header +/// and `api_key=` query fallback. Plezy therefore drives both through one +/// client stack ([JellyfinClient]) and keeps every delta in this one type. +/// +/// Verified against Jellyfin 10.10.7/10.11 and Emby 4.9.5: +/// - Jellyfin 10.9 renamed a batch of user-scoped write routes to unprefixed +/// forms and added `/Users/Me`. Emby only has the original user-scoped +/// spellings — see [MediaBrowserPaths]. +/// - Trickplay, `/MediaSegments`, `/Audio/{id}/Lyrics`, `/Items/Filters` and +/// Quick Connect do not exist on Emby. `/Audio/{id}/Lyrics` is actively +/// harmful there: Emby parses `Lyrics` as a container name and starts an +/// ffmpeg transcode. +/// - Emby tolerates unknown `Fields`/`SortBy` values, so the shared field sets +/// need no per-dialect pruning. +enum MediaBrowserDialect { + jellyfin, + emby; + + /// Stable wire/persistence id. Matches the [MediaBackend] and + /// `ConnectionKind` ids for the same server kind. + String get id => switch (this) { + MediaBrowserDialect.jellyfin => 'jellyfin', + MediaBrowserDialect.emby => 'emby', + }; + + static MediaBrowserDialect fromId(String id) => switch (id) { + 'jellyfin' => MediaBrowserDialect.jellyfin, + 'emby' => MediaBrowserDialect.emby, + _ => throw ArgumentError('Unknown MediaBrowserDialect id: $id'), + }; + + /// Like [fromId] but tolerates legacy/missing values by defaulting to + /// Jellyfin. Persisted connection rows written before Emby support carry no + /// `dialect` key at all, and those are Jellyfin by construction. + static MediaBrowserDialect fromIdOrJellyfin(Object? id) => switch (id) { + 'emby' => MediaBrowserDialect.emby, + _ => MediaBrowserDialect.jellyfin, + }; + + MediaBackend get backend => switch (this) { + MediaBrowserDialect.jellyfin => MediaBackend.jellyfin, + MediaBrowserDialect.emby => MediaBackend.emby, + }; + + /// Product name, used verbatim in UI that names the backend. These are + /// trademarks, so they are not localized. + String get productName => switch (this) { + MediaBrowserDialect.jellyfin => 'Jellyfin', + MediaBrowserDialect.emby => 'Emby', + }; + + /// Placeholder host shown in the "server URL" field. + String get exampleBaseUrl => switch (this) { + MediaBrowserDialect.jellyfin => 'https://jellyfin.example.com', + MediaBrowserDialect.emby => 'https://emby.example.com', + }; + + /// UDP payload the server answers on port 7359. Emby ignores Jellyfin's + /// string and vice versa, which makes the datagram itself a reliable + /// dialect discriminator during LAN discovery. + String get lanDiscoveryMessage => switch (this) { + MediaBrowserDialect.jellyfin => 'who is JellyfinServer?', + MediaBrowserDialect.emby => 'who is EmbyServer?', + }; + + /// Ports appended when the user types a bare host, most-likely first. + /// Both ship 8096 for HTTP; Emby's default HTTPS port is 8920. + List get httpsPortGuesses => switch (this) { + MediaBrowserDialect.jellyfin => const [8096], + MediaBrowserDialect.emby => const [8920, 8096], + }; + + /// `/QuickConnect/*` plus `POST /Users/AuthenticateWithQuickConnect`. + bool get supportsQuickConnect => this == MediaBrowserDialect.jellyfin; + + /// `/Videos/{id}/Trickplay/{width}/{n}.jpg` sprite sheets and the + /// `Trickplay` item field (Jellyfin 10.9+). Emby 404s on the route and never + /// fills the field; its own preview transports are unwired — see + /// [ServerCapabilities.emby]. + bool get supportsTrickplay => this == MediaBrowserDialect.jellyfin; + + /// `/MediaSegments/{itemId}` intro/outro/credit markers (Jellyfin 10.10+). + /// Emby 404s; chapter-name fallback still applies. + bool get supportsMediaSegments => this == MediaBrowserDialect.jellyfin; + + /// `GET /Audio/{id}/Lyrics` (Jellyfin 10.9+). Never call this on Emby: the + /// route resolves to audio streaming with `Lyrics` as the container and + /// spawns an ffmpeg process that fails with a 500. + bool get supportsLyrics => this == MediaBrowserDialect.jellyfin; + + /// `GET /Items/Filters`, the single call that returns a library's distinct + /// genres, official ratings, tags and years. Emby has no aggregate route; the + /// client reassembles the same payload from `/Genres`, `/OfficialRatings`, + /// `/Tags` and `/Years`. + bool get supportsAggregateItemFilters => this == MediaBrowserDialect.jellyfin; + + /// `POST /Users/{uid}/Items/{id}/HideFromResume` hides an item from Continue + /// Watching without clearing its resume position. + /// + /// Emby-only, and the one capability where Emby is ahead of Jellyfin: + /// measured 200 on Emby 4.9.5 (the row leaves `/Users/{uid}/Items/Resume` + /// while `UserData.PlaybackPositionTicks` survives), and 404 on Jellyfin + /// 10.11 for both that spelling and `/UserItems/{id}/HideFromResume`. + bool get supportsContinueWatchingRemoval => this == MediaBrowserDialect.emby; + + /// `POST /Items/{id}` persists genre and tag edits from the `GenreItems` / + /// `TagItems` name-pair arrays rather than the plain `Genres` / `Tags` string + /// lists. + /// + /// Measured on Emby 4.9.5: sending `Genres: ['Action']` alone round-trips as + /// an empty list and the `/Genres` facet stays empty, while + /// `GenreItems: [{'Name': 'Action'}]` sticks and is immediately indexed. The + /// sibling fields (`Studios`, `People`, `ProductionLocations`, `Taglines`, + /// `Overview`, `OriginalTitle`) all persist from their ordinary shapes on + /// both dialects. + bool get metadataWritesUseNamePairLists => this == MediaBrowserDialect.emby; + + /// `GET /Shows/NextUp` answers an unscoped, library-wide query. + /// + /// Jellyfin-only. Measured on Emby 4.9.5 with one played episode: the + /// unscoped query returns `TotalRecordCount: 0` under every parameter + /// combination tried (`ParentId` on the view or the series, `SeriesId=`, + /// `Recursive`, `GroupItems`, `EnableResumable`, `SortBy`), while the same + /// query with `SeriesId=` returns the series' 23 remaining episodes. + /// Emby therefore only computes Next Up per series, and the library-wide + /// shelf has to be reconstructed client-side from recently played episodes. + bool get supportsGlobalNextUp => this == MediaBrowserDialect.jellyfin; + + /// The resume route returns only items with a saved playback position. + /// + /// Jellyfin-only. Measured with one in-progress movie and 30 started series: + /// Jellyfin's `/UserItems/Resume` returned exactly the movie, while Emby's + /// `/Users/{uid}/Items/Resume` returned 30 rows — the movie plus 29 + /// zero-position *next* episodes — and `Filters=IsResumable` did not remove + /// them. Emby's own UI merges both into one shelf; Plezy models Continue + /// Watching and Next Up as separate rows, so the Emby resume leg is filtered + /// to genuine progress and the next-up rows come from the Next Up path + /// instead. Without the filter a started series occupies both rows and can + /// push the real in-progress item out of a limited one. + bool get resumeReturnsOnlyStartedItems => this == MediaBrowserDialect.jellyfin; + + /// `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no + /// `PlaySessionId`. + /// + /// Measured on Emby 4.9.5: both return HTTP 400 `Value cannot be null. + /// (Parameter 'key')` when the field is absent, while + /// `/Sessions/Playing/Stopped` tolerates it. Jellyfin accepts all three + /// without one. Callers that never negotiated a PlaybackInfo session — the + /// offline watch-progress sync is the live example — therefore need a + /// synthesized id on Emby or their progress is silently dropped. + bool get requiresPlaySessionId => this == MediaBrowserDialect.emby; + + /// `/Items?IncludeItemTypes=Playlist` honours a `MediaTypes` filter. + /// + /// Measured on Emby 4.9.5: passing *any* `MediaTypes` value makes the server + /// discard `IncludeItemTypes` and return the whole index — 14554 rows of + /// `Genre`/`Person`/`Studio`/`Movie` instead of the one playlist. Emby also + /// never populates `MediaType` on a playlist DTO, not even for a playlist + /// created with `MediaType=Audio`, so there is nothing to filter on either + /// side and every playlist is returned regardless of the requested type. + bool get playlistsFilterByMediaType => this == MediaBrowserDialect.jellyfin; + + /// True when the dialect only accepts the pre-10.9 `/Users/{userId}/…` + /// spelling of the user-scoped item routes. + bool get requiresUserScopedItemRoutes => this == MediaBrowserDialect.emby; + + /// Best-effort dialect detection from a `/System/Info/Public` body. + /// + /// Jellyfin reports `ProductName: "Jellyfin Server"`. Emby 4.9 omits + /// `ProductName` entirely but is the only one of the two that returns the + /// `RemoteAddresses` array. Returns `null` when neither signal is present so + /// callers keep whichever dialect the user picked. + static MediaBrowserDialect? detectFromPublicSystemInfo(Map json) { + final productName = json['ProductName']; + if (productName is String && productName.isNotEmpty) { + final normalized = productName.toLowerCase(); + if (normalized.contains('jellyfin')) return MediaBrowserDialect.jellyfin; + if (normalized.contains('emby')) return MediaBrowserDialect.emby; + } + if (json.containsKey('RemoteAddresses')) return MediaBrowserDialect.emby; + return null; + } +} diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index bf876f8f..23a61e53 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -7,6 +7,7 @@ import '../services/settings_service.dart' show EpisodePosterMode; import '../utils/global_key_utils.dart'; import '../utils/json_utils.dart'; import 'media_backend.dart'; +import 'media_browser_dialect.dart'; import 'media_kind.dart'; import 'media_role.dart'; import 'media_version.dart'; @@ -23,6 +24,10 @@ const double _squareHeroAspectRatio = 1.39; /// Backend-neutral media item shape used by UI, providers, persistence, and /// playback. Concrete variants retain backend-only fields without forcing the /// rest of the app to traffic in Plex/Jellyfin DTOs. +/// +/// [JellyfinMediaItem] backs both MediaBrowser-family backends: Jellyfin and +/// Emby return field-identical `BaseItemDto`s, so they share one variant and +/// carry [JellyfinMediaItem.dialect] to tell them apart. @Freezed(unionKey: 'backend', unionValueCase: FreezedUnionCase.none, equal: false, makeCollectionsUnmodifiable: false) sealed class MediaItem with _$MediaItem { const MediaItem._(); @@ -154,7 +159,8 @@ sealed class MediaItem with _$MediaItem { backendFolderKey: backendFolderKey, raw: raw, ), - MediaBackend.jellyfin => JellyfinMediaItem( + MediaBackend.jellyfin || MediaBackend.emby => JellyfinMediaItem( + dialect: backend.dialect!, id: id, kind: kind, guid: guid, @@ -300,10 +306,20 @@ sealed class MediaItem with _$MediaItem { @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw, }) = PlexMediaItem; - /// Backend-tagged concrete subclass for items sourced from a Jellyfin server. + /// Backend-tagged concrete subclass for items sourced from a MediaBrowser + /// server — Jellyfin or Emby, discriminated by [JellyfinMediaItem.dialect]. @FreezedUnionValue('jellyfin') @JsonSerializable(includeIfNull: false, explicitToJson: true) const factory MediaItem.jellyfin({ + /// Which MediaBrowser dialect produced this item. + /// + /// Not serialized on its own: [MediaItem.toJson] already writes the + /// resolved [backend] id under the union key, and [MediaItem.fromJson] + /// restores the dialect from it. Keeping one discriminator on the wire + /// avoids the two disagreeing on a stale cache row. + @JsonKey(includeToJson: false, includeFromJson: false) + @Default(MediaBrowserDialect.jellyfin) + MediaBrowserDialect dialect, @JsonKey(readValue: readStringField, defaultValue: '') required String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required MediaKind kind, String? guid, @@ -375,7 +391,7 @@ sealed class MediaItem with _$MediaItem { MediaBackend get backend => switch (this) { PlexMediaItem() => MediaBackend.plex, - JellyfinMediaItem() => MediaBackend.jellyfin, + JellyfinMediaItem(:final dialect) => dialect.backend, }; /// Restore a [MediaItem] from a [toJson] payload. Missing/unknown backend @@ -385,13 +401,14 @@ sealed class MediaItem with _$MediaItem { return switch (MediaBackend.fromString(json['backend'] as String?)) { MediaBackend.plex => _$PlexMediaItemFromJson(json), MediaBackend.jellyfin => _$JellyfinMediaItemFromJson(json), + MediaBackend.emby => _$JellyfinMediaItemFromJson(json).copyWith(dialect: MediaBrowserDialect.emby), }; } Map toJson() { return switch (this) { final PlexMediaItem item => {'backend': MediaBackend.plex.id, ..._$PlexMediaItemToJson(item)}, - final JellyfinMediaItem item => {'backend': MediaBackend.jellyfin.id, ..._$JellyfinMediaItemToJson(item)}, + final JellyfinMediaItem item => {'backend': item.dialect.backend.id, ..._$JellyfinMediaItemToJson(item)}, }; } diff --git a/lib/media/media_item.freezed.dart b/lib/media/media_item.freezed.dart index d6c15fcc..51f00eca 100644 --- a/lib/media/media_item.freezed.dart +++ b/lib/media/media_item.freezed.dart @@ -208,11 +208,11 @@ return jellyfin(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? plex,TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? jellyfin,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? plex,TResult Function(@JsonKey(includeToJson: false, includeFromJson: false) MediaBrowserDialect dialect, @JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? jellyfin,required TResult orElse(),}) {final _that = this; switch (_that) { case PlexMediaItem() when plex != null: return plex(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.editionTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.ratings,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.subtitleLanguage,_that.subtitleMode,_that.trailerKey,_that.playlistItemId,_that.playQueueItemId,_that.subtype,_that.extraType,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case JellyfinMediaItem() when jellyfin != null: -return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.ratings,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case _: +return jellyfin(_that.dialect,_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.ratings,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case _: return orElse(); } @@ -230,11 +230,11 @@ return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw) plex,required TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw) jellyfin,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw) plex,required TResult Function(@JsonKey(includeToJson: false, includeFromJson: false) MediaBrowserDialect dialect, @JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw) jellyfin,}) {final _that = this; switch (_that) { case PlexMediaItem(): return plex(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.editionTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.ratings,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.subtitleLanguage,_that.subtitleMode,_that.trailerKey,_that.playlistItemId,_that.playQueueItemId,_that.subtype,_that.extraType,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case JellyfinMediaItem(): -return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.ratings,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);} +return jellyfin(_that.dialect,_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.ratings,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);} } /// A variant of `when` that fallback to returning `null` /// @@ -248,11 +248,11 @@ return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? plex,TResult? Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? jellyfin,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? plex,TResult? Function(@JsonKey(includeToJson: false, includeFromJson: false) MediaBrowserDialect dialect, @JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? jellyfin,}) {final _that = this; switch (_that) { case PlexMediaItem() when plex != null: return plex(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.editionTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.ratings,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.subtitleLanguage,_that.subtitleMode,_that.trailerKey,_that.playlistItemId,_that.playQueueItemId,_that.subtype,_that.extraType,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case JellyfinMediaItem() when jellyfin != null: -return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.ratings,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case _: +return jellyfin(_that.dialect,_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.ratings,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case _: return null; } @@ -461,9 +461,16 @@ as Map?, @JsonSerializable(includeIfNull: false, explicitToJson: true) class JellyfinMediaItem extends MediaItem { - const JellyfinMediaItem({@JsonKey(readValue: readStringField, defaultValue: '') required this.id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required this.kind, this.guid, this.title, this.titleSort, this.summary, this.tagline, this.originalTitle, this.studio, @JsonKey(fromJson: flexibleInt) this.year, this.originallyAvailableAt, this.contentRating, this.parentId, this.parentTitle, this.parentThumbPath, @JsonKey(fromJson: flexibleInt) this.parentIndex, @JsonKey(fromJson: flexibleInt) this.index, this.grandparentId, this.grandparentTitle, this.grandparentThumbPath, this.grandparentArtPath, this.grandparentBackdropPaths, this.thumbPath, this.artPath, this.backdropPaths, this.clearLogoPath, this.backgroundSquarePath, @JsonKey(fromJson: flexibleInt) this.durationMs, @JsonKey(fromJson: flexibleInt) this.viewOffsetMs, @JsonKey(fromJson: flexibleInt) this.viewCount, @JsonKey(fromJson: flexibleInt) this.lastViewedAt, @JsonKey(fromJson: flexibleInt) this.leafCount, @JsonKey(fromJson: flexibleInt) this.viewedLeafCount, @JsonKey(fromJson: flexibleInt) this.childCount, @JsonKey(fromJson: flexibleInt) this.addedAt, @JsonKey(fromJson: flexibleInt) this.updatedAt, @JsonKey(fromJson: flexibleDouble) this.rating, @JsonKey(fromJson: flexibleDouble) this.userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) this.ratings, this.isFavorite, @JsonKey(fromJson: _mediaItemStringList) this.genres, @JsonKey(fromJson: _mediaItemStringList) this.directors, @JsonKey(fromJson: _mediaItemStringList) this.writers, @JsonKey(fromJson: _mediaItemStringList) this.producers, @JsonKey(fromJson: _mediaItemStringList) this.countries, @JsonKey(fromJson: _mediaItemStringList) this.collections, @JsonKey(fromJson: _mediaItemStringList) this.labels, @JsonKey(fromJson: _mediaItemStringList) this.styles, @JsonKey(fromJson: _mediaItemStringList) this.moods, @JsonKey(fromJson: _mediaItemRolesFromJson) this.roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) this.mediaVersions, this.libraryId, this.libraryTitle, this.audioLanguage, this.playlistItemId, this.serverId, this.serverName, this.backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) this.raw}): super._(); + const JellyfinMediaItem({@JsonKey(includeToJson: false, includeFromJson: false) this.dialect = MediaBrowserDialect.jellyfin, @JsonKey(readValue: readStringField, defaultValue: '') required this.id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required this.kind, this.guid, this.title, this.titleSort, this.summary, this.tagline, this.originalTitle, this.studio, @JsonKey(fromJson: flexibleInt) this.year, this.originallyAvailableAt, this.contentRating, this.parentId, this.parentTitle, this.parentThumbPath, @JsonKey(fromJson: flexibleInt) this.parentIndex, @JsonKey(fromJson: flexibleInt) this.index, this.grandparentId, this.grandparentTitle, this.grandparentThumbPath, this.grandparentArtPath, this.grandparentBackdropPaths, this.thumbPath, this.artPath, this.backdropPaths, this.clearLogoPath, this.backgroundSquarePath, @JsonKey(fromJson: flexibleInt) this.durationMs, @JsonKey(fromJson: flexibleInt) this.viewOffsetMs, @JsonKey(fromJson: flexibleInt) this.viewCount, @JsonKey(fromJson: flexibleInt) this.lastViewedAt, @JsonKey(fromJson: flexibleInt) this.leafCount, @JsonKey(fromJson: flexibleInt) this.viewedLeafCount, @JsonKey(fromJson: flexibleInt) this.childCount, @JsonKey(fromJson: flexibleInt) this.addedAt, @JsonKey(fromJson: flexibleInt) this.updatedAt, @JsonKey(fromJson: flexibleDouble) this.rating, @JsonKey(fromJson: flexibleDouble) this.userRating, @JsonKey(fromJson: _mediaItemRatingsFromJson) this.ratings, this.isFavorite, @JsonKey(fromJson: _mediaItemStringList) this.genres, @JsonKey(fromJson: _mediaItemStringList) this.directors, @JsonKey(fromJson: _mediaItemStringList) this.writers, @JsonKey(fromJson: _mediaItemStringList) this.producers, @JsonKey(fromJson: _mediaItemStringList) this.countries, @JsonKey(fromJson: _mediaItemStringList) this.collections, @JsonKey(fromJson: _mediaItemStringList) this.labels, @JsonKey(fromJson: _mediaItemStringList) this.styles, @JsonKey(fromJson: _mediaItemStringList) this.moods, @JsonKey(fromJson: _mediaItemRolesFromJson) this.roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) this.mediaVersions, this.libraryId, this.libraryTitle, this.audioLanguage, this.playlistItemId, this.serverId, this.serverName, this.backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) this.raw}): super._(); +/// Which MediaBrowser dialect produced this item. +/// +/// Not serialized on its own: [MediaItem.toJson] already writes the +/// resolved [backend] id under the union key, and [MediaItem.fromJson] +/// restores the dialect from it. Keeping one discriminator on the wire +/// avoids the two disagreeing on a stale cache row. +@JsonKey(includeToJson: false, includeFromJson: false) final MediaBrowserDialect dialect; @override@JsonKey(readValue: readStringField, defaultValue: '') final String id; @override@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) final MediaKind kind; @override final String? guid; @@ -541,7 +548,7 @@ $JellyfinMediaItemCopyWith get copyWith => _$JellyfinMediaIte @override String toString() { - return 'MediaItem.jellyfin(id: $id, kind: $kind, guid: $guid, title: $title, titleSort: $titleSort, summary: $summary, tagline: $tagline, originalTitle: $originalTitle, studio: $studio, year: $year, originallyAvailableAt: $originallyAvailableAt, contentRating: $contentRating, parentId: $parentId, parentTitle: $parentTitle, parentThumbPath: $parentThumbPath, parentIndex: $parentIndex, index: $index, grandparentId: $grandparentId, grandparentTitle: $grandparentTitle, grandparentThumbPath: $grandparentThumbPath, grandparentArtPath: $grandparentArtPath, grandparentBackdropPaths: $grandparentBackdropPaths, thumbPath: $thumbPath, artPath: $artPath, backdropPaths: $backdropPaths, clearLogoPath: $clearLogoPath, backgroundSquarePath: $backgroundSquarePath, durationMs: $durationMs, viewOffsetMs: $viewOffsetMs, viewCount: $viewCount, lastViewedAt: $lastViewedAt, leafCount: $leafCount, viewedLeafCount: $viewedLeafCount, childCount: $childCount, addedAt: $addedAt, updatedAt: $updatedAt, rating: $rating, userRating: $userRating, ratings: $ratings, isFavorite: $isFavorite, genres: $genres, directors: $directors, writers: $writers, producers: $producers, countries: $countries, collections: $collections, labels: $labels, styles: $styles, moods: $moods, roles: $roles, mediaVersions: $mediaVersions, libraryId: $libraryId, libraryTitle: $libraryTitle, audioLanguage: $audioLanguage, playlistItemId: $playlistItemId, serverId: $serverId, serverName: $serverName, backendFolderKey: $backendFolderKey, raw: $raw)'; + return 'MediaItem.jellyfin(dialect: $dialect, id: $id, kind: $kind, guid: $guid, title: $title, titleSort: $titleSort, summary: $summary, tagline: $tagline, originalTitle: $originalTitle, studio: $studio, year: $year, originallyAvailableAt: $originallyAvailableAt, contentRating: $contentRating, parentId: $parentId, parentTitle: $parentTitle, parentThumbPath: $parentThumbPath, parentIndex: $parentIndex, index: $index, grandparentId: $grandparentId, grandparentTitle: $grandparentTitle, grandparentThumbPath: $grandparentThumbPath, grandparentArtPath: $grandparentArtPath, grandparentBackdropPaths: $grandparentBackdropPaths, thumbPath: $thumbPath, artPath: $artPath, backdropPaths: $backdropPaths, clearLogoPath: $clearLogoPath, backgroundSquarePath: $backgroundSquarePath, durationMs: $durationMs, viewOffsetMs: $viewOffsetMs, viewCount: $viewCount, lastViewedAt: $lastViewedAt, leafCount: $leafCount, viewedLeafCount: $viewedLeafCount, childCount: $childCount, addedAt: $addedAt, updatedAt: $updatedAt, rating: $rating, userRating: $userRating, ratings: $ratings, isFavorite: $isFavorite, genres: $genres, directors: $directors, writers: $writers, producers: $producers, countries: $countries, collections: $collections, labels: $labels, styles: $styles, moods: $moods, roles: $roles, mediaVersions: $mediaVersions, libraryId: $libraryId, libraryTitle: $libraryTitle, audioLanguage: $audioLanguage, playlistItemId: $playlistItemId, serverId: $serverId, serverName: $serverName, backendFolderKey: $backendFolderKey, raw: $raw)'; } @@ -552,7 +559,7 @@ abstract mixin class $JellyfinMediaItemCopyWith<$Res> implements $MediaItemCopyW factory $JellyfinMediaItemCopyWith(JellyfinMediaItem value, $Res Function(JellyfinMediaItem) _then) = _$JellyfinMediaItemCopyWithImpl; @override @useResult $Res call({ -@JsonKey(readValue: readStringField, defaultValue: '') String id,@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio,@JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath,@JsonKey(fromJson: flexibleInt) int? parentIndex,@JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath,@JsonKey(fromJson: flexibleInt) int? durationMs,@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,@JsonKey(fromJson: flexibleInt) int? viewCount,@JsonKey(fromJson: flexibleInt) int? lastViewedAt,@JsonKey(fromJson: flexibleInt) int? leafCount,@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,@JsonKey(fromJson: flexibleInt) int? childCount,@JsonKey(fromJson: flexibleInt) int? addedAt,@JsonKey(fromJson: flexibleInt) int? updatedAt,@JsonKey(fromJson: flexibleDouble) double? rating,@JsonKey(fromJson: flexibleDouble) double? userRating,@JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite,@JsonKey(fromJson: _mediaItemStringList) List? genres,@JsonKey(fromJson: _mediaItemStringList) List? directors,@JsonKey(fromJson: _mediaItemStringList) List? writers,@JsonKey(fromJson: _mediaItemStringList) List? producers,@JsonKey(fromJson: _mediaItemStringList) List? countries,@JsonKey(fromJson: _mediaItemStringList) List? collections,@JsonKey(fromJson: _mediaItemStringList) List? labels,@JsonKey(fromJson: _mediaItemStringList) List? styles,@JsonKey(fromJson: _mediaItemStringList) List? moods,@JsonKey(fromJson: _mediaItemRolesFromJson) List? roles,@JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey,@JsonKey(fromJson: _mediaItemRawFromJson) Map? raw +@JsonKey(includeToJson: false, includeFromJson: false) MediaBrowserDialect dialect,@JsonKey(readValue: readStringField, defaultValue: '') String id,@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio,@JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath,@JsonKey(fromJson: flexibleInt) int? parentIndex,@JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath,@JsonKey(fromJson: flexibleInt) int? durationMs,@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,@JsonKey(fromJson: flexibleInt) int? viewCount,@JsonKey(fromJson: flexibleInt) int? lastViewedAt,@JsonKey(fromJson: flexibleInt) int? leafCount,@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,@JsonKey(fromJson: flexibleInt) int? childCount,@JsonKey(fromJson: flexibleInt) int? addedAt,@JsonKey(fromJson: flexibleInt) int? updatedAt,@JsonKey(fromJson: flexibleDouble) double? rating,@JsonKey(fromJson: flexibleDouble) double? userRating,@JsonKey(fromJson: _mediaItemRatingsFromJson) List? ratings, bool? isFavorite,@JsonKey(fromJson: _mediaItemStringList) List? genres,@JsonKey(fromJson: _mediaItemStringList) List? directors,@JsonKey(fromJson: _mediaItemStringList) List? writers,@JsonKey(fromJson: _mediaItemStringList) List? producers,@JsonKey(fromJson: _mediaItemStringList) List? countries,@JsonKey(fromJson: _mediaItemStringList) List? collections,@JsonKey(fromJson: _mediaItemStringList) List? labels,@JsonKey(fromJson: _mediaItemStringList) List? styles,@JsonKey(fromJson: _mediaItemStringList) List? moods,@JsonKey(fromJson: _mediaItemRolesFromJson) List? roles,@JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey,@JsonKey(fromJson: _mediaItemRawFromJson) Map? raw }); @@ -569,9 +576,10 @@ class _$JellyfinMediaItemCopyWithImpl<$Res> /// Create a copy of MediaItem /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? kind = null,Object? guid = freezed,Object? title = freezed,Object? titleSort = freezed,Object? summary = freezed,Object? tagline = freezed,Object? originalTitle = freezed,Object? studio = freezed,Object? year = freezed,Object? originallyAvailableAt = freezed,Object? contentRating = freezed,Object? parentId = freezed,Object? parentTitle = freezed,Object? parentThumbPath = freezed,Object? parentIndex = freezed,Object? index = freezed,Object? grandparentId = freezed,Object? grandparentTitle = freezed,Object? grandparentThumbPath = freezed,Object? grandparentArtPath = freezed,Object? grandparentBackdropPaths = freezed,Object? thumbPath = freezed,Object? artPath = freezed,Object? backdropPaths = freezed,Object? clearLogoPath = freezed,Object? backgroundSquarePath = freezed,Object? durationMs = freezed,Object? viewOffsetMs = freezed,Object? viewCount = freezed,Object? lastViewedAt = freezed,Object? leafCount = freezed,Object? viewedLeafCount = freezed,Object? childCount = freezed,Object? addedAt = freezed,Object? updatedAt = freezed,Object? rating = freezed,Object? userRating = freezed,Object? ratings = freezed,Object? isFavorite = freezed,Object? genres = freezed,Object? directors = freezed,Object? writers = freezed,Object? producers = freezed,Object? countries = freezed,Object? collections = freezed,Object? labels = freezed,Object? styles = freezed,Object? moods = freezed,Object? roles = freezed,Object? mediaVersions = freezed,Object? libraryId = freezed,Object? libraryTitle = freezed,Object? audioLanguage = freezed,Object? playlistItemId = freezed,Object? serverId = freezed,Object? serverName = freezed,Object? backendFolderKey = freezed,Object? raw = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? dialect = null,Object? id = null,Object? kind = null,Object? guid = freezed,Object? title = freezed,Object? titleSort = freezed,Object? summary = freezed,Object? tagline = freezed,Object? originalTitle = freezed,Object? studio = freezed,Object? year = freezed,Object? originallyAvailableAt = freezed,Object? contentRating = freezed,Object? parentId = freezed,Object? parentTitle = freezed,Object? parentThumbPath = freezed,Object? parentIndex = freezed,Object? index = freezed,Object? grandparentId = freezed,Object? grandparentTitle = freezed,Object? grandparentThumbPath = freezed,Object? grandparentArtPath = freezed,Object? grandparentBackdropPaths = freezed,Object? thumbPath = freezed,Object? artPath = freezed,Object? backdropPaths = freezed,Object? clearLogoPath = freezed,Object? backgroundSquarePath = freezed,Object? durationMs = freezed,Object? viewOffsetMs = freezed,Object? viewCount = freezed,Object? lastViewedAt = freezed,Object? leafCount = freezed,Object? viewedLeafCount = freezed,Object? childCount = freezed,Object? addedAt = freezed,Object? updatedAt = freezed,Object? rating = freezed,Object? userRating = freezed,Object? ratings = freezed,Object? isFavorite = freezed,Object? genres = freezed,Object? directors = freezed,Object? writers = freezed,Object? producers = freezed,Object? countries = freezed,Object? collections = freezed,Object? labels = freezed,Object? styles = freezed,Object? moods = freezed,Object? roles = freezed,Object? mediaVersions = freezed,Object? libraryId = freezed,Object? libraryTitle = freezed,Object? audioLanguage = freezed,Object? playlistItemId = freezed,Object? serverId = freezed,Object? serverName = freezed,Object? backendFolderKey = freezed,Object? raw = freezed,}) { return _then(JellyfinMediaItem( -id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +dialect: null == dialect ? _self.dialect : dialect // ignore: cast_nullable_to_non_nullable +as MediaBrowserDialect,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable as MediaKind,guid: freezed == guid ? _self.guid : guid // ignore: cast_nullable_to_non_nullable as String?,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 8dcafb82..a9fc3cf5 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -115,6 +115,43 @@ class ServerCapabilities { instantMix: true, ); + /// Defaults for an Emby server. + /// + /// `continueWatchingRemoval` is the one flag where Emby is ahead of Jellyfin: + /// `POST /Users/{uid}/Items/{id}/HideFromResume` drops an item from Continue + /// Watching while keeping its resume position, and Jellyfin 10.11 has no + /// equivalent route. + /// + /// Otherwise identical to [jellyfin] except [scrubThumbnails]: seek-bar + /// previews come + /// from Jellyfin's `/Videos/{id}/Trickplay` sprite sheets, which Emby (the + /// pre-fork ancestor) never gained — it 404s and never populates the + /// `Trickplay` item field. With the flag off the player never attempts the + /// load; see [MediaBrowserDialect.supportsTrickplay]. + /// + /// Emby does expose two *other* preview transports, `/Videos/{id}/index.bif` + /// (the same BIF format Plex uses, so [BifThumbnailService] could parse it) + /// and `/Items/{id}/ThumbnailSet`. Neither is wired: on Emby 4.9.5 both + /// answer 200 with an empty payload — a 72-byte header-only BIF and + /// `{"Thumbnails": []}` — even after a full metadata+image refresh, because + /// Emby only fills them once its own extraction task has run. Wiring them + /// needs a server that has actually generated the frames, so the flag stays + /// `false` rather than shipping a path that cannot be verified. + static const ServerCapabilities emby = ServerCapabilities( + liveTv: true, + liveTvDvr: false, + videoTranscoding: true, + richHubs: false, + numericUserRating: false, + userFavorites: true, + continueWatchingRemoval: true, + externalSubtitleSearch: false, + richMetadataEdit: true, + scrubThumbnails: false, + folderGrouping: true, + instantMix: true, + ); + /// Every flag here is fixed per backend *kind* except [videoTranscoding], /// which Plex probes per server (`PlexClient.capabilities`) — so that is the /// only override this type needs. Widen the parameter list if another flag diff --git a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart index ea40cbad..161fcec9 100644 --- a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart +++ b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart @@ -13,7 +13,7 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { JellyfinMetadataEditAdapter(this.client); @override - MediaBackend get backend => MediaBackend.jellyfin; + MediaBackend get backend => client.backend; @override MediaServerClient get mediaClient => client; @@ -26,7 +26,7 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { Future load(MediaItem item) async { final raw = await client.fetchEditableMetadataItem(item.id); if (raw == null) { - throw StateError('Editable Jellyfin metadata item is unavailable'); + throw StateError('Editable MediaBrowser metadata item is unavailable'); } final values = {}; _writeCommonValues(values, raw, item); @@ -56,8 +56,8 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { final dto = Map.from(raw); dto['ProviderIds'] = _stringMap(dto['ProviderIds']); - dto['Tags'] = metadataStringList(dto['Tags']); - dto['Genres'] = metadataStringList(dto['Genres']); + dto['Tags'] = _namedStringList(dto, 'Tags', 'TagItems'); + dto['Genres'] = _namedStringList(dto, 'Genres', 'GenreItems'); dto['People'] = _mapList(dto['People']); dto['Studios'] = _mapList(dto['Studios']); dto['LockedFields'] = metadataStringList(dto['LockedFields']); @@ -102,6 +102,17 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { } if (peopleChanged) dto['People'] = people; + // Emby ignores the plain `Genres`/`Tags` string lists on `POST /Items/{id}` + // and reads the `GenreItems`/`TagItems` name-pair arrays instead. The + // fetched DTO carries the *old* pairs, so mirroring unconditionally (not + // only when the field changed) is what keeps a save from silently + // reinstating the previous genres. See + // [MediaBrowserDialect.metadataWritesUseNamePairLists]. + if (client.dialect.metadataWritesUseNamePairLists) { + dto['GenreItems'] = _toNamePairs(dto['Genres']); + dto['TagItems'] = _toNamePairs(dto['Tags']); + } + final success = await client.updateMetadataItem(draft.sourceItem.id, dto); if (success) { draft.extras['raw'] = dto; @@ -178,12 +189,12 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { ? metadataFirstString(raw['Taglines']) : item.tagline ?? ''; values['summary'] = raw['Overview'] as String? ?? item.summary ?? ''; - values['genre'] = metadataStringList(raw['Genres']); + values['genre'] = _namedStringList(raw, 'Genres', 'GenreItems'); values['director'] = _peopleByType(raw['People'], 'Director'); values['writer'] = _peopleByType(raw['People'], 'Writer'); values['producer'] = _peopleByType(raw['People'], 'Producer'); values['country'] = metadataStringList(raw['ProductionLocations']); - values['label'] = metadataStringList(raw['Tags']); + values['label'] = _namedStringList(raw, 'Tags', 'TagItems'); } void _writeArtworkValues(Map values, MediaItem item) { @@ -242,6 +253,18 @@ List _nameList(Object? value) { .toList(); } +/// Effective value of a `Genres`/`Tags` style field, preferring the plain string +/// array and falling back to its `…Items` name-pair sibling. +/// +/// Emby never returns the plain `Tags` array at all — only `TagItems`, whatever +/// `Fields` the request asks for (measured on Emby 4.9.5). Reading the plain key +/// alone would show an empty tag editor for an item that has tags and, worse, +/// write that emptiness back on the next save. +List _namedStringList(Map dto, String key, String pairKey) { + final plain = metadataStringList(dto[key]); + return plain.isNotEmpty ? plain : _nameList(dto[pairKey]); +} + List _peopleByType(Object? value, String type) { return _mapList(value) .where((person) => (person['Type'] as String?)?.toLowerCase() == type.toLowerCase()) @@ -267,6 +290,13 @@ List> _replaceNamePairs(List> existing return names.map((name) => _preserveNamedMap(existing, used, name)).toList(); } +/// Project a `Genres`/`Tags` string list into the `[{'Name': …}]` shape Emby +/// requires on write. The server assigns the `Id` for a new entry, so omitting +/// it is correct — it resolves an existing tag by name and creates one when +/// there is no match. +List> _toNamePairs(Object? names) => + metadataStringList(names).map((name) => {'Name': name}).toList(); + Map _preserveNamedMap( List> existing, Set used, diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index 07083332..e80640b0 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -304,7 +304,7 @@ class ActiveProfileBinder { // Bind the implicit Plex Home parent and borrowed/extra join rows in // parallel. A slow/offline Plex parent should not add its timeout budget - // on top of an otherwise reachable Jellyfin or borrowed-server bind. + // on top of an otherwise reachable MediaBrowser or borrowed-server bind. final results = await Future.wait([ if (profile.isPlexHome) _bindPlexHome( @@ -315,8 +315,8 @@ class ActiveProfileBinder { generation: generation, ), // Both kinds also bind borrowed/extra connections via the join table. - // For plex_home this handles a Jellyfin server (or extra Plex account) - // that was attached to the profile via the borrow flow — the parent + // For plex_home this handles a MediaBrowser server (or extra Plex + // account) that was attached to the profile via the borrow flow — the // account is bound by `_bindPlexHome` above and isn't represented in // the join table. _bindJoinRows( @@ -548,7 +548,7 @@ class ActiveProfileBinder { ); case JellyfinConnection(): expected.add(conn.serverMachineId); - futures.add(_bindJellyfin(conn, profileId: profile.id, generation: generation)); + futures.add(_bindMediaBrowser(conn, profileId: profile.id, generation: generation)); } } final results = await Future.wait(futures); @@ -1001,7 +1001,7 @@ class ActiveProfileBinder { ); } - Future<_ProfileBindResult> _bindJellyfin( + Future<_ProfileBindResult> _bindMediaBrowser( JellyfinConnection conn, { required String profileId, required int generation, diff --git a/lib/profiles/profile_avatar_source.dart b/lib/profiles/profile_avatar_source.dart index d420e7e4..91b18484 100644 --- a/lib/profiles/profile_avatar_source.dart +++ b/lib/profiles/profile_avatar_source.dart @@ -80,6 +80,9 @@ bool _isEarlier(Connection candidate, Connection incumbent) { /// borrows a *specific* Home user (`userIdentifier`), and that user's live /// [PlexHomeUser.thumb] is the picture Plex shows for it. Reading the live /// cache also means the avatar tracks Plex's hourly refresh for free. +/// +/// Jellyfin and Emby use the same `/Users/{uid}/Images/Primary` route, so the +/// shared [JellyfinConnection] arm is dialect-agnostic. String? connectionAvatarUrl({ required Connection connection, required ProfileConnection link, diff --git a/lib/profiles/profile_connection.dart b/lib/profiles/profile_connection.dart index 471f4447..3d726481 100644 --- a/lib/profiles/profile_connection.dart +++ b/lib/profiles/profile_connection.dart @@ -11,8 +11,9 @@ part 'profile_connection.freezed.dart'; /// `ActiveProfileBinder` performs the switch on first activation and /// caches the resulting token back into this row. /// -/// For Jellyfin: [userToken] mirrors the Connection's accessToken (one -/// user per connection); [userIdentifier] is the Jellyfin user id. +/// For the MediaBrowser backends (Jellyfin and Emby): [userToken] mirrors the +/// Connection's accessToken (one user per connection); [userIdentifier] is the +/// server-side user id. @freezed sealed class ProfileConnection with _$ProfileConnection { const ProfileConnection._(); diff --git a/lib/profiles/profile_connection_cleanup.dart b/lib/profiles/profile_connection_cleanup.dart index 8328baa9..e8d48afd 100644 --- a/lib/profiles/profile_connection_cleanup.dart +++ b/lib/profiles/profile_connection_cleanup.dart @@ -89,7 +89,7 @@ class ProfileConnectionCleanup { /// Sign out of a Plex account: remove the account [Connection], every join /// row referencing it, and everything owned by its virtual Plex Home - /// profiles — including borrowed Jellyfin connections left unreferenced, + /// profiles — including borrowed MediaBrowser connections left unreferenced, /// which previously survived as orphans and wedged the session (#1423). /// /// Pass a read-only [plannedRemoval] from @@ -131,9 +131,9 @@ class ProfileConnectionCleanup { /// In-session mirror of the boot guard (`main.dart`: "stored connections /// exist but no profiles resolved — returning to auth"): prune orphaned - /// Jellyfin connections, then decide whether any selectable profile remains. - /// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed - /// accounts are harmless because the connection map is re-read here. + /// MediaBrowser connections, then decide whether any selectable profile + /// remains. [plexHomeUsers] is [PlexHomeService.current]; stale entries for + /// removed accounts are harmless because the connection map is re-read here. Future<({PostRemovalRoute route, List profiles})> resolvePostRemovalState({ required ProfileRegistry profileRegistry, required Map> plexHomeUsers, @@ -152,6 +152,8 @@ class ProfileConnectionCleanup { return (route: PostRemovalRoute.staySignedIn, profiles: merged); } + /// The Jellyfin-era method name is retained for existing callers; + /// [JellyfinConnection] represents both Jellyfin and Emby. Future pruneUnreferencedJellyfinConnections() async { final all = await connections.list(); final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index d7157e3c..6b6adcd7 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -306,7 +306,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin ), ); case JellyfinConnection(): - addContext(await _createJellyfinAuthContext(connection: connection)); + addContext(await _createMediaBrowserAuthContext(connection: connection)); } } @@ -355,7 +355,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin return RemoteAuthContext( id: auth.computeAuthContextId(homeSecret), - backend: 'plex', + backend: account.kind.id, connectionId: account.id, homeSecret: homeSecret, discoveryKey: await auth.deriveDiscoveryKey(homeSecret), @@ -365,9 +365,9 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin ); } - Future _createJellyfinAuthContext({required JellyfinConnection connection}) async { + Future _createMediaBrowserAuthContext({required JellyfinConnection connection}) async { if (connection.accessToken.isEmpty || connection.userId.isEmpty || connection.serverMachineId.isEmpty) { - appLogger.w('CompanionRemote: Skipping Jellyfin remote identity — incomplete connection ${connection.id}'); + appLogger.w('CompanionRemote: Skipping MediaBrowser remote identity — incomplete connection ${connection.id}'); return null; } @@ -378,7 +378,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin ); return RemoteAuthContext( id: auth.computeAuthContextId(homeSecret), - backend: 'jellyfin', + backend: connection.kind.id, connectionId: connection.id, homeSecret: homeSecret, discoveryKey: await auth.deriveDiscoveryKey(homeSecret), diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 8ef3e117..ecdb0c6a 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -403,7 +403,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Load all downloads from database final downloads = await _downloadManager.getAllDownloads(); - // Bulk-load all pinned metadata across both backends in a single pass + // Bulk-load all pinned metadata across every backend in a single pass // instead of per-item DB calls. final allMetadata = await _downloadManager.getAllPinnedMetadata( preferActiveScope: true, @@ -637,10 +637,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Fallback: synthesize from episode metadata (missing year, summary) // Only Plex consumers read `raw['key']` (library-section + folder // navigation), so we synthesize the Plex URI for Plex shows and - // emit a Jellyfin-shaped item for Jellyfin (Id + Type=Series). + // emit a MediaBrowser-shaped item for Jellyfin or Emby + // (`Id` + `Type=Series`). final synthesizedRaw = switch (meta.backend) { MediaBackend.plex => {'key': '/library/metadata/$showRatingKey'}, - MediaBackend.jellyfin => {'Id': showRatingKey, 'Type': 'Series'}, + MediaBackend.jellyfin || MediaBackend.emby => {'Id': showRatingKey, 'Type': 'Series'}, }; shows[showRatingKey] = MediaItem( id: showRatingKey, @@ -1376,7 +1377,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Queue every track under an album/artist. Expansion is one /// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants]) on - /// both backends — Plex branches album→/children, Jellyfin retries + /// every backend — Plex branches album→/children, while MediaBrowser retries /// tag-only artists by album-artist credit. Future _queueMusicContainerDownload( MediaItem container, diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index e31560dd..140c23d6 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -222,7 +222,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Whether at least one online server is a Plex server. Used to gate /// Plex-only chrome (server-activities popover, conflict-resolution - /// helpers) so they don't render against a Jellyfin-only profile. + /// helpers) so it doesn't render against a MediaBrowser-only profile. bool get hasOnlinePlexServers => onlineServerIds.any((id) => _serverManager.getPlexClient(ServerId(id)) != null); /// Visibility-filtered server ids whose latest health probe was rejected @@ -263,10 +263,9 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Check all online servers for DVR/Live TV availability. Plex servers /// expose `/livetv/dvrs` (one entry per configured DVR with its own - /// lineup); Jellyfin servers expose `/LiveTv/Channels` with a single + /// lineup); MediaBrowser servers expose `/LiveTv/Channels` with a single /// flat channel list per server (synthesized into one [LiveTvServerInfo] - /// with `dvrKey: 'jellyfin'` so the rest of the UI's per-DVR loop works - /// uniformly). + /// whose backend-derived `dvrKey` keeps the UI's per-DVR identity stable). Future checkLiveTvAvailability() async { if (isDisposed) return; final generation = ++_liveTvCheckGeneration; @@ -285,9 +284,11 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: dvr.key, lineup: dvr.lineup, dvrs: dvrs)); } } else if (await liveTv.isAvailable()) { - // Jellyfin: no per-DVR partitioning; synthesize a single entry so - // the rest of the UI's per-DVR loop works uniformly. - newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: 'jellyfin', lineup: null, dvrs: const [])); + // MediaBrowser: no per-DVR partitioning; synthesize a single entry + // so the rest of the UI's per-DVR loop works uniformly. + newLiveTvServers.add( + LiveTvServerInfo(serverId: serverId, dvrKey: genericClient.backend.id, lineup: null, dvrs: const []), + ); } } catch (e) { appLogger.d('LiveTV check failed for server $serverId', error: e); diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index 141d56ff..c038b2de 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -19,8 +19,8 @@ import '../utils/app_logger.dart'; /// Holds the *current user's playback preferences* (audio/subtitle language /// defaults) for the active profile. Plex profiles fetch from -/// `https://clients.plex.tv/api/v2/user`; Jellyfin profiles fetch from -/// `/Users/Me` on the bound Jellyfin server. +/// `https://clients.plex.tv/api/v2/user`; MediaBrowser profiles use their +/// dialect's current-user route on the bound server. /// /// Profile *identity* and *switching* are owned by [ActiveProfileProvider] /// and [ActiveProfileBinder]. This provider is just the settings cache so @@ -172,12 +172,12 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi final settingsConnection = await _resolveActiveSettingsConnection(); final connection = settingsConnection?.connection; if (connection is JellyfinConnection) { - final jellyfinClient = _resolveJellyfinClient(connection); - if (jellyfinClient == null) { - appLogger.d('UserProfileProvider: default Jellyfin client unavailable, skipping settings refresh'); + final mediaBrowserClient = _resolveMediaBrowserClient(connection); + if (mediaBrowserClient == null) { + appLogger.d('UserProfileProvider: default MediaBrowser client unavailable, skipping settings refresh'); return; } - final profile = await jellyfinClient.fetchUserProfile(); + final profile = await mediaBrowserClient.fetchUserProfile(); if (profile != null && !stale()) { _profileSettings = profile; safeNotifyListeners(); @@ -202,7 +202,7 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi } } - JellyfinClient? _resolveJellyfinClient(JellyfinConnection conn) { + JellyfinClient? _resolveMediaBrowserClient(JellyfinConnection conn) { final manager = _serverManager; if (manager == null) return null; final client = manager.getClient(ServerId(conn.serverMachineId)); diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 5b6f0978..96645111 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -24,6 +24,7 @@ import '../focus/focusable_button.dart'; import '../focus/focusable_text_field.dart'; import '../focus/key_event_utils.dart'; import '../media/media_backend.dart'; +import '../media/media_browser_dialect.dart'; import '../navigation/profile_session_screen.dart'; import '../utils/navigation_transitions.dart'; import '../widgets/backend_badge.dart'; @@ -231,10 +232,13 @@ class _AuthScreenState extends State { _showDebugTokenDialog(); } - Future _connectToJellyfin() async { + Future _connectToMediaBrowser(MediaBrowserDialect dialect) async { if (!await _prepareDatabaseRecoveryForSignIn()) return; if (!mounted) return; - final added = await Navigator.push(context, MaterialPageRoute(builder: (_) => const AddJellyfinScreen())); + final added = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => AddJellyfinScreen(dialect: dialect)), + ); if (!mounted || added != true) return; // The connection persisted and the manager registered the client; move // straight to the main screen. [MainScreen] reads the active client @@ -350,6 +354,10 @@ class _AuthScreenState extends State { final isAppleTV = PlatformDetector.isAppleTV(); void startBrowserAfterRecovery() => unawaited(_startPlexAfterRecovery(startBrowser)); void startQrAfterRecovery() => unawaited(_startPlexAfterRecovery(startQr)); + const jellyfinDialect = MediaBrowserDialect.jellyfin; + const embyDialect = MediaBrowserDialect.emby; + void connectToJellyfin() => unawaited(_connectToMediaBrowser(jellyfinDialect)); + void connectToEmby() => unawaited(_connectToMediaBrowser(embyDialect)); return Column( mainAxisSize: .min, crossAxisAlignment: .stretch, @@ -423,12 +431,22 @@ class _AuthScreenState extends State { ), const SizedBox(height: 12), FocusableButton( - onPressed: _connectToJellyfin, + onPressed: connectToJellyfin, child: OutlinedButton.icon( - onPressed: _connectToJellyfin, + onPressed: connectToJellyfin, style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18), - label: Text(t.auth.connectToJellyfin), + label: Text(t.auth.connectToMediaBrowser(product: jellyfinDialect.productName)), + ), + ), + const SizedBox(height: 12), + FocusableButton( + onPressed: connectToEmby, + child: OutlinedButton.icon( + onPressed: connectToEmby, + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + icon: const BackendBadge(backend: MediaBackend.emby, size: 18), + label: Text(t.auth.connectToMediaBrowser(product: embyDialect.productName)), ), ), if (kDebugMode) ...[ diff --git a/lib/screens/catalog_item_detail_screen.dart b/lib/screens/catalog_item_detail_screen.dart index ef2aecc0..0d9868c6 100644 --- a/lib/screens/catalog_item_detail_screen.dart +++ b/lib/screens/catalog_item_detail_screen.dart @@ -549,14 +549,14 @@ class _CatalogItemDetailScreenState extends State { } Widget _buildLibraryMatchTile(MediaItem match, int index) { - // Plex matches carry their library title; Jellyfin's search-based lookup + // Plex matches carry their library title; MediaBrowser search-based lookup // only does when the ancestors call succeeded, so fall back to the server // name alone. The subtitle carries whatever else tells two copies apart. final details = [?_libraryMatchQuality(match), ?(match.libraryTitle == null ? null : match.serverName)]; return FocusableListTile( focusNode: _libraryMatchFocusNodes[index], leading: BackendBadge(backend: match.backend, size: 24), - title: Text(match.libraryTitle ?? match.serverName ?? match.backend.name), + title: Text(match.libraryTitle ?? match.serverName ?? match.backend.dialect?.productName ?? 'Plex'), subtitle: details.isEmpty ? null : Text(details.join(' • ')), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => unawaited(navigateToMediaItemDetails(context, match)), diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 94f94659..c351fca4 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -5,7 +5,6 @@ import '../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../media/library_query.dart'; -import '../media/media_backend.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; @@ -276,7 +275,7 @@ class _HubDetailScreenState extends State } bool _shouldUsePaginatedLoader(MediaServerClient client) => - client.backend == MediaBackend.jellyfin && widget.hub.id.endsWith('.recent'); + client.backend.usesMediaBrowserApi && widget.hub.id.endsWith('.recent'); @override Future> fetchPage(int start, int size, AbortController? abort) async { @@ -358,7 +357,7 @@ class _HubDetailScreenState extends State _applySort(); if (!usesCustomLoader && !_usesPaginatedLoader && client != null && loadedCount < totalCount) { - _replaceContinuationItems = client.backend == MediaBackend.plex; + _replaceContinuationItems = !client.backend.usesMediaBrowserApi; if (_replaceContinuationItems) { _continuation.setContinuation(startIndex: 0, totalCount: 1); } else { diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index f08ca328..762392c7 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -52,7 +52,7 @@ class FolderTreeViewState extends State { /// Folders/items returned by the backend's folder API and mapped to neutral /// [MediaItem]s. Plex folder URLs survive in [MediaItem.raw]['key']; - /// Jellyfin folders use the item id as their recursive parent id. + /// MediaBrowser folders use the item id as their recursive parent id. List _rootFolders = []; final Map> _childrenCache = {}; final Set _expandedFolders = {}; @@ -60,9 +60,9 @@ class FolderTreeViewState extends State { bool _isLoadingRoot = false; String? _errorMessage; - /// Generation counter for in-flight loads. Jellyfin folder fetches render - /// page-by-page via `onPage`; a root reload or deletion refresh bumps the - /// epoch so superseded pagination callbacks are dropped. + /// Generation counter for in-flight loads. MediaBrowser folder fetches + /// render page-by-page via `onPage`; a root reload or deletion refresh + /// bumps the epoch so superseded pagination callbacks are dropped. int _loadEpoch = 0; /// Stable expand/cache key for an expandable row: the backend folder key @@ -250,7 +250,7 @@ class FolderTreeViewState extends State { /// [widget.serverId], not `forItem`'s fall-back-to-any-online resolution. Future _launchFolder(MediaItem folder, {required bool shuffle}) async { final MediaListPlaybackLauncher launcher; - if (folder.backend == MediaBackend.jellyfin) { + if (folder.backend.usesMediaBrowserApi) { launcher = JellyfinSequentialLauncher(context: context); } else { final client = context.getPlexClientForServer(ServerId(widget.serverId!)); @@ -259,22 +259,22 @@ class FolderTreeViewState extends State { await launcher.launchFromFolder(folder: folder, shuffle: shuffle); } - /// Expandable rows: directory rows plus Jellyfin media containers whose + /// Expandable rows: directory rows plus MediaBrowser media containers whose /// direct children form the folder tree. Music libraries expose folder- /// backed artists and albums as MusicArtist/MusicAlbum rather than generic /// Folder DTOs, so those rows must expand instead of opening empty details. bool _isExpandable(MediaItem item) { - return item.kind == MediaKind.folder || (item.backend == MediaBackend.jellyfin && _isJellyfinMediaContainer(item)); + return item.kind == MediaKind.folder || (item.backend.usesMediaBrowserApi && _isMediaBrowserMediaContainer(item)); } - bool _isJellyfinMediaContainer(MediaItem item) { + bool _isMediaBrowserMediaContainer(MediaItem item) { if (item.kind == MediaKind.show || item.kind == MediaKind.season) return true; return widget.libraryKind?.isMusic == true && (item.kind == MediaKind.artist || item.kind == MediaKind.album); } bool _canPlayFolder(MediaItem item) { if (item.backend == MediaBackend.plex) return true; - if (item.backend == MediaBackend.jellyfin) return widget.libraryKind?.isMusic != true; + if (item.backend.usesMediaBrowserApi) return widget.libraryKind?.isMusic != true; return false; } diff --git a/lib/screens/libraries/library_alpha_bar_strategy.dart b/lib/screens/libraries/library_alpha_bar_strategy.dart index a6dbc30a..c45da90f 100644 --- a/lib/screens/libraries/library_alpha_bar_strategy.dart +++ b/lib/screens/libraries/library_alpha_bar_strategy.dart @@ -10,10 +10,10 @@ import 'alpha_jump_helper.dart'; /// driven — tapping a letter scrolls to that letter's cumulative offset and /// the highlighted letter follows the visible row. /// -/// Jellyfin libraries have no per-letter count endpoint. The bar synthesises -/// the 27-letter alphabet (`#`, `A`–`Z`) and acts as a name-prefix filter -/// that refetches the page when the user picks a letter (matches the JF web -/// client's UX). +/// MediaBrowser libraries have no per-letter count endpoint. The bar +/// synthesises the 27-letter alphabet (`#`, `A`–`Z`) and acts as a name-prefix +/// filter that refetches the page when the user picks a letter (matching the +/// server web clients' UX). abstract class LibraryAlphaBarStrategy { /// Whether the bar should be rendered at all. Implementations consider /// total item count, sort key, and current filter state. @@ -22,7 +22,7 @@ abstract class LibraryAlphaBarStrategy { required int loadedCharacterCount, required String? sortKey, required bool isFolderGrouping, - required String? jellyfinAlphaPrefix, + required String? mediaBrowserAlphaPrefix, required bool isPhone, }); @@ -36,22 +36,22 @@ abstract class LibraryAlphaBarStrategy { }); /// Letter to highlight given the current scroll-derived index. Plex maps - /// the index back through the cumulative offsets; Jellyfin echoes back - /// whatever filter is active. - String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix}); + /// the index back through the cumulative offsets; MediaBrowser backends echo + /// back whatever filter is active. + String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix}); /// Handle a tap on the letter at [targetIndex]. Plex strategies invoke /// [onPlexJump] with the cumulative item index for in-grid scrolling; - /// Jellyfin strategies invoke [onJellyfinPrefixChange] with the next + /// MediaBrowser strategies invoke [onMediaBrowserPrefixChange] with the next /// `NameStartsWith` prefix (or `null` to clear the filter when the user /// re-taps the active letter). Each strategy ignores the callback that /// doesn't apply to its UX, so callers can wire both unconditionally. void onLetterPressed( int targetIndex, AlphaJumpHelper helper, { - required String? currentJellyfinPrefix, + required String? currentMediaBrowserPrefix, required void Function(int index) onPlexJump, - required void Function(String? nextPrefix) onJellyfinPrefixChange, + required void Function(String? nextPrefix) onMediaBrowserPrefixChange, }); /// Construct the right strategy for [backend]. @@ -67,7 +67,7 @@ abstract class LibraryAlphaBarStrategy { libraryKey: libraryKey, isShared: isShared, ), - MediaBackend.jellyfin => const JellyfinAlphaBarStrategy(), + MediaBackend.jellyfin || MediaBackend.emby => const MediaBrowserAlphaBarStrategy(), }; } } @@ -88,7 +88,7 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy { required int loadedCharacterCount, required String? sortKey, required bool isFolderGrouping, - required String? jellyfinAlphaPrefix, + required String? mediaBrowserAlphaPrefix, required bool isPhone, }) { if (isFolderGrouping) return false; @@ -115,7 +115,8 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy { } @override - String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix}) => helper.currentLetter(index); + String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix}) => + helper.currentLetter(index); /// Plex jumps the grid to the cumulative offset for the tapped letter — /// the helper's letter list already encodes the per-letter ranges from @@ -124,17 +125,17 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy { void onLetterPressed( int targetIndex, AlphaJumpHelper helper, { - required String? currentJellyfinPrefix, + required String? currentMediaBrowserPrefix, required void Function(int index) onPlexJump, - required void Function(String? nextPrefix) onJellyfinPrefixChange, + required void Function(String? nextPrefix) onMediaBrowserPrefixChange, }) { onPlexJump(targetIndex); } } -/// Jellyfin strategy — synthesises the 27-letter alphabet locally and uses -/// the bar as a `NameStartsWith` filter. -class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy { +/// MediaBrowser strategy — synthesises the 27-letter alphabet locally and +/// uses the bar as a `NameStartsWith` filter. +class MediaBrowserAlphaBarStrategy implements LibraryAlphaBarStrategy { static const _letters = [ '#', 'A', @@ -165,7 +166,7 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy { 'Z', ]; - const JellyfinAlphaBarStrategy(); + const MediaBrowserAlphaBarStrategy(); @override bool shouldShow({ @@ -173,13 +174,13 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy { required int loadedCharacterCount, required String? sortKey, required bool isFolderGrouping, - required String? jellyfinAlphaPrefix, + required String? mediaBrowserAlphaPrefix, required bool isPhone, }) { if (isPhone) return false; if (isFolderGrouping) return false; if (loadedCharacterCount == 0) return false; - return totalItemCount >= 80 || jellyfinAlphaPrefix != null; + return totalItemCount >= 80 || mediaBrowserAlphaPrefix != null; } @override @@ -193,23 +194,24 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy { } @override - String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix}) => jellyfinAlphaPrefix ?? ''; + String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix}) => + mediaBrowserAlphaPrefix ?? ''; - /// Jellyfin reuses the alpha bar as a `NameStartsWith` filter. We map the - /// bar offset back to a letter (the synthesised `size: 1` entries make - /// offset == position in [helper.letters]) and toggle the filter — re-tap - /// the active letter to clear, otherwise set the new prefix. + /// MediaBrowser backends reuse the alpha bar as a `NameStartsWith` filter. + /// We map the bar offset back to a letter (the synthesised `size: 1` entries + /// make offset == position in [helper.letters]) and toggle the filter — + /// re-tap the active letter to clear, otherwise set the new prefix. @override void onLetterPressed( int targetIndex, AlphaJumpHelper helper, { - required String? currentJellyfinPrefix, + required String? currentMediaBrowserPrefix, required void Function(int index) onPlexJump, - required void Function(String? nextPrefix) onJellyfinPrefixChange, + required void Function(String? nextPrefix) onMediaBrowserPrefixChange, }) { if (targetIndex < 0 || targetIndex >= helper.letters.length) return; final letter = helper.letters[targetIndex]; - final next = (currentJellyfinPrefix == letter) ? null : letter; - onJellyfinPrefixChange(next); + final next = (currentMediaBrowserPrefix == letter) ? null : letter; + onMediaBrowserPrefixChange(next); } } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 6652e02c..94a69f88 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -6,7 +6,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../../media/library_first_character.dart'; import '../../../media/library_query.dart'; -import '../../../media/media_backend.dart'; import '../../../media/media_item.dart'; import '../../../media/media_kind.dart'; import '../../../media/media_library.dart'; @@ -211,15 +210,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> _jellyfinFilterValues = const {}; + Map> _mediaBrowserFilterValues = const {}; final ValueNotifier _currentFirstVisibleIndex = ValueNotifier(0); LibraryAlphaScrollMetrics _scrollMetrics = LibraryAlphaScrollMetrics.empty; @@ -257,7 +255,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState widget.library.backend == MediaBackend.jellyfin; - int get _activeFetchSize => _isJellyfinLibrary ? _jellyfinFetchSize : _fetchSize; + bool get _isMediaBrowserLibrary => widget.library.backend.usesMediaBrowserApi; + int get _activeFetchSize => _isMediaBrowserLibrary ? _mediaBrowserFetchSize : _fetchSize; // Focus nodes for filter chips final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip'); @@ -518,9 +516,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState client); @@ -536,10 +534,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState - _alphaStrategy.currentLetter(index, _alphaHelper, jellyfinAlphaPrefix: _jellyfinAlphaPrefix); + _alphaStrategy.currentLetter(index, _alphaHelper, mediaBrowserAlphaPrefix: _mediaBrowserAlphaPrefix); /// Whether the alpha jump bar should be shown. /// Only shown when sorting by title (titleSort) and not in folders mode. @@ -1233,7 +1234,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState[ _BackendOption( backend: MediaBackend.plex, @@ -40,11 +43,25 @@ class AddConnectionScreen extends StatelessWidget { ), _BackendOption( backend: MediaBackend.jellyfin, - title: t.addServer.connectToJellyfinCard, + title: t.addServer.connectToMediaBrowserCard(product: jellyfinDialect.productName), subtitle: scoped - ? t.addServer.connectToJellyfinCardSubtitleScoped(name: targetProfile!.displayName) - : t.addServer.connectToJellyfinCardSubtitle, - builder: (_) => AddJellyfinScreen(targetProfile: targetProfile), + ? t.addServer.connectToMediaBrowserCardSubtitleScoped( + product: jellyfinDialect.productName, + name: targetProfile!.displayName, + ) + : t.addServer.connectToMediaBrowserCardSubtitle, + builder: (_) => AddJellyfinScreen(targetProfile: targetProfile, dialect: jellyfinDialect), + ), + _BackendOption( + backend: MediaBackend.emby, + title: t.addServer.connectToMediaBrowserCard(product: embyDialect.productName), + subtitle: scoped + ? t.addServer.connectToMediaBrowserCardSubtitleScoped( + product: embyDialect.productName, + name: targetProfile!.displayName, + ) + : t.addServer.connectToMediaBrowserCardSubtitle, + builder: (_) => AddJellyfinScreen(targetProfile: targetProfile, dialect: embyDialect), ), if (scoped) _BackendOption( diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 8f029d97..bad0f8e2 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -14,6 +14,7 @@ import '../../focus/focusable_button.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; +import '../../media/media_browser_dialect.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../profiles/active_profile_binder.dart'; import '../../profiles/active_profile_provider.dart'; @@ -69,26 +70,28 @@ bool shouldPromptForJellyfinProfileSelection({ return targetProfile == null && activeProfile == null && hasProfiles; } -/// Three-step form to add a Jellyfin server: +/// Three-step form to add a Jellyfin or Emby server: /// 1. Probe URL candidates (`/System/Info/Public`). -/// 2. Username + password (`/Users/AuthenticateByName`) **or** Quick Connect -/// (`/QuickConnect/Initiate` → poll → `/Users/AuthenticateWithQuickConnect`). +/// 2. Username + password (`/Users/AuthenticateByName`) or Quick Connect +/// when supported by the selected [dialect]. /// 3. Persist via [ConnectionRegistry] and create a [ProfileConnection] /// row binding the server to [targetProfile] (or the active profile, /// if not provided). When the target *is* the active profile we also /// register the client with the manager so libraries refresh /// immediately; otherwise the binder picks it up on the next switch. class AddJellyfinScreen extends StatefulWidget { - /// When set, the new Jellyfin connection is bound to this profile via a + /// When set, the new MediaBrowser connection is bound to this profile via a /// [ProfileConnection] row. When null, falls back to the currently active /// profile (typical for the global Connections screen entry point). final Profile? targetProfile; + final MediaBrowserDialect dialect; final FutureOr Function()? _authServiceFactory; final FutureOr> Function()? _localDiscoveryFactory; const AddJellyfinScreen({ super.key, this.targetProfile, + this.dialect = MediaBrowserDialect.jellyfin, @visibleForTesting this._authServiceFactory, @visibleForTesting this._localDiscoveryFactory, }); @@ -157,7 +160,10 @@ class _AddJellyfinScreenState extends State with AsyncFormSta final factory = widget._localDiscoveryFactory; final servers = factory != null ? await factory() - : await JellyfinLanDiscoveryService().discover(responseWindow: const Duration(milliseconds: 1300)); + : await JellyfinLanDiscoveryService().discover( + dialect: widget.dialect, + responseWindow: const Duration(milliseconds: 1300), + ); if (!mounted || attemptId != _localDiscoveryAttemptId) return; setState(() { _localServers = servers; @@ -165,7 +171,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta _syncDiscoveredServerFocusNodes(servers); }); } catch (e, st) { - appLogger.w('Add Jellyfin local discovery failed', error: e, stackTrace: st); + appLogger.w('Add ${widget.dialect.productName} local discovery failed', error: e, stackTrace: st); if (!mounted || attemptId != _localDiscoveryAttemptId) return; setState(() => _isDiscoveringLocalServers = false); } @@ -201,9 +207,9 @@ class _AddJellyfinScreenState extends State with AsyncFormSta } Future _probe() async { - final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls()); + final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls(), dialect: widget.dialect); if (input.probeBaseUrls.isEmpty) { - setErrorText(t.addServer.enterJellyfinUrlError); + setErrorText(t.addServer.enterMediaBrowserUrlError(product: widget.dialect.productName)); return; } final autoStartQuickConnect = await runAsync( @@ -214,7 +220,10 @@ class _AddJellyfinScreenState extends State with AsyncFormSta baseUrlsToPersist: input.explicitBaseUrls, baseUrlValidationGroups: input.validationBaseUrlGroups, ); - final qcEnabled = await auth.isQuickConnectEnabled(endpoint.activeBaseUrl); + final serverDialect = endpoint.serverInfo.dialect ?? widget.dialect; + final qcEnabled = widget.dialect.supportsQuickConnect && serverDialect.supportsQuickConnect + ? await auth.isQuickConnectEnabled(endpoint.activeBaseUrl) + : false; if (!mounted) return false; setState(() { _serverEndpoint = endpoint; @@ -268,7 +277,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta }, errorMapper: (e) { if (e is MediaServerAuthException) return e.message; - appLogger.e('Add Jellyfin failed', error: e); + appLogger.e('Add ${widget.dialect.productName} failed', error: e); return t.addServer.signInFailed(error: e.toString()); }, ); @@ -278,6 +287,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta final info = _serverInfo; final endpoint = _serverEndpoint; if (info == null || endpoint == null) return; + if (!widget.dialect.supportsQuickConnect || !(info.dialect ?? widget.dialect).supportsQuickConnect) return; final attemptId = ++_qcAttemptId; setState(() => _qcCancelled = false); await runAsync( @@ -440,7 +450,12 @@ class _AddJellyfinScreenState extends State with AsyncFormSta if (authServiceFactory != null) return await authServiceFactory(); final clientVersion = await resolveJellyfinClientVersion(); final deviceName = await _resolveDeviceName(); - return JellyfinConnectionAuthService(clientName: 'Plezy', clientVersion: clientVersion, deviceName: deviceName); + return JellyfinConnectionAuthService( + clientName: 'Plezy', + clientVersion: clientVersion, + deviceName: deviceName, + dialect: widget.dialect, + ); } /// The raw name, not a header-sanitized one: the Jellyfin `MediaBrowser` @@ -455,9 +470,9 @@ class _AddJellyfinScreenState extends State with AsyncFormSta Widget build(BuildContext context) { final theme = Theme.of(context); return FocusedScrollScaffold( - title: Text(t.addServer.addJellyfinTitle), + title: Text(t.addServer.addMediaBrowserTitle(product: widget.dialect.productName)), slivers: [ - if (_qcInitiation != null) + if (widget.dialect.supportsQuickConnect && _qcInitiation != null) SliverFillRemaining( hasScrollBody: false, child: Padding( @@ -509,7 +524,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta decoration: InputDecoration( labelText: t.addServer.serverUrls, // URL example — intentionally not localized. - hintText: 'https://jellyfin.example.com', + hintText: widget.dialect.exampleBaseUrl, helperText: _serverInfo == null ? t.addServer.serverUrlsHelper : null, prefixIcon: const AppIcon(Symbols.link_rounded, fill: 1), ), @@ -560,7 +575,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta labelText: t.addServer.password, prefixIcon: const AppIcon(Symbols.lock_rounded, fill: 1), ), - // Empty password is valid for some Jellyfin setups, so don't + // Empty passwords are valid on some MediaBrowser servers, so don't // require a value. ), const SizedBox(height: 16), @@ -574,7 +589,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta label: Text(t.addServer.signIn), ), ), - if (_quickConnectEnabled) ...[ + if (widget.dialect.supportsQuickConnect && _quickConnectEnabled) ...[ const SizedBox(height: 12), FocusableButton( focusNode: _quickConnectFocus, @@ -609,7 +624,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta children: [ Text(_serverInfo!.serverName, style: theme.textTheme.titleSmall), Text( - 'Jellyfin ${_serverInfo!.version}', + '${widget.dialect.productName} ${_serverInfo!.version}', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)), ), ], @@ -649,7 +664,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta const SizedBox(width: 10), Expanded( child: Text( - t.addServer.searchingLocalServers, + t.addServer.searchingLocalMediaBrowserServers(product: widget.dialect.productName), style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)), ), ), @@ -662,7 +677,10 @@ class _AddJellyfinScreenState extends State with AsyncFormSta final tokensRef = tokens(context); return [ const SizedBox(height: 16), - Text(t.addServer.localServers, style: theme.textTheme.titleSmall), + Text( + t.addServer.localMediaBrowserServers(product: widget.dialect.productName), + style: theme.textTheme.titleSmall, + ), const SizedBox(height: 8), for (final (i, server) in _localServers.indexed) ...[ if (i > 0) SizedBox(height: tokensRef.groupGap), diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index 008af827..22fc7f02 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -43,8 +43,11 @@ class _EditJellyfinConnectionScreenState extends State( () async { - final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls()); - final endpoint = await JellyfinEndpointDiscovery().raceEndpoints( + final input = JellyfinEndpointDiscovery.buildUserInputCandidates( + _enteredUrls(), + dialect: widget.connection.dialect, + ); + final endpoint = await JellyfinEndpointDiscovery(dialect: widget.connection.dialect).raceEndpoints( input.probeBaseUrls, preferredUrl: widget.connection.baseUrl, expectedMachineId: widget.connection.serverMachineId, @@ -63,7 +66,7 @@ class _EditJellyfinConnectionScreenState extends State { - ApiCacheSingleton(this.backend, this.typeName); + ApiCacheSingleton(this.backends, this.typeName); - final MediaBackend backend; + /// Every backend this cache answers for. Jellyfin and Emby share one + /// instance: their DTO shapes are identical and cache rows are keyed by the + /// compound `machineId/userId` scope, so there is nothing to isolate. + final Set backends; final String typeName; T? _instance; @@ -37,7 +40,7 @@ class ApiCacheSingleton { void install(T instance) { _instance = instance; - ApiCache.registerInstance(backend, instance); + ApiCache.registerInstance(instance, backends); } } @@ -65,14 +68,17 @@ Map decodeCachedMediaRows( abstract class ApiCache { static final Map _byBackend = {}; - /// Registers a backend cache. A new database marks a new application/test - /// lifecycle, so registrations tied to the previous database are discarded - /// instead of leaving backend dispatch pointed at a closed connection. - static void registerInstance(MediaBackend backend, ApiCache cache) { - if (_byBackend.values.any((registered) => !identical(registered.database, cache.database))) { + /// Registers [instance] for each requested backend. A new database marks a + /// new application/test lifecycle, so registrations tied to the previous + /// database are discarded instead of leaving backend dispatch pointed at a + /// closed connection. + static void registerInstance(ApiCache instance, Set backends) { + if (_byBackend.values.any((registered) => !identical(registered.database, instance.database))) { _byBackend.clear(); } - _byBackend[backend] = cache; + for (final backend in backends) { + _byBackend[backend] = instance; + } } /// Pick the cache for [backend]. Plex is the legacy default — covers items @@ -169,7 +175,7 @@ abstract class ApiCache { /// Pull pinned rows for [serverId] and extract the first capture group of /// [keyPattern] from each `cacheKey`. Returns the unique set of captured /// ids — backend subclasses use this to enumerate their pinned items - /// (Plex ratingKeys, Jellyfin item ids). + /// (Plex ratingKeys, MediaBrowser item ids). Future> extractPinnedIds(ServerId serverId, RegExp keyPattern) async { final rows = await (_db.select( _db.apiCache, @@ -215,7 +221,7 @@ abstract class ApiCache { /// [itemId] so reloads (`getMetadata` / `getAllPinnedMetadata`) reflect the /// state without having to refetch from the server. No-op when the row /// isn't cached. Backend subclasses know which JSON fields to mutate - /// (Plex `viewCount`, Jellyfin `UserData.PlayCount` / `Played`). + /// (Plex `viewCount`, MediaBrowser `UserData.PlayCount` / `Played`). /// /// Optional positional progress fields ([viewOffsetMs], [lastViewedAt], /// [viewedLeafCount]) let the offline-watch-sync service mirror richer @@ -228,7 +234,7 @@ abstract class ApiCache { /// shape + units are not. Adding a new watch-state input here means /// updating *both* concrete impls ([PlexApiCache.applyWatchState], /// [JellyfinApiCache.applyWatchState]) — Plex stores epoch-seconds and - /// flat fields, Jellyfin stores ISO-8601 + ticks under `UserData`. + /// flat fields, while Jellyfin and Emby store ISO-8601 + ticks under `UserData`. /// The mutations are too short (~3 lines per backend) for a shared /// adapter to be a net win, so they live duplicated by design. Future applyWatchState({ diff --git a/lib/services/cached_playback_metadata_service.dart b/lib/services/cached_playback_metadata_service.dart index f3c48696..55ffb352 100644 --- a/lib/services/cached_playback_metadata_service.dart +++ b/lib/services/cached_playback_metadata_service.dart @@ -23,7 +23,12 @@ class CachedPlaybackMetadataService { try { return switch (backend) { MediaBackend.plex => _fetchPlexMediaSourceInfo(ServerId(cacheServerId), itemId, mediaIndex: mediaIndex), - MediaBackend.jellyfin => _fetchJellyfinMediaSourceInfo(cacheServerId, itemId, mediaIndex: mediaIndex), + MediaBackend.jellyfin || MediaBackend.emby => _fetchJellyfinMediaSourceInfo( + cacheServerId, + itemId, + backend: backend, + mediaIndex: mediaIndex, + ), }; } catch (e) { appLogger.d('Cached media source info unavailable for $cacheServerId:$itemId', error: e); @@ -48,9 +53,10 @@ class CachedPlaybackMetadataService { creditsPattern: creditsPattern, forceChapterFallback: forceChapterFallback, ), - MediaBackend.jellyfin => _fetchJellyfinPlaybackExtras( + MediaBackend.jellyfin || MediaBackend.emby => _fetchJellyfinPlaybackExtras( cacheServerId, itemId, + backend: backend, introPattern: introPattern, creditsPattern: creditsPattern, forceChapterFallback: forceChapterFallback, @@ -96,9 +102,10 @@ class CachedPlaybackMetadataService { static Future _fetchJellyfinMediaSourceInfo( String cacheServerId, String itemId, { + required MediaBackend backend, required int mediaIndex, }) async { - final resolved = await _jellyfinRawItem(cacheServerId, itemId); + final resolved = await _jellyfinRawItem(cacheServerId, itemId, backend: backend); final raw = resolved.raw; final sources = raw['MediaSources']; if (sources is! List || sources.isEmpty) return null; @@ -110,13 +117,14 @@ class CachedPlaybackMetadataService { static Future _fetchJellyfinPlaybackExtras( String cacheServerId, String itemId, { + required MediaBackend backend, String? introPattern, String? creditsPattern, bool forceChapterFallback = false, }) async { - final resolved = await _jellyfinRawItem(cacheServerId, itemId); + final resolved = await _jellyfinRawItem(cacheServerId, itemId, backend: backend); final raw = resolved.raw; - final markers = await _jellyfinMediaSegmentMarkers(resolved.scopeId, itemId); + final markers = await _jellyfinMediaSegmentMarkers(resolved.scopeId, itemId, backend: backend); return jellyfinPlaybackExtrasFromRaw( raw, itemId, @@ -127,10 +135,14 @@ class CachedPlaybackMetadataService { ); } - static Future> _jellyfinMediaSegmentMarkers(String cacheServerId, String itemId) async { + static Future> _jellyfinMediaSegmentMarkers( + String cacheServerId, + String itemId, { + required MediaBackend backend, + }) async { try { final raw = await ApiCache.forBackend( - MediaBackend.jellyfin, + backend, ).get(ServerId(cacheServerId), JellyfinApiCache.mediaSegmentsEndpoint(itemId)); return jellyfinMediaSegmentsToMarkers(raw); } catch (e) { @@ -141,11 +153,12 @@ class CachedPlaybackMetadataService { static Future<({Map raw, String scopeId})> _jellyfinRawItem( String cacheServerId, - String itemId, - ) async { - final cache = ApiCache.forBackend(MediaBackend.jellyfin); + String itemId, { + required MediaBackend backend, + }) async { + final cache = ApiCache.forBackend(backend); final resolved = await JellyfinCacheResolver(cache.database).findItem(cacheServerId, itemId); - if (resolved == null) throw StateError('No Jellyfin cache row for $cacheServerId:$itemId'); + if (resolved == null) throw StateError('No MediaBrowser cache row for $cacheServerId:$itemId'); return (raw: jsonDecode(resolved.cacheRow.data) as Map, scopeId: resolved.key.scopeId); } } diff --git a/lib/services/companion_remote/remote_auth_service.dart b/lib/services/companion_remote/remote_auth_service.dart index f4bf1d6d..b8108267 100644 --- a/lib/services/companion_remote/remote_auth_service.dart +++ b/lib/services/companion_remote/remote_auth_service.dart @@ -12,10 +12,11 @@ import '../../utils/app_logger.dart'; /// /// Proves same-account membership via a backend-derived shared secret. /// -/// Plex uses the Plex Home metadata available to signed-in devices. Jellyfin -/// uses the stable server/user identity available after sign-in, matching the -/// same local-LAN trust model: peers that know the same backend identity can -/// discover and authenticate each other without a central pairing round-trip. +/// Plex uses the Plex Home metadata available to signed-in devices. The +/// MediaBrowser backends (Jellyfin and Emby) use the stable server/user +/// identity available after sign-in, matching the same local-LAN trust model: +/// peers that know the same backend identity can discover and authenticate +/// each other without a central pairing round-trip. class RemoteAuthService { RemoteAuthService._(); static final instance = RemoteAuthService._(); @@ -76,7 +77,14 @@ class RemoteAuthService { return deriveHomeSecret(home.id, admin.uuid); } - /// Derive a companion remote secret from a Jellyfin server/user identity. + /// Derive a companion remote secret from a MediaBrowser server/user identity + /// — Jellyfin or Emby. + /// + /// Both peers of a pair are signed into the same server and user, so both + /// derive the same secret regardless of dialect. The `jellyfin-secret` HKDF + /// `info` label and the `jellyfin:` cache prefix are therefore fixed domain + /// separators, not dialect discriminators: changing either would invalidate + /// every existing pairing for zero benefit. Future> deriveJellyfinSecret({required String serverMachineId, required String userId}) async { final normalizedServerId = serverMachineId.toLowerCase(); final normalizedUserId = userId.toLowerCase(); @@ -98,7 +106,7 @@ class RemoteAuthService { _cachedSecret = await secretKey.extractBytes(); _cachedSecretKey = cacheKey; - appLogger.d('RemoteAuth: Derived Jellyfin secret'); + appLogger.d('RemoteAuth: Derived MediaBrowser secret'); return _cachedSecret!; } diff --git a/lib/services/credential_vault.dart b/lib/services/credential_vault.dart index 7809a5e8..c6146d94 100644 --- a/lib/services/credential_vault.dart +++ b/lib/services/credential_vault.dart @@ -63,11 +63,7 @@ class CredentialVault { static Future> protectConnectionConfig(String kind, Map config) async { final copy = Map.from(config); - final tokenKey = switch (kind) { - 'plex' => 'accountToken', - 'jellyfin' => 'accessToken', - _ => null, - }; + final tokenKey = _tokenKeyForKind(kind); final token = tokenKey == null ? null : copy[tokenKey]; if (token is String) copy[tokenKey!] = await protect(token); if (kind == 'plex') { @@ -81,11 +77,7 @@ class CredentialVault { Map config, ) async { final copy = Map.from(config); - final tokenKey = switch (kind) { - 'plex' => 'accountToken', - 'jellyfin' => 'accessToken', - _ => null, - }; + final tokenKey = _tokenKeyForKind(kind); var migrated = false; final token = tokenKey == null ? null : copy[tokenKey]; if (token is String && token.isNotEmpty) { @@ -103,6 +95,15 @@ class CredentialVault { return (config: copy, migrated: migrated); } + /// Config key holding the long-lived credential for a `connections.kind` + /// value. Returning `null` means "nothing to encrypt", so every new kind MUST + /// be listed here — an omission silently persists the token in plaintext. + static String? _tokenKeyForKind(String kind) => switch (kind) { + 'plex' => 'accountToken', + 'jellyfin' || 'emby' => 'accessToken', + _ => null, + }; + static Future _protectPlexServers(Object? rawServers) async { if (rawServers is! List) return rawServers; final servers = []; diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index bd9312fe..1eb7e767 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -459,18 +459,19 @@ class DownloadManagerService { /// Returns the cache namespace visible to [activeProfileId] for [serverId]. /// - /// Jellyfin prefers the persisted profile-to-user binding so a cold launch - /// and a profile switch cannot inherit the physical download row's creator - /// scope. A live scope is used only when no persisted binding exists. + /// MediaBrowser backends prefer the persisted profile-to-user binding so a + /// cold launch and a profile switch cannot inherit the physical download + /// row's creator scope. A live scope is used only when no persisted binding + /// exists. Future profileClientScopeIdForServer(ServerId serverId, String? activeProfileId) async { if (activeProfileId == null || activeProfileId.isEmpty) return null; final backend = await _backendForServer(serverId); - if (backend == MediaBackend.plex) { - return buildPlexProfileScopeId(serverId: serverId, profileId: activeProfileId); + if (backend == null) return null; + if (backend.usesMediaBrowserApi) { + final persisted = await JellyfinCacheResolver(_database).findProfileScopeId(serverId, activeProfileId); + return persisted ?? activeClientScopeIdForServer(serverId); } - if (backend != MediaBackend.jellyfin) return null; - final persisted = await JellyfinCacheResolver(_database).findProfileScopeId(serverId, activeProfileId); - return persisted ?? activeClientScopeIdForServer(serverId); + return buildPlexProfileScopeId(serverId: serverId, profileId: activeProfileId); } /// Bulk-load pinned metadata. Profile-visible hydration reads only exact @@ -568,16 +569,19 @@ class DownloadManagerService { /// is currently offline (the connection persists across launches). /// /// [JellyfinCacheResolver] reconciles bare machine ids with compound - /// `${serverMachineId}/$userId` connection ids without treating `_` or `%` - /// as wildcards. + /// `${serverMachineId}/$userId` MediaBrowser connection ids without treating + /// `_` or `%` as wildcards. Future _backendForServer(ServerId serverId) async { // Prefer a live client — `MediaServerClient.backend` is in memory. final live = _getClient(serverId); if (live != null) return live.backend; final row = await JellyfinCacheResolver(_database).findConnection(serverId); if (row == null) return null; + // Keep the persisted discriminator intact even though both MediaBrowser + // values dispatch to the same cache implementation. return switch (row.kind) { 'jellyfin' => MediaBackend.jellyfin, + 'emby' => MediaBackend.emby, 'plex' => MediaBackend.plex, _ => null, }; @@ -590,8 +594,8 @@ class DownloadManagerService { /// When [_backendForServer] can't resolve the backend (no live client and /// no `connections` row — happens when a server has been removed but old /// download rows still reference it), fan out to every registered backend - /// cache instead of silently defaulting to Plex. Otherwise Jellyfin items - /// would render with blank metadata after a connection is severed. + /// cache instead of silently defaulting to Plex. Otherwise MediaBrowser + /// items would render with blank metadata after a connection is severed. Future _lookupMetadata(ServerId serverId, String itemId, {String? clientScopeId}) async { final backend = await _backendForServer(serverId); final live = _getClient(serverId, clientScopeId: clientScopeId); @@ -712,8 +716,8 @@ class DownloadManagerService { } } - /// Backend-aware "ensure cached & pin". Jellyfin loads playback extras so - /// both item metadata and native media segments are available offline; other + /// Backend-aware "ensure cached & pin". MediaBrowser backends load playback + /// extras; Jellyfin can additionally cache native media segments. Other /// backends only need the item metadata row. Then pin cached rows so they /// survive general cache eviction. Future _pinMetadataForOffline(MediaServerClient client, MediaItem metadata) async { @@ -722,7 +726,7 @@ class DownloadManagerService { appLogger.w('Cannot pin metadata without serverId'); return; } - if (client.backend == MediaBackend.jellyfin) { + if (client.backend.usesMediaBrowserApi) { try { await client.fetchPlaybackExtras(metadata.id); } catch (e) { @@ -3564,7 +3568,7 @@ class DownloadManagerService { /// Save metadata for a media item (show, season, movie, or episode) /// Used to persist parent metadata (shows/seasons) for offline display. /// - /// Both backends now have read-path cache-through, so the work is just to + /// All backends have read-path cache-through, so the work is just to /// hit `client.fetchItem` (idempotent) and pin the resulting row. Future saveMetadata(MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) { diff --git a/lib/services/jellyfin_api_cache.dart b/lib/services/jellyfin_api_cache.dart index 1b4cb16c..60cd6f3f 100644 --- a/lib/services/jellyfin_api_cache.dart +++ b/lib/services/jellyfin_api_cache.dart @@ -5,6 +5,7 @@ import 'package:drift/drift.dart'; import '../database/app_database.dart'; import '../media/media_backend.dart'; +import '../media/media_browser_dialect.dart'; import '../media/media_item.dart'; import '../utils/app_logger.dart'; import '../utils/global_key_utils.dart'; @@ -14,22 +15,26 @@ import 'credential_vault.dart'; import 'jellyfin_cache_resolver.dart'; import 'jellyfin_mappers.dart'; -/// Jellyfin-shape helpers on top of the shared [ApiCache] substrate. +/// MediaBrowser-shape helpers on top of the shared [ApiCache] substrate. /// -/// Cache rows for Jellyfin item metadata use the compound connection id -/// (`{machineId}/{userId}`) plus the read-path endpoint key +/// Cache rows for Jellyfin and Emby item metadata use the compound connection +/// id (`{machineId}/{userId}`) plus the read-path endpoint key /// `/Users/{userId}/Items/{itemId}`. The public [MediaItem.serverId] remains /// the bare machine id; the compound prefix only isolates local user-scoped /// state such as `UserData`. class JellyfinApiCache extends ApiCache { - static final _singleton = ApiCacheSingleton(MediaBackend.jellyfin, 'JellyfinApiCache'); + static final _singleton = ApiCacheSingleton(const { + MediaBackend.jellyfin, + MediaBackend.emby, + }, 'JellyfinApiCache'); static JellyfinApiCache get instance => _singleton.instance; JellyfinApiCache._(super.db); /// Initialize the singleton with an [AppDatabase] instance. Also registers /// this instance with the [ApiCache] backend dispatch so callers using - /// `ApiCache.forBackend(MediaBackend.jellyfin)` resolve here. + /// `ApiCache.forBackend(MediaBackend.jellyfin)` or + /// `ApiCache.forBackend(MediaBackend.emby)` resolve here. static void initialize(AppDatabase db) => _singleton.install(JellyfinApiCache._(db)); JellyfinCacheResolver get _resolver => JellyfinCacheResolver(database); @@ -111,6 +116,7 @@ class JellyfinApiCache extends ApiCache { serverId: ServerId(ctx.machineId), serverName: ctx.name, absolutizer: absolutizer, + dialect: ctx.dialect, ); } catch (_) { return null; @@ -118,18 +124,17 @@ class JellyfinApiCache extends ApiCache { } /// Persist a watched/unwatched flip into cached `BaseItemDto` rows for - /// [itemId]. Compound Jellyfin scope ids update only their user. A legacy + /// [itemId]. Compound MediaBrowser scope ids update only their user. A legacy /// bare machine id is accepted only when its matching rows belong to one /// user; ambiguous multi-user writes are skipped rather than bleeding watch /// state across profiles. /// - /// [viewOffsetMs] is converted to Jellyfin's 100-ns ticks for + /// [viewOffsetMs] is converted to MediaBrowser 100-ns ticks for /// `UserData.PlaybackPositionTicks`. [lastViewedAt] is treated as Plex's - /// epoch-seconds and translated to Jellyfin's ISO-8601 `LastPlayedDate`. - /// [viewedLeafCount] is ignored — Jellyfin tracks per-show rollup via - /// `UserData.UnplayedItemCount`, computed from individual children rather - /// than aggregated on the parent. The parameter is accepted for API parity - /// with the Plex caller. + /// epoch-seconds and translated to the MediaBrowser ISO-8601 + /// `LastPlayedDate`. [viewedLeafCount] is ignored — both dialects compute + /// per-show rollup from individual children via `UserData.UnplayedItemCount`. + /// The parameter is accepted for API parity with the Plex caller. @override Future applyWatchState({ required ServerId serverId, @@ -150,7 +155,7 @@ class JellyfinApiCache extends ApiCache { }; if (userIds.length > 1) { appLogger.w( - 'Skipping ambiguous bare-scope Jellyfin watch-state cache write', + 'Skipping ambiguous bare-scope MediaBrowser watch-state cache write', error: {'serverId': serverId, 'itemId': itemId, 'userCount': userIds.length}, ); return; @@ -192,7 +197,7 @@ class JellyfinApiCache extends ApiCache { } } - /// Load all pinned Jellyfin metadata in a single query. + /// Load all pinned MediaBrowser metadata in a single query. /// /// Returns a map keyed by `buildGlobalKey(ServerId(serverId), itemId)` for O(1) /// lookups, mirroring [PlexApiCache.getAllPinnedMetadata] so callers can @@ -213,9 +218,10 @@ class JellyfinApiCache extends ApiCache { // Resolve the connection context per serverId once on the main thread // (DB queries can't move into the isolate). Each context carries the - // serverName used to stamp the [MediaItem] plus the baseUrl/accessToken - // required to absolutize image paths. - final contexts = {}; + // serverName and dialect used to stamp the [MediaItem] plus the + // baseUrl/accessToken required to absolutize image paths. + final contexts = + {}; final absolutizers = {}; for (final entry in entries) { final id = entry.connection.id; @@ -240,6 +246,7 @@ class JellyfinApiCache extends ApiCache { serverId: ServerId(ctx.machineId), serverName: ctx.name, absolutizer: absolutizer, + dialect: ctx.dialect, ); if (mapped == null) return null; return MapEntry(buildGlobalKey(ServerId(entry.key.scopeId), entry.key.itemId), mapped); @@ -248,27 +255,26 @@ class JellyfinApiCache extends ApiCache { ); } - /// Resolve the connection context (server name + base URL + access token) - /// for a cache row keyed by the server's machineId. The [Connections] + /// Resolve the connection context (server name, dialect, base URL, and access + /// token) for a cache row keyed by the server's machineId. The [Connections] /// row's `id` is `${serverMachineId}/$userId`, so a direct `id == serverId` /// lookup misses; fall back to a prefix match. /// - /// `name` matches what the live [JellyfinClient] stamps onto online - /// MediaItems (`connection.serverName`, not the compound `displayName`). - /// `baseUrl` and `accessToken` come from the same `configJson` payload - /// [JellyfinConnection.toConfigJson] writes, so cache-read absolutization - /// uses the current values — token/URL rotations Just Work. + /// `name` and `dialect` match what the live [JellyfinClient] stamps onto + /// online MediaItems. `baseUrl` and `accessToken` come from the same + /// `configJson` payload [JellyfinConnection.toConfigJson] writes, so + /// cache-read absolutization uses the current values — token/URL rotations + /// Just Work. /// - /// Returns `null` when no row matches or the row carries an empty - /// `baseUrl` (no honest URL we can build). - Future<({String machineId, String name, String baseUrl, String accessToken})?> _serverContext( - ConnectionRow row, { - required String machineId, - }) async { + /// Returns `null` when no row matches or the row carries an empty `baseUrl` + /// (no honest URL we can build). + Future<({String machineId, String name, String baseUrl, String accessToken, MediaBrowserDialect dialect})?> + _serverContext(ConnectionRow row, {required String machineId}) async { String? configName; String? configMachineId; String baseUrl = ''; String accessToken = ''; + final dialect = MediaBrowserDialect.fromIdOrJellyfin(row.kind); try { final rawConfig = jsonDecode(row.configJson) as Map; final config = (await CredentialVault.revealConnectionConfig(row.kind, rawConfig)).config; @@ -282,6 +288,6 @@ class JellyfinApiCache extends ApiCache { if (baseUrl.isEmpty) return null; configMachineId ??= machineId; final name = (configName != null && configName.isNotEmpty) ? configName : row.displayName; - return (machineId: configMachineId, name: name, baseUrl: baseUrl, accessToken: accessToken); + return (machineId: configMachineId, name: name, baseUrl: baseUrl, accessToken: accessToken, dialect: dialect); } } diff --git a/lib/services/jellyfin_auth_header.dart b/lib/services/jellyfin_auth_header.dart index be1821b4..fec4e14e 100644 --- a/lib/services/jellyfin_auth_header.dart +++ b/lib/services/jellyfin_auth_header.dart @@ -1,22 +1,21 @@ import '../utils/device_identity.dart'; -/// Build the `MediaBrowser` Authorization header value the way the official -/// Jellyfin SDK formats it: every field value is percent-encoded, and the -/// server reverses that with `WebUtility.UrlDecode` while parsing the header. -/// Used at auth time and on every authenticated request so the server sees a +/// Builds the `MediaBrowser` Authorization header value understood by both +/// Jellyfin and Emby. Every field value is percent-encoded, and the server +/// reverses that encoding while parsing the header. The same value is used at +/// auth time and on every authenticated request so either dialect sees a /// consistent client identity. /// /// Encoding is what keeps the header sendable at all. A device name like /// `Bjørn PC` cannot travel verbatim: `dart:io` rejects header values above /// 0x7F outright, and CFNetwork puts the raw code unit on the wire as a -/// Latin-1 byte, which Kestrel — the HTTP server hosting Jellyfin — refuses -/// as a malformed header with 400 before the request is ever routed. It also -/// removes the grammar hazards the header has no escape for: quotes, commas, -/// and `=` inside a value. +/// Latin-1 byte, which the server rejects as a malformed header before the +/// request is routed. It also removes the grammar hazards the header has no +/// escape for: quotes, commas, and `=` inside a value. /// -/// Jellyfin requires non-empty client, device, and version fields when +/// Both dialects require non-empty client, device, and version fields when /// creating a session, so those values use stable fallbacks. An empty device -/// ID is omitted for authenticated requests, where Jellyfin can recover it +/// ID is omitted for authenticated requests, where the server can recover it /// from the token; unauthenticated entry points must call /// [requireJellyfinDeviceId]. String buildJellyfinAuthHeader({ @@ -49,14 +48,14 @@ final RegExp _controlCharacters = RegExp(r'[\x00-\x1f\x7f-\x9f]'); /// Percent-encoding makes any byte transportable, so the only values worth /// filtering are the ones that carry no identity at all — a name of control -/// characters would otherwise reach Jellyfin's device list as `%00` noise +/// characters would otherwise reach the server's device list as `%00` noise /// instead of falling back to a readable label. String _meaningful(String value) => value.replaceAll(_controlCharacters, '').trim(); -/// Validates the stable device identity required by unauthenticated Jellyfin -/// session creation. Never substitute a placeholder: Jellyfin keys sessions -/// and access tokens by this value, so a shared fallback would collide across -/// installations. +/// Validates the stable device identity required by unauthenticated +/// MediaBrowser session creation. Never substitute a placeholder: both +/// dialects key sessions and access tokens by this value, so a shared fallback +/// would collide across installations. String requireJellyfinDeviceId(String deviceId) { final sanitized = sanitizeHeaderValue(deviceId); if (sanitized == null || sanitized != deviceId || sanitized.contains('"')) { diff --git a/lib/services/jellyfin_auth_service.dart b/lib/services/jellyfin_auth_service.dart index 42fe3966..b00fc580 100644 --- a/lib/services/jellyfin_auth_service.dart +++ b/lib/services/jellyfin_auth_service.dart @@ -6,6 +6,7 @@ import 'package:http/http.dart' as http; import '../connection/connection.dart'; import '../exceptions/media_server_exceptions.dart'; +import '../media/media_browser_dialect.dart'; import '../utils/app_logger.dart'; import '../utils/media_server_http_client.dart'; import '../utils/media_server_timeouts.dart'; @@ -13,6 +14,7 @@ import '../utils/log_redaction_manager.dart'; import '../utils/poll_with_backoff.dart'; import 'jellyfin_auth_header.dart'; import 'jellyfin_endpoint_discovery.dart'; +import 'media_browser_paths.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 @@ -42,7 +44,7 @@ class _JellyfinAuthenticationResponse { /// Auth flow for adding or refreshing a [JellyfinConnection]. /// /// Lifecycle for adding a server: -/// 1. [probe] — validates the URL responds as a Jellyfin server. +/// 1. [probe] — validates the URL responds as a MediaBrowser server. /// 2. [authenticateByName] (or future Quick Connect equivalent) — exchanges /// credentials for a long-lived access token and returns a built /// [JellyfinConnection] ready to insert into [ConnectionRegistry]. @@ -53,12 +55,15 @@ class JellyfinConnectionAuthService { required this.clientName, required this.clientVersion, required this.deviceName, + MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin, @visibleForTesting this._testHttpClientFactory, - }) : _endpointDiscovery = JellyfinEndpointDiscovery(testHttpClientFactory: _testHttpClientFactory); + }) : dialect = dialect, + _endpointDiscovery = JellyfinEndpointDiscovery(dialect: dialect, testHttpClientFactory: _testHttpClientFactory); /// App identity sent in the `MediaBrowser` Authorization header. Jellyfin - /// uses `Client`/`Device`/`DeviceId`/`Version` to populate the device list - /// in its admin UI and to issue tokens. + /// and Emby use `Client`/`Device`/`DeviceId`/`Version` to populate the + /// device list in their admin UI and to issue tokens. + final MediaBrowserDialect dialect; final String clientName; final String clientVersion; final String deviceName; @@ -81,7 +86,7 @@ class JellyfinConnectionAuthService { /// Probe the server identified by [baseUrl] without authenticating. Returns /// the public info used by the UI to confirm "yes that's the right server" /// before asking for credentials. Throws [MediaServerUrlException] when the - /// URL is unreachable or doesn't look like a Jellyfin server. + /// URL is unreachable or doesn't look like the selected media server. Future probe(String baseUrl) async { return _endpointDiscovery.probe(baseUrl); } @@ -158,10 +163,12 @@ class JellyfinConnectionAuthService { } } - /// Whether [baseUrl] has Quick Connect enabled. Returns `false` for any - /// failure — Jellyfin <10.7 returns 404 on this path, and an offline server - /// is functionally indistinguishable from QC-disabled for UI purposes. + /// Whether [baseUrl] has Quick Connect enabled. Returns `false` without a + /// request for dialects that do not support it, and for any probe failure — + /// Jellyfin <10.7 returns 404 on this path, and an offline server is + /// functionally indistinguishable from QC-disabled for UI purposes. Future isQuickConnectEnabled(String baseUrl) async { + if (!dialect.supportsQuickConnect) return false; final normalised = _normaliseBaseUrl(baseUrl); final client = _buildHttpClient(baseUrl: normalised); try { @@ -184,6 +191,7 @@ class JellyfinConnectionAuthService { required String baseUrl, required String deviceId, }) async { + _requireQuickConnectSupport(); final validDeviceId = requireJellyfinDeviceId(deviceId); final normalised = _normaliseBaseUrl(baseUrl); final authHeader = buildJellyfinAuthHeader( @@ -228,7 +236,7 @@ class JellyfinConnectionAuthService { /// in their Jellyfin web UI, then exchange the approved secret for a token /// and return a fully-formed [JellyfinConnection]. Returns `null` on /// cancel, timeout, or server-side secret expiry (404 mid-poll). Throws - /// [MediaServerAuthException] on auth failures (401/403). + /// [MediaServerAuthException] on auth failures or an unsupported dialect. Future authenticateByQuickConnect({ required String baseUrl, List? baseUrls, @@ -238,6 +246,7 @@ class JellyfinConnectionAuthService { Duration timeout = const Duration(minutes: 5), bool Function()? shouldCancel, }) async { + _requireQuickConnectSupport(); final validDeviceId = requireJellyfinDeviceId(deviceId); final normalised = _normaliseBaseUrl(baseUrl); final info = serverInfo ?? await probe(normalised); @@ -331,8 +340,9 @@ class JellyfinConnectionAuthService { Future validate(Connection connection) async { if (connection is! JellyfinConnection) return false; final client = _authenticatedClient(connection); + final currentUser = MediaBrowserPaths(dialect: dialect, userId: connection.userId).currentUser; try { - final response = await client.get('/Users/Me', timeout: MediaServerTimeouts.jellyfinProbe); + final response = await client.get(currentUser, timeout: MediaServerTimeouts.jellyfinProbe); return response.statusCode == 200; } on MediaServerHttpException catch (e) { if (e.statusCode == 401 || e.statusCode == 403) return false; @@ -368,6 +378,15 @@ class JellyfinConnectionAuthService { } } + /// Emby 4.9.5 returns 404 for every `/QuickConnect/*` route. Emby Connect is + /// a separate account-level product and is not an authentication flow Plezy + /// implements. + void _requireQuickConnectSupport() { + if (!dialect.supportsQuickConnect) { + throw MediaServerAuthException('Quick Connect rejected by server'); + } + } + MediaServerHttpClient _authenticatedClient(JellyfinConnection connection) { LogRedactionManager.registerToken(connection.accessToken); return _buildHttpClient( @@ -441,7 +460,7 @@ class JellyfinConnectionAuthService { /// Build a [JellyfinConnection] from a successful auth/exchange response. /// Connection id is derived from `(machineId, userId)` so each user on a /// given server has a single stable connection row. - static JellyfinConnection _buildConnection({ + JellyfinConnection _buildConnection({ required JellyfinServerInfo info, required String normalisedBaseUrl, List? baseUrls, @@ -463,6 +482,7 @@ class JellyfinConnectionAuthService { userName: userName, accessToken: accessToken, deviceId: deviceId, + dialect: info.dialect ?? dialect, isAdministrator: isAdministrator, primaryImageTag: primaryImageTag, status: ConnectionStatus.online, diff --git a/lib/services/jellyfin_cache_resolver.dart b/lib/services/jellyfin_cache_resolver.dart index 98637f9a..e26bb3b9 100644 --- a/lib/services/jellyfin_cache_resolver.dart +++ b/lib/services/jellyfin_cache_resolver.dart @@ -8,7 +8,7 @@ typedef JellyfinItemCacheKey = ({String scopeId, String machineId, String userId typedef JellyfinCacheItem = ({ApiCacheData cacheRow, JellyfinItemCacheKey key}); typedef ResolvedJellyfinCacheItem = ({ApiCacheData cacheRow, ConnectionRow connection, JellyfinItemCacheKey key}); -/// Canonical Jellyfin connection and item-cache key resolution. +/// Canonical MediaBrowser connection and item-cache key resolution. class JellyfinCacheResolver { JellyfinCacheResolver(this.database); @@ -18,6 +18,9 @@ class JellyfinCacheResolver { static const _usersMarker = ':/Users/'; static const _itemsMarker = '/Items/'; + static Expression _mediaBrowserKind(GeneratedColumn kind) => + kind.equals('jellyfin') | kind.equals('emby'); + Expression itemKeyPredicate(GeneratedColumn column, String serverOrScopeId, String itemId) { final scope = _splitScope(serverOrScopeId); final escapedItemId = _escapeLike(itemId); @@ -78,7 +81,7 @@ class JellyfinCacheResolver { .get(); if (rows.isEmpty) return const []; - final connections = await (database.select(database.connections)..where((t) => t.kind.equals('jellyfin'))).get(); + final connections = await (database.select(database.connections)..where((t) => _mediaBrowserKind(t.kind))).get(); final connectionById = {for (final connection in connections) connection.id: connection}; final bindings = await database.select(database.profileConnections).get(); final bindingsByConnection = >{}; @@ -111,11 +114,11 @@ class JellyfinCacheResolver { return matches; } - /// Resolves the exact persisted Jellyfin cache namespace owned by + /// Resolves the exact persisted MediaBrowser cache namespace owned by /// [profileId] for [serverOrScopeId]. /// /// The physical download row is deliberately not consulted: it is shared - /// across profiles and may have been created by a different Jellyfin user. + /// across profiles and may have been created by a different MediaBrowser user. Future findProfileScopeId(String serverOrScopeId, String profileId) async { if (profileId.isEmpty) return null; final requested = _splitScope(serverOrScopeId); @@ -132,7 +135,7 @@ class JellyfinCacheResolver { if (binding.userIdentifier.isEmpty) continue; final connection = await (database.select( database.connections, - )..where((t) => t.id.equals(binding.connectionId) & t.kind.equals('jellyfin'))).getSingleOrNull(); + )..where((t) => t.id.equals(binding.connectionId) & _mediaBrowserKind(t.kind))).getSingleOrNull(); if (connection == null) continue; final connectionScope = _splitScope(connection.id); @@ -169,12 +172,12 @@ class JellyfinCacheResolver { final compoundId = '${scope.machineId}/$expectedUserId'; final compound = await (database.select( database.connections, - )..where((t) => t.id.equals(compoundId) & t.kind.equals('jellyfin'))).getSingleOrNull(); + )..where((t) => t.id.equals(compoundId) & _mediaBrowserKind(t.kind))).getSingleOrNull(); if (compound != null && await _matchesProfileBinding(compound.id, expectedUserId)) return compound; final legacy = await (database.select( database.connections, - )..where((t) => t.id.equals(scope.machineId) & t.kind.equals('jellyfin'))).getSingleOrNull(); + )..where((t) => t.id.equals(scope.machineId) & _mediaBrowserKind(t.kind))).getSingleOrNull(); if (legacy != null && await _matchesProfileBinding(legacy.id, expectedUserId)) return legacy; return null; } @@ -189,7 +192,7 @@ class JellyfinCacheResolver { final prefix = '${scope.machineId}/'; return (database.select(database.connections) - ..where((t) => t.id.substr(1, prefix.length).equals(prefix) & t.kind.equals('jellyfin')) + ..where((t) => t.id.substr(1, prefix.length).equals(prefix) & _mediaBrowserKind(t.kind)) ..orderBy([(t) => OrderingTerm.asc(t.id)]) ..limit(1)) .getSingleOrNull(); diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index e49b4c32..f9b37fac 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; @@ -17,6 +18,7 @@ import '../media/media_filter.dart'; import '../media/live_tv_support.dart'; import '../media/lyrics.dart'; import '../media/media_backend.dart'; +import '../media/media_browser_dialect.dart'; import '../media/media_file_info.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; @@ -61,6 +63,7 @@ import 'jellyfin_media_info_mapper.dart'; import 'jellyfin_playback_bundle.dart'; import 'jellyfin_playback_urls.dart'; import 'jellyfin_trickplay_service.dart'; +import 'media_browser_paths.dart'; import 'playback_initialization_types.dart'; import 'scrub_preview_source.dart'; import 'subtitle_preference.dart'; @@ -86,18 +89,79 @@ part 'jellyfin_client/parts/metadata_edit.dart'; /// used by a single part stay declared in that part. mixin _JellyfinClientInternals on MediaServerCacheMixin { JellyfinConnection get connection; + MediaBrowserDialect get dialect; + MediaBrowserPaths get paths; FailoverHttpClient get _http; MediaItem? _mapItem(Map json); List _mapItems(Iterable> items); String? _absolutizeImagePath(String? path); + + /// Row metadata Jellyfin volunteers on `/Items` list responses but Emby + /// withholds unless it is named in `Fields`. + /// + /// Measured on Emby 4.9.5 against a movie that carries them all: a row built + /// from [_baseBrowseFields] came back with no `ProductionYear`, + /// `OfficialRating`, `PremiereDate` or `DateCreated`, while the same query + /// naming them returned `2011`, `PG-13`, the premiere date and the library-add + /// time. Jellyfin 10.11 includes the first three in every row regardless. + /// Emby's *detail* route volunteers everything, so only list rows are + /// affected — but that is every card in the app, which would otherwise lose + /// its year and age-rating badge. + /// + /// `DateCreated` is load-bearing beyond display: it is the `addedAt` every + /// recency-ordered surface degrades to when a row has never been played. + /// + /// `UserDataLastPlayedDate` is the odd one out: it is not an `ItemFields` + /// member but an Emby-specific token, and it is the only way to get + /// `UserData.LastPlayedDate` onto a list row. Measured on Emby 4.9.5: the + /// played date is absent under `Fields=UserData`, `EnableUserData=true` and the + /// user-scoped `Ids=` form, and present only on the single-item detail route or + /// when this token is named. Jellyfin 10.11 volunteers the date on every row and + /// accepts the token without changing its responses, but since it is undocumented + /// there, only Emby is asked for it. + /// + /// Every row set needs it, not just the recency-ordered ones: a null played date + /// on a *watched* row makes [JellyfinApiCache.applyWatchState] stamp + /// `DateTime.now()`, so an offline watch-state pull over episode rows would + /// rewrite the cached play time of everything it touched. + static const _embyWithheldRowFields = [ + 'ProductionYear', + 'OfficialRating', + 'PremiereDate', + 'DateCreated', + 'UserDataLastPlayedDate', + ]; + + /// Append the fields this dialect withholds, skipping any the set already + /// names so Jellyfin's request strings stay byte-identical. + String _withDialectRowFields(String fields) { + if (dialect != MediaBrowserDialect.emby) return fields; + final present = fields.split(',').map((field) => field.trim()).toSet(); + final missing = _embyWithheldRowFields.where((field) => !present.contains(field)); + return missing.isEmpty ? fields : '$fields,${missing.join(',')}'; + } + + String get _browseFields => _withDialectRowFields(_baseBrowseFields); + String get _hubRowFields => _withDialectRowFields(_baseHubRowFields); + String get _episodeRowFields => _withDialectRowFields(_baseEpisodeRowFields); + String get _folderBrowseFields => _withDialectRowFields(_baseFolderBrowseFields); + String get _folderRowFields => _withDialectRowFields(_baseFolderRowFields); + String get _musicAlbumRowFields => _withDialectRowFields(_baseMusicAlbumRowFields); + String get _musicTrackRowFields => _withDialectRowFields(_baseMusicTrackRowFields); + String get _queueFields => _withDialectRowFields(_baseQueueFields); } -/// [MediaServerClient] over a Jellyfin server. +/// [MediaServerClient] over a MediaBrowser-family server — Jellyfin or Emby. /// /// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the /// HTTP wrapper is backend-agnostic despite the name). Implements the full /// neutral interface: browse, watch state, playlist read, playback session /// reporting, and live TV via [LiveTvSupport]. +/// +/// Jellyfin forked from Emby 3.5.2 and the wire contract is still ~95% shared, +/// so one client serves both. [dialect] selects the divergent routes (via +/// [paths]) and the features that exist on only one side — see +/// [MediaBrowserDialect]. class JellyfinClient with MediaServerCacheMixin, @@ -119,7 +183,8 @@ class JellyfinClient ScopedMediaServerClient, GracefullyCloseable { JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository}) - : _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository(); + : _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository(), + _paths = MediaBrowserPaths(dialect: _connection.dialect, userId: _connection.userId); /// Build a fully-initialised [JellyfinClient]. Endpoint reachability is /// raced before construction by onboarding/profile binding; this factory @@ -131,6 +196,10 @@ class JellyfinClient /// reject requests that only carry the legacy `X-Emby-Token` header, /// returning 404 from the proxy or a routing-level handler instead of /// 401. We send `X-Emby-Token` too for old Emby/Jellyfin builds. + /// + /// Emby accepts this header pair verbatim: it authored both the + /// `MediaBrowser` Authorization scheme and `X-Emby-Token`, so no dialect + /// branch is needed here (verified against Emby 4.9.5). static Future create( JellyfinConnection connection, { FavoriteChannelsRepository? favoritesRepository, @@ -140,7 +209,7 @@ class JellyfinClient // HTTP traffic. Orchestration logs contain no literals; this additionally // protects unavoidable network-layer diagnostics. _registerConnectionDiagnostics(connection); - final endpointDiscovery = JellyfinEndpointDiscovery(); + final endpointDiscovery = JellyfinEndpointDiscovery(dialect: connection.dialect); String version = '1.0'; try { final pkg = await PackageInfo.fromPlatform(); @@ -203,7 +272,10 @@ class JellyfinClient void Function()? onAllEndpointsExhausted, }) { _registerConnectionDiagnostics(connection); - final endpointDiscovery = JellyfinEndpointDiscovery(testHttpClientFactory: endpointProbeHttpClientFactory); + final endpointDiscovery = JellyfinEndpointDiscovery( + dialect: connection.dialect, + testHttpClientFactory: endpointProbeHttpClientFactory, + ); late JellyfinClient client; final mediaHttp = FailoverHttpClient( baseUrl: connection.baseUrl, @@ -221,11 +293,23 @@ class JellyfinClient } /// Mutable so [isHealthy] can refresh `Policy.IsAdministrator` from the - /// `/Users/Me` probe response — admin status changed server-side should + /// current-user probe response — admin status changed server-side should /// propagate without forcing the user to re-auth. JellyfinConnection _connection; @override JellyfinConnection get connection => _connection; + + /// Which MediaBrowser dialect this server speaks. Fixed for the lifetime of + /// the client: an endpoint switch can move the base URL but never turns a + /// Jellyfin server into an Emby one. + @override + MediaBrowserDialect get dialect => _connection.dialect; + + /// Route builders for the endpoints where the two dialects diverge. + @override + MediaBrowserPaths get paths => _paths; + final MediaBrowserPaths _paths; + @override final FailoverHttpClient _http; final FavoriteChannelsRepository _favoritesRepository; @@ -275,8 +359,13 @@ class JellyfinClient String? _absolutizeImagePath(String? path) => _absolutizer.absolutize(path); @override - MediaItem? _mapItem(Map json) => - JellyfinMappers.mediaItem(json, serverId: serverId, serverName: serverName, absolutizer: _absolutizer); + MediaItem? _mapItem(Map json) => JellyfinMappers.mediaItem( + json, + serverId: serverId, + serverName: serverName, + absolutizer: _absolutizer, + dialect: dialect, + ); @override List _mapItems(Iterable> items) => @@ -292,20 +381,23 @@ class JellyfinClient String? get serverName => connection.serverName; @override - MediaBackend get backend => MediaBackend.jellyfin; + MediaBackend get backend => dialect.backend; @override - ServerCapabilities get capabilities => ServerCapabilities.jellyfin; + ServerCapabilities get capabilities => switch (dialect) { + MediaBrowserDialect.jellyfin => ServerCapabilities.jellyfin, + MediaBrowserDialect.emby => ServerCapabilities.emby, + }; - /// Jellyfin doesn't expose a per-server played-threshold pref, so we mirror + /// Neither dialect exposes a per-server played-threshold pref, so we mirror /// Plex's default of 90%. @override double get watchedThreshold => 0.9; - /// Jellyfin marks an item played from `/Sessions/Playing/Stopped` itself - /// (server `MaxResumePct`, default 90%), so the in-player auto-scrobble must - /// not also `POST /UserPlayedItems` — that double-scrobbles via the Trakt - /// plugin (#1287). Manual mark-watched still hits `/UserPlayedItems`. + /// Both dialects mark an item played from `/Sessions/Playing/Stopped` + /// themselves (server `MaxResumePct`, default 90%), so the in-player + /// auto-scrobble must not also POST the played route — that double-scrobbles + /// via the Trakt plugin (#1287). Manual mark-watched still writes it. @override bool get marksWatchedOnPlaybackStopped => true; @@ -316,7 +408,8 @@ class JellyfinClient Future closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) => _http.closeGracefully(drainTimeout: drainTimeout); - /// Reachable *and* token-valid. We probe `/Users/Me` (auth-required) + /// Reachable *and* token-valid. We probe the current-user route + /// ([MediaBrowserPaths.currentUser], auth-required) /// rather than `/System/Info/Public` so a revoked token surfaces as /// unhealthy on the very next sweep, instead of waiting for the first /// real call to 401. @@ -332,7 +425,7 @@ class JellyfinClient @override Future checkHealth() async { try { - final response = await _http.get('/Users/Me', timeout: MediaServerTimeouts.jellyfinProbe); + final response = await _http.get(paths.currentUser, timeout: MediaServerTimeouts.jellyfinProbe); final ok = response.statusCode >= 200 && response.statusCode < 300; if (ok) { final data = response.data; @@ -382,7 +475,7 @@ class JellyfinClient /// Returns null on transport failures — caller treats as "no preference". Future fetchUserProfile() async { try { - final response = await _http.get('/Users/Me'); + final response = await _http.get(paths.currentUser); throwIfHttpError(response); final data = response.data; if (data is! Map) return null; diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 2c1b565f..573a4a19 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -68,7 +68,7 @@ LibraryPage _pagedItems( /// `CommaDelimitedCollectionModelBinder` drops them element-by-element and /// they never did anything. `UserData` is governed by `EnableUserData` /// (default true) and `dto.PremiereDate` is set unconditionally. -const _browseFields = 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Overview'; +const _baseBrowseFields = 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Overview'; /// Field set for the home / per-library hub rows (Recently Added, Continue /// Watching, Next Up). Poster cards render artwork, title, year and the @@ -87,7 +87,7 @@ const _browseFields = 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Over /// (`Folder.FillUserDataDtoValues`), and [MediaItem.unwatchedCount] falls back /// to `UserData.UnplayedItemCount`. Only the season progress bar needs real /// leaf totals, and seasons never appear on a hub row. -const _hubRowFields = 'Overview'; +const _baseHubRowFields = 'Overview'; /// How far back `/Shows/NextUp` looks for a series to resume, mirroring /// Jellyfin web's `maxDaysForNextUp` default. Without it the server's @@ -101,7 +101,7 @@ String _nextUpDateCutoff() => /// Existing episode-row requests can show Plex-style quality labels when the /// response includes `MediaSources`. Keep this off broad library/search/latest /// queries because it is the heaviest item field Jellyfin returns. -const _episodeRowFields = '$_browseFields,MediaSources'; +const _baseEpisodeRowFields = '$_baseBrowseFields,MediaSources'; /// Media types global search surfaces. Episodes are included so a user can /// find a single episode by name. @@ -115,13 +115,13 @@ const _searchItemTypes = 'Movie,Series,Episode,MusicAlbum,Audio'; /// Jellyfin web's folder view requests none of them either. The unwatched /// badge survives via `UserData.UnplayedItemCount` /// ([MediaItem.unwatchedCount] fallback). -const _folderBrowseFields = 'UserData,PremiereDate,OriginalTitle,SortName'; +const _baseFolderBrowseFields = 'UserData,PremiereDate,OriginalTitle,SortName'; /// Folder-tree field set for FILESYSTEM FOLDER children, which render only /// their name. Queried with `EnableUserData=false`: user data on a folder dto /// makes the server compute a recursive unplayed count per folder, by far the /// dominant cost of folder browsing (see [_fetchFolderChildren]). -const _folderRowFields = 'SortName'; +const _baseFolderRowFields = 'SortName'; /// Latest Albums hub row. `/Users/{id}/Items/Latest` on a music library /// returns MusicAlbum FOLDER dtos, so [_browseFields] would trigger the same @@ -133,13 +133,13 @@ const _folderRowFields = 'SortName'; /// filesystem folder rows. Trade-off: fully played albums lose the watched /// checkmark on this row (Jellyfin web's latest-albums row shows no play /// state either). -const _musicAlbumRowFields = 'PremiereDate,OriginalTitle,SortName'; +const _baseMusicAlbumRowFields = 'PremiereDate,OriginalTitle,SortName'; /// Played-track hub rows (Recently Played / Most Played): Audio LEAF dtos. /// Keeps `UserData` — a cheap direct lookup on leaves that drives the /// play-state overlay — and drops the folder count fields (meaningless on /// Audio) and `Overview` (never rendered on track cards). -const _musicTrackRowFields = 'UserData,PremiereDate,OriginalTitle,SortName'; +const _baseMusicTrackRowFields = 'UserData,PremiereDate,OriginalTitle,SortName'; /// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows /// only need title, thumbnail (`ImageTags['Primary']`), season/episode @@ -149,7 +149,7 @@ const _musicTrackRowFields = 'UserData,PremiereDate,OriginalTitle,SortName'; /// Specials interleave — see [compareEpisodesByWatchOrder]). Drops /// `Overview` etc. so even a thousand-episode shounen show fits in one /// response. -const _queueFields = 'UserData,PremiereDate'; +const _baseQueueFields = 'UserData,PremiereDate'; /// Page size for [fetchClientSideEpisodeQueue]. Keeps each server response /// bounded while still returning the full series queue. @@ -177,11 +177,14 @@ const _seriesLastPlayedLookupLimit = 24; /// shelf is better off unstamped than waiting on the 10s/120s shared defaults. const _seriesLastPlayedRequestTimeout = Duration(seconds: 3); -/// Hard ceiling on [_attachSeriesLastPlayed]. On expiry it aborts the in-flight -/// batch, so it bounds the whole pass regardless of how many batches remain or -/// which request phase a lookup is stuck in. Two orders of magnitude under the -/// 10s-connect/120s-receive defaults the single unscoped scan ran with, so a -/// stalled endpoint cannot make the scoped form slower than the query it fixes. +/// Hard ceiling on [_attachSeriesLastPlayed] and on the reconstructed Next Up +/// pass, recency probe included. On expiry it aborts whatever is in flight, so it +/// bounds the whole pass regardless of how many batches remain or which request +/// phase a lookup is stuck in — the per-request timeouts alone cannot, because +/// each applies to the connect and receive phases independently. Two orders of +/// magnitude under the 10s-connect/120s-receive defaults the single unscoped scan +/// ran with, so a stalled endpoint cannot make the scoped form slower than the +/// query it fixes. const _seriesLastPlayedBudget = Duration(seconds: 4); const _childrenPageSize = 500; @@ -203,8 +206,8 @@ String _jellyfinFolderSortName(Map item) { return raw.toLowerCase(); } -/// `/Items/Filters` is a legacy unpaged endpoint; keep failures isolated from -/// the paged Browse tab so very large libraries can still open. +/// Aggregate/facet filter lookups are kept isolated from the paged Browse tab +/// so failures on very large libraries do not prevent the library from opening. const _filtersTimeout = Duration(seconds: 8); /// Full field set for the detail screen and the resume / next-up @@ -212,24 +215,22 @@ const _filtersTimeout = Duration(seconds: 8); const _detailFields = 'Overview,Genres,People,Studios,ProductionLocations,Tags,Taglines,DateCreated,DateLastSaved,' 'PremiereDate,RecursiveItemCount,ChildCount,UserData,MediaSources,OriginalTitle,SortName,' - // Chapters: Jellyfin returns them at the item level; the playback - // init flow plucks `raw['Chapters']` and feeds the seek-bar tick UI. + // Chapters: both dialects return them at the item level; playback plucks + // `raw['Chapters']` and feeds the seek-bar tick UI. 'Chapters,' - // Trickplay: per-resolution sprite-sheet manifest. The scrub-thumbnail - // loader reads `raw['Trickplay']` and computes tile URLs from it. + // Trickplay: Jellyfin's per-resolution sprite-sheet manifest. Emby 4.9.5 + // tolerates this unknown field selection and never populates the field. 'Trickplay,' // ProviderIds carries Tmdb/Imdb/Tvdb keys — required for Trakt + the - // unified tracker coordinator to scrobble Jellyfin items without - // any extra round-trip. + // unified tracker coordinator to scrobble MediaBrowser items without an + // extra round-trip. 'ProviderIds'; mixin _JellyfinBrowseMethods on _JellyfinClientInternals { - // Endpoint conventions follow what the official Jellyfin Kotlin SDK - // generates (cross-checked against the Findroid client). The SDK mixes - // `/Users/{userId}/...` for "user library" / "views" / "latest" / "single - // item" calls and `/Items?userId=...` for the generic list and resume - // endpoints. We mirror that exactly so requests hash the same way against - // proxy rules and rate limiters as a stock Jellyfin app. + // Shared endpoints and query shapes follow the official Jellyfin SDK so + // Jellyfin requests remain unchanged. [MediaBrowserPaths] owns the measured + // route differences: Emby 4.9.5 requires the older user-scoped spellings, + // while the Jellyfin spellings remain unprefixed. /// Views as of the last load, reused by scoped search. /// @@ -278,7 +279,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { abort?.throwIfAborted(); throwIfHttpError(response); final items = _itemsArray(response.data); - // Jellyfin surfaces the user's collection (BoxSet) and playlist roots as + // Both MediaBrowser dialects surface collection (BoxSet) and playlist roots as // top-level views. We expose those as per-library tabs instead of // standalone library entries — matches the Plex shape and avoids // duplicating the same data in two navigation slots. @@ -287,7 +288,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { final ct = (view['CollectionType'] as String?)?.toLowerCase(); return ct != 'boxsets' && ct != 'playlists'; }) - .map((view) => JellyfinMappers.library(view, serverId: serverId, serverName: serverName)) + .map((view) => JellyfinMappers.library(view, serverId: serverId, serverName: serverName, dialect: dialect)) .whereType() .toList(); } @@ -348,13 +349,13 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { } /// Jellyfin's `/Items/Filters` returns Genres / OfficialRatings / Tags / - /// Categories + values from `/Items/Filters` in a single call. The - /// unwatched/unplayed boolean is synthetic because Jellyfin exposes it as - /// an `/Items` query filter, not a filter-listing category. Keys are - /// translated to Plex's filter naming so the existing filter-param map - /// round-trips through `_buildFilterParams` unchanged; the synthesised - /// `MediaFilter.key` is prefixed `jellyfin:` so FiltersBottomSheet can - /// recognise it as cached and skip the per-category value fetch. + /// Years in one call. Emby has no aggregate or official-rating route, so its + /// branch concurrently reads `/Genres`, `/Tags`, and `/Years`. The + /// unwatched/unplayed boolean remains synthetic because both dialects expose + /// it as an `/Items` query filter. Keys are translated to Plex's filter + /// naming so the existing filter-param map round-trips through + /// `_buildFilterParams` unchanged; the synthesised `MediaFilter.key` keeps + /// the historic `jellyfin:` prefix so existing cached preferences remain valid. @override Future fetchLibraryFiltersWithValues(String libraryId, {MediaKind? libraryKind}) async { final filters = [ @@ -382,13 +383,25 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { return raw.whereType().where((s) => s.isNotEmpty).toList(); } + List yearList(Object? raw) { + if (raw is! List) return const []; + return raw + .map( + (year) => switch (year) { + final num value => value.toInt().toString(), + final String value => value, + _ => '', + }, + ) + .where((year) => year.isNotEmpty) + .toList(); + } + final raw = >{ 'genre': stringList(data['Genres']), 'contentRating': stringList(data['OfficialRatings']), 'tag': stringList(data['Tags']), - 'year': (data['Years'] is List) - ? (data['Years'] as List).whereType().map((y) => y.toInt().toString()).toList() - : const [], + 'year': yearList(data['Years']), }; const order = ['genre', 'year', 'contentRating', 'tag']; @@ -417,6 +430,11 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { } Future?> _safeFetchFilterPayload(String libraryId) async { + if (!dialect.supportsAggregateItemFilters) { + // Emby 4.9.5 returns 404 for `/Items/Filters`; its three facet routes below are verified 200. + return _safeFetchFilterFacets(libraryId); + } + try { final response = await _http.get( '/Items/Filters', @@ -433,6 +451,50 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { } } + /// Reassemble the `/Items/Filters` payload from Emby's per-facet routes. + /// + /// Note the route spellings: Emby serves the ratings facet at + /// `/OfficialRatings`, not the `/Items/OfficialRatings` form its siblings + /// might suggest (that one 404s). Jellyfin has none of these four and answers + /// the aggregate route instead. + Future> _safeFetchFilterFacets(String libraryId) async { + final facets = await Future.wait([ + _safeFetchFilterFacet('/Genres', libraryId), + _safeFetchFilterFacet('/OfficialRatings', libraryId), + _safeFetchFilterFacet('/Tags', libraryId), + _safeFetchFilterFacet('/Years', libraryId), + ]); + return {'Genres': facets[0], 'OfficialRatings': facets[1], 'Tags': facets[2], 'Years': facets[3]}; + } + + Future> _safeFetchFilterFacet(String endpoint, String libraryId) async { + try { + final response = await _http.get( + endpoint, + // `Recursive=true` is required: without it Emby only considers the + // library view's direct children and every facet comes back empty + // (measured against Emby 4.9.5 — `/Years` returns 0 vs 15 rows). + queryParameters: {'UserId': connection.userId, 'ParentId': libraryId, 'Recursive': 'true'}, + timeout: _filtersTimeout, + ); + throwIfHttpError(response); + final data = response.data; + if (data is! Map) return const []; + final items = data['Items']; + if (items is! List) return const []; + final names = []; + for (final item in items) { + if (item is! Map) continue; + final name = item['Name']; + if (name is String && name.isNotEmpty) names.add(name); + } + return names; + } catch (e, st) { + appLogger.w('MediaBrowserClient: $endpoint filter facet unavailable', error: e, stackTrace: st); + return const []; + } + } + /// Jellyfin has no `/sorts` listing endpoint, so this returns a hardcoded /// list based on the broad sort set Streamyfin exposes. Keys remain /// backend-neutral where Plezy already had saved preferences (`rating`, @@ -1498,8 +1560,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { @override Future> fetchContinueWatching({int? count = 20}) async { final results = await Future.wait([ - _fetchItemsArray('/UserItems/Resume', { + _fetchItemsArray(_resumePath, { 'userId': connection.userId, + ..._resumeFilterQuery, 'Limit': ?count?.toString(), 'Fields': _hubRowFields, 'MediaTypes': 'Video', @@ -1507,7 +1570,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, }, retry: _continueWatchingRetry), - _safeFetchItemsArray('/Shows/NextUp', { + _fetchNextUpRows({ 'userId': connection.userId, 'Limit': ?count?.toString(), 'Fields': _hubRowFields, @@ -1518,9 +1581,13 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { }, retry: _continueWatchingRetry), ]); + final nextUp = _mapItems(results[1]); return _mergeContinueWatchingAndNextUp( resume: _mapItems(results.first), - nextUp: await _attachSeriesLastPlayed(_mapItems(results[1])), + // Only the server-side shelf needs enriching: `/Shows/NextUp` rows carry no + // series play date, while the reconstructed rows were already stamped from + // the recency query that ordered them. + nextUp: dialect.supportsGlobalNextUp ? await _attachSeriesLastPlayed(nextUp) : nextUp, limit: count, ); } @@ -1631,8 +1698,9 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { final results = await Future.wait([ latestFuture, - _safeFetchItemsArray('/UserItems/Resume', { + _safeFetchItemsArray(_resumePath, { 'userId': connection.userId, + ..._resumeFilterQuery, 'ParentId': ?parentId, 'Limit': limit.toString(), 'Fields': _hubRowFields, @@ -1642,7 +1710,7 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { ...jellyfinImageQueryParameters, }, retry: retry), includeNextUp - ? _safeFetchItemsArray('/Shows/NextUp', { + ? _fetchNextUpRows({ 'userId': connection.userId, 'ParentId': ?parentId, 'Limit': limit.toString(), @@ -1657,7 +1725,10 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { return [ hub('continue', continueTitle, 'mixed', results[1]), - hub('nextup', nextUpTitle, 'episode', results[2]), + // The reconstructed Emby shelf is capped by the series lookup limit rather + // than the caller's, so slice it to the requested preview size the way the + // server-side query already does for Jellyfin. + hub('nextup', nextUpTitle, 'episode', results[2].take(limit).toList(growable: false)), hub('recent', recentTitle, 'mixed', results.first), ].where((h) => h.items.isNotEmpty).toList(); } @@ -1816,9 +1887,10 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { ); case 'continue': return _safeFetchMediaPage( - '/UserItems/Resume', + _resumePath, { 'userId': connection.userId, + ..._resumeFilterQuery, 'StartIndex': offset.toString(), 'Limit': effectiveLimit, 'Fields': _hubRowFields, @@ -1832,23 +1904,31 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { abort: abort, ); case 'nextup': - return _safeFetchMediaPage( - '/Shows/NextUp', - { - 'userId': connection.userId, - 'StartIndex': offset.toString(), - 'Limit': effectiveLimit, - 'Fields': _hubRowFields, - 'ParentId': ?parentId, - 'EnableResumable': 'false', - 'NextUpDateCutoff': _nextUpDateCutoff(), - 'EnableTotalRecordCount': 'true', - ...jellyfinImageQueryParameters, - }, - offset: offset, - requestedSize: pageSize, - abort: abort, - ); + final nextUpQuery = { + 'userId': connection.userId, + 'StartIndex': offset.toString(), + 'Limit': effectiveLimit, + 'Fields': _hubRowFields, + 'ParentId': ?parentId, + 'EnableResumable': 'false', + 'NextUpDateCutoff': _nextUpDateCutoff(), + 'EnableTotalRecordCount': 'true', + ...jellyfinImageQueryParameters, + }; + if (dialect.supportsGlobalNextUp) { + return _safeFetchMediaPage( + '/Shows/NextUp', + nextUpQuery, + offset: offset, + requestedSize: pageSize, + abort: abort, + ); + } + // Emby's shelf is assembled client-side and is capped by + // [_seriesLastPlayedLookupLimit], so it is paged in memory. + final rows = await _fetchNextUpRows(nextUpQuery, abort: abort); + final window = rows.skip(offset).take(pageSize).toList(growable: false); + return LibraryPage(items: _mapItems(window), totalCount: rows.length, offset: offset); case 'recentlyplayed': case 'mostplayed': return _safeFetchMediaPage( @@ -1925,22 +2005,16 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { ].where((h) => h.items.isNotEmpty).toList(); } - /// Jellyfin exposes local trailers separately from special features. Combine - /// both into Plezy's existing extras row, but keep remote/YouTube trailers - /// out of scope because they are external URLs, not playable Jellyfin items. + /// Both dialects expose local trailers separately from special features. + /// Combine them into Plezy's existing extras row, but keep remote/YouTube + /// trailers out of scope because they are external URLs, not playable items. @override Future> fetchExtras(String id) async { if (isOfflineMode) return const []; final results = await Future.wait([ - _safeFetchItemsArray('/Items/${_segment(id)}/LocalTrailers', { - 'userId': connection.userId, - ...jellyfinImageQueryParameters, - }), - _safeFetchItemsArray('/Items/${_segment(id)}/SpecialFeatures', { - 'userId': connection.userId, - ...jellyfinImageQueryParameters, - }), + _safeFetchItemsArray(paths.localTrailers(id), {'userId': connection.userId, ...jellyfinImageQueryParameters}), + _safeFetchItemsArray(paths.specialFeatures(id), {'userId': connection.userId, ...jellyfinImageQueryParameters}), ]); return _playableExtrasFromRaw(results.expand((items) => items)); @@ -2043,6 +2117,259 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals { ]; } + /// Route and extra query that list genuinely in-progress items. + /// + /// Jellyfin's dedicated resume route already returns only started items. Emby's + /// does not: measured against one in-progress movie and 30 started series it + /// returned 30 rows — the movie plus every started series' next episode — with + /// the movie sorted *last*, and no `Filters` value removed them. Paging that is + /// unusable, so Emby reads the ordinary `/Items` route with + /// `Filters=IsResumable`, which returned exactly the movie + /// (`TotalRecordCount: 1`) and supports the same sort, paging and `ParentId` + /// scoping. Those next episodes reach the UI through the Next Up shelf, which + /// is where Plezy models them. + Map get _resumeFilterQuery => dialect.resumeReturnsOnlyStartedItems + ? const {} + : const {'Filters': 'IsResumable', 'SortBy': 'DatePlayed', 'SortOrder': 'Descending'}; + + /// `/Items` on Emby (see [_resumeFilterQuery]); the dedicated route otherwise. + String get _resumePath => dialect.resumeReturnsOnlyStartedItems ? paths.resumeItems : '/Items'; + + /// Library-wide Next Up rows. + /// + /// Jellyfin computes this server-side. Emby only computes Next Up per series + /// — an unscoped `/Shows/NextUp` returns nothing there under any parameter + /// combination (see [MediaBrowserDialect.supportsGlobalNextUp]) — so this + /// reconstructs the shelf in two phases: + /// + /// 1. one `/Items` query for the most recently played episodes, newest first, + /// which yields the started series in exactly the order the shelf wants; + /// 2. one `/Shows/NextUp?SeriesId=` per distinct series, bounded by the same + /// concurrency and lookup cap [_attachSeriesLastPlayed] uses, and by one + /// [_seriesLastPlayedBudget] wall clock that covers phase 1 as well, so a + /// slow server cannot hold the home screen. + /// + /// Rows come back series-recency-ordered, which is what the caller's merge + /// expects, and a failed per-series lookup drops only that series. + Future>> _fetchNextUpRows( + Map queryParameters, { + _HubRetryPolicy? retry, + AbortController? abort, + }) async { + if (dialect.supportsGlobalNextUp) { + return _safeFetchItemsArray('/Shows/NextUp', queryParameters, retry: retry, abort: abort); + } + + final budgetAbort = AbortController(); + final deadline = Timer(_seriesLastPlayedBudget, budgetAbort.abort); + // The caller's cancellation has to reach the requests this pass owns, which + // all run under [budgetAbort]. + if (abort != null) unawaited(abort.trigger.then((_) => budgetAbort.abort())); + try { + return await _reconstructNextUpRows(queryParameters, abort: abort, budgetAbort: budgetAbort); + } finally { + deadline.cancel(); + budgetAbort.abort(); + } + } + + /// The reconstruction itself, under a caller-supplied wall-clock budget. + Future>> _reconstructNextUpRows( + Map queryParameters, { + required AbortController? abort, + required AbortController budgetAbort, + }) async { + final probe = _fetchRecentlyPlayedSeriesIds(parentId: queryParameters['ParentId'] as String?, abort: budgetAbort); + final List<(String, String?)> probed; + try { + // Raced, not just awaited: aborting is advisory at the transport, and the + // probe's own timeout applies to the connect and receive phases + // independently, so a delayed header plus a stalled body could outlive the + // deadline. Same policy as the per-series batches below. + probed = await Future.any([probe, budgetAbort.trigger.then((_) => const <(String, String?)>[])]); + } on MediaServerHttpException catch (e) { + // Our own deadline expiring is not a disrupted fetch: this shelf is + // best-effort, so an over-budget probe costs the Next Up half rather than + // the caller's whole request. A cancellation the *caller* asked for still + // propagates. + if (e.isCancellation && abort?.isAborted != true) return const []; + rethrow; + } finally { + // The loser may still fail later; its outcome is no longer anyone's. + probe.ignore(); + } + // Before the early return below: an empty probe can also mean the caller + // cancelled mid-request and the transport reported an ordinary timeout, which + // must not be published as "nothing to watch next". + abort?.throwIfAborted(); + + final recentSeries = _withinNextUpCutoff(probed, queryParameters['NextUpDateCutoff'] as String?); + if (recentSeries.isEmpty) return const []; + final seriesIds = [for (final (seriesId, _) in recentSeries) seriesId]; + + // Per-series queries carry the caller's field/image selection but never its + // paging: the shelf is assembled here and sliced by the caller. + final perSeriesQuery = { + for (final entry in queryParameters.entries) + if (entry.key != 'StartIndex' && entry.key != 'Limit' && entry.key != 'ParentId') entry.key: entry.value, + 'Limit': '1', + }; + + final rowsBySeries = >{}; + for (var start = 0; start < seriesIds.length; start += _seriesLastPlayedConcurrency) { + if (budgetAbort.isAborted || abort?.isAborted == true) break; + final batch = seriesIds.skip(start).take(_seriesLastPlayedConcurrency); + // Each lookup records itself as it completes, so a sibling that is still + // slow when the deadline fires costs only its own row. Awaiting the batch + // as one future would discard every result it had already collected. + final lookups = [ + for (final seriesId in batch) + _fetchSeriesNextUp(seriesId, perSeriesQuery, budgetAbort).then((result) { + final (id, row) = result; + if (row != null) rowsBySeries[id] = row; + }), + ]; + final batchDone = Future.wait(lookups); + await Future.any([batchDone, budgetAbort.trigger]); + // No-op once the batch has won; suppresses the loser's late completion. + batchDone.ignore(); + } + + // The loop breaks on either abort, and the caller's means disruption: a + // partial shelf must not be published as though the series had no next + // episode. Our own budget expiring keeps degrading to whatever was collected. + abort?.throwIfAborted(); + + // Reassemble in series-recency order rather than completion order, stamping + // each row with its series' newest play date. A Next Up episode has never + // been played, so its own `UserData` carries no date, and the shelf's sort + // key would otherwise fall back to when the episode was added to the + // library. The date comes from the same response that established this + // order, so no extra request is needed. + return [ + for (final (seriesId, lastPlayed) in recentSeries) + if (rowsBySeries[seriesId] case final row?) + if (lastPlayed == null) + row + else + { + ...row, + // Tolerant read: a dto whose `UserData` is absent or not an object + // still gets a date rather than raising past the shelf's guards. + 'UserData': { + ...?(row['UserData'] is Map ? row['UserData'] as Map : null), + 'LastPlayedDate': lastPlayed, + }, + }, + ]; + } + + /// Drop series whose newest play predates [cutoff], the window Jellyfin's own + /// `/Shows/NextUp` applies through `NextUpDateCutoff`. + /// + /// Measured on Emby 4.9.5: `/Shows/NextUp` ignores `NextUpDateCutoff` entirely + /// — a cutoff of 2030 still returned the row that a 2019 cutoff did, while + /// Jellyfin 10.11 returned nothing for a future cutoff. Without this the + /// reconstructed shelf would resurrect series the user abandoned years ago that + /// Jellyfin hides. + /// + /// Filtered here rather than on the server because Emby has no played-date + /// filter for this scan: `MinDatePlayed` and `MinDateLastPlayed` are both + /// silently ignored (`TotalRecordCount` unchanged at 53 with a cutoff of 2030), + /// while `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` do filter + /// but on unrelated dates. The scan's own cap therefore still counts rows + /// outside the window; that only shortens an already best-effort shelf. + /// + /// A series the server gave no date for is kept: the date is missing only when + /// the server withheld it, and an unfiltered row is a better failure than a + /// silently empty shelf. + List<(String, String?)> _withinNextUpCutoff(List<(String, String?)> series, String? cutoff) { + final threshold = cutoff == null ? null : DateTime.tryParse(cutoff); + if (threshold == null) return series; + return [ + for (final entry in series) + if (entry.$2 == null || !(DateTime.tryParse(entry.$2!)?.isBefore(threshold) ?? false)) entry, + ]; + } + + /// Distinct series ids behind the user's most recently played episodes, newest + /// first, capped at [_seriesLastPlayedLookupLimit], each paired with that + /// series' newest play date. + /// + /// `Filters=IsPlayed` + `SortBy=DatePlayed` is the only library-wide recency + /// signal Emby exposes for series: a series dto carries neither a usable + /// `PlayCount` nor a `DatePlayed` sort key, so the episodes have to supply the + /// order. Because the rows arrive newest-first, the first row naming a series + /// *is* that series' newest play, so this one request yields both the shelf + /// order and the timestamp each reconstructed row needs — no per-series + /// enrichment round trip. + Future> _fetchRecentlyPlayedSeriesIds({String? parentId, AbortController? abort}) async { + final rows = await _safeFetchItemsArray( + '/Items', + { + 'userId': connection.userId, + 'ParentId': ?parentId, + 'Recursive': 'true', + 'IncludeItemTypes': 'Episode', + 'Filters': 'IsPlayed', + 'SortBy': 'DatePlayed', + 'SortOrder': 'Descending', + // Eight played episodes per shelf slot: enough that a few binged series + // near the top do not starve the rest. A user who watched more than this + // window inside a single series gets a shorter shelf, never a wrong one — + // the series they were last watching still ranks first, which is the + // ordering the shelf exists to show. + 'Limit': (_seriesLastPlayedLookupLimit * 8).toString(), + 'Fields': _withDialectRowFields('SeriesId'), + 'EnableImages': 'false', + 'EnableTotalRecordCount': 'false', + }, + timeout: _seriesLastPlayedRequestTimeout, + abort: abort, + // Best-effort shelf: a timeout or 5xx here must not move the client's + // active endpoint, which is what every other hub leg also avoids. + // + // This [timeout] bounds a single request phase, not the probe: like every + // per-call timeout it applies to connect and receive independently. The + // wall clock comes from the caller's shared [_seriesLastPlayedBudget], + // which covers this probe and the per-series lookups together. + // + // Deliberately *not* the hub retry policy: `_getItemsResponse` ignores + // `timeout` on the retried path, which would leave this request bounded + // only by the 15s home / 20s library hub deadline. + allowEndpointFailover: false, + ); + + // Insertion-ordered map: preserves the server's recency ordering, and keeps + // each series' first (newest) play date rather than a later, older one. + final lastPlayedBySeries = {}; + for (final row in rows) { + final seriesId = row['SeriesId']; + if (seriesId is! String || seriesId.isEmpty || lastPlayedBySeries.containsKey(seriesId)) continue; + final userData = row['UserData']; + lastPlayedBySeries[seriesId] = userData is Map ? userData['LastPlayedDate'] as String? : null; + if (lastPlayedBySeries.length >= _seriesLastPlayedLookupLimit) break; + } + return [for (final entry in lastPlayedBySeries.entries) (entry.key, entry.value)]; + } + + /// The single Next Up episode for [seriesId], or null when the series has none + /// left — or when the lookup failed, which drops only this series. + Future<(String, Map?)> _fetchSeriesNextUp( + String seriesId, + Map queryParameters, + AbortController abort, + ) async { + final rows = await _safeFetchItemsArray( + '/Shows/NextUp', + {...queryParameters, 'SeriesId': seriesId}, + abort: abort, + timeout: _seriesLastPlayedRequestTimeout, + allowEndpointFailover: false, + ); + return (seriesId, rows.isEmpty ? null : rows.first); + } + /// Newest `LastPlayedDate` across [seriesId]'s episodes, or null when the /// series has never been played — or when the lookup failed, in which case the /// row keeps a null date and degrades to its `addedAt` in the shelf sort. diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 593aacbf..76005f9b 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -33,7 +33,7 @@ mixin _JellyfinLiveTvMethods on _JellyfinClientInternals { } return false; } catch (e) { - appLogger.d('Jellyfin Live TV probe failed', error: e); + appLogger.d('${dialect.productName} Live TV probe failed', error: e); return false; } } @@ -53,8 +53,8 @@ mixin _JellyfinLiveTvMethods on _JellyfinClientInternals { /// EPG / programs grid. [channelIds] scopes to specific channels (when /// empty, the server returns programs across all channels). [beginsAt] / - /// [endsAt] are epoch seconds and bound the time window — Jellyfin uses - /// ISO 8601 strings on the wire. + /// [endsAt] are epoch seconds and bound the time window — both MediaBrowser + /// dialects use ISO 8601 strings on the wire. Future> fetchLiveTvPrograms({ List channelIds = const [], int? beginsAt, @@ -143,7 +143,7 @@ mixin _JellyfinLiveTvMethods on _JellyfinClientInternals { LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this as JellyfinClient); } -/// Adapter from [LiveTvSupport] to Jellyfin channel/program helpers. +/// Adapter from [LiveTvSupport] to MediaBrowser channel/program helpers. class _JellyfinLiveTvSupport implements LiveTvSupport { final JellyfinClient _client; _JellyfinLiveTvSupport(this._client); @@ -178,8 +178,8 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { if (sources.isEmpty) return null; final firstSource = sources.first; if (firstSource is! Map) { - throw const PlaybackException( - 'Jellyfin returned invalid Live TV playback data', + throw PlaybackException( + '${_client.dialect.productName} returned invalid Live TV playback data', reason: PlaybackFailureReason.invalidPlaybackData, ); } @@ -192,12 +192,12 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { var liveStreamId = nonEmptyString(source['LiveStreamId']); final rawUrl = nonEmptyString(source['TranscodingUrl']); if (rawUrl == null) { - appLogger.w('Jellyfin Live TV negotiation returned no HLS transcode URL'); + appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL'); return null; } final rawUri = Uri.tryParse(rawUrl); if (rawUri == null || !rawUri.path.toLowerCase().endsWith('.m3u8')) { - appLogger.w('Jellyfin Live TV negotiation returned no HLS transcode URL'); + appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL'); return null; } final url = _client._withApiKey(rawUrl); @@ -222,8 +222,9 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { } /// SharedPreferences key for the locally-persisted favorite-channel list. - /// Keyed by the compound connection id (`{machineId}/{userId}`) so two - /// Jellyfin users on the same server don't share favorites. + /// Keyed by the compound connection id (`{machineId}/{userId}`) so users on + /// the same MediaBrowser server don't share favorites. + // Keep the legacy prefix: the connection id isolates both dialects, and changing it would lose Jellyfin ordering. String get _favoritesPrefsKey => 'jellyfin_fav_channels:${_client.connection.id}'; /// Legacy bare-machineId key, kept for one-shot migration. @@ -266,7 +267,11 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { } catch (error, stackTrace) { firstError ??= error; firstStackTrace ??= stackTrace; - appLogger.w('Failed to update a Jellyfin favorite channel', error: error, stackTrace: stackTrace); + appLogger.w( + 'Failed to update a ${_client.dialect.productName} favorite channel', + error: error, + stackTrace: stackTrace, + ); } } @@ -291,7 +296,7 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { } } -/// A Jellyfin live playback session: one negotiated HLS transcode URL plus +/// A MediaBrowser live playback session: one negotiated HLS transcode URL plus /// `/Sessions/Playing*` heartbeats via [JellyfinLiveSessionTracker]. No /// program-scoped session and no time-shift — [recover] re-opens the same /// negotiated URL. diff --git a/lib/services/jellyfin_client/parts/metadata_edit.dart b/lib/services/jellyfin_client/parts/metadata_edit.dart index 5e8a29d2..68116608 100644 --- a/lib/services/jellyfin_client/parts/metadata_edit.dart +++ b/lib/services/jellyfin_client/parts/metadata_edit.dart @@ -50,6 +50,14 @@ mixin _JellyfinMetadataEditMethods on _JellyfinClientInternals { return response.statusCode >= 200 && response.statusCode < 300; } + /// Upload custom artwork for [itemId]. + /// + /// The body must be base64 **text**, not the raw bytes: both dialects reject + /// a binary body with HTTP 500 (Emby 4.9.5 says so explicitly — `The input is + /// not a valid Base-64 string` — and Jellyfin 10.11 answers a bare + /// `Error processing request.`), and both accept the encoded form with 204. + /// The `Content-Type` still names the *image* type, which is how the server + /// decides the on-disk extension. Future uploadItemImage( String itemId, { required String imageType, @@ -58,7 +66,7 @@ mixin _JellyfinMetadataEditMethods on _JellyfinClientInternals { }) async { final response = await _http.post( '/Items/${_segment(itemId)}/Images/${_segment(imageType)}', - body: bytes, + body: base64Encode(bytes), headers: {'Content-Type': contentType}, ); throwIfHttpError(response); diff --git a/lib/services/jellyfin_client/parts/music.dart b/lib/services/jellyfin_client/parts/music.dart index 81f293ac..1d3aafab 100644 --- a/lib/services/jellyfin_client/parts/music.dart +++ b/lib/services/jellyfin_client/parts/music.dart @@ -66,13 +66,17 @@ mixin _JellyfinMusicMethods on _JellyfinClientInternals { return _mapItems(_itemsArray(response.data)); } - /// Lyrics for [track] from `/Audio/{id}/Lyrics`. Jellyfin's `LyricDto` + /// Lyrics for [track] from Jellyfin's `/Audio/{id}/Lyrics`. `LyricDto` /// carries per-line `Start` offsets in ticks when the source is an LRC / /// synced provider; `IsSynced` is absent on some server versions, so - /// synced-ness is inferred from any line carrying a `Start`. 404 means - /// the track has no lyrics → `null`. + /// synced-ness is inferred from any line carrying a `Start`. A Jellyfin 404 + /// means the track has no lyrics → `null`; Emby is rejected before the request. @override Future fetchLyrics(MediaItem track) async { + if (!dialect.supportsLyrics) { + // Emby 4.9.5 binds `Lyrics` as an audio container and starts a failing ffmpeg transcode, so this call is harmful. + return null; + } try { final response = await _http.get('/Audio/${_segment(track.id)}/Lyrics'); throwIfHttpError(response); diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index b045cfc6..326816ba 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -10,10 +10,10 @@ bool _canUseJellyfinStaticStreamFallback(Object error) { } mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { - /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters - /// at the item level (`raw['Chapters']`) and native skip segments through a - /// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort - /// so older servers still use chapter title fallback. + /// Backend-neutral [PlaybackExtras] for [itemId]. Both dialects expose + /// chapters at the item level (`raw['Chapters']`), while only Jellyfin exposes + /// native skip segments through `/MediaSegments/{itemId}`. Segment loading is + /// best-effort so unsupported and older servers use chapter title fallback. @override Future fetchPlaybackExtras( String itemId, { @@ -71,6 +71,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { required MediaItem item, required MediaSourceInfo mediaSource, }) async { + // Emby 4.9.5 has neither the `Trickplay` field nor the tile route; its capabilities stop URL construction here. if (!capabilities.scrubThumbnails) return null; final manifest = mediaSource.trickplayByWidth; if (manifest == null || manifest.isEmpty) return null; @@ -83,6 +84,10 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { } Future> _fetchMediaSegmentMarkers(String itemId) async { + if (!dialect.supportsMediaSegments) { + // Emby 4.9.5 returns 404 for `/MediaSegments/{itemId}`; empty markers preserve the chapter-name fallback. + return const []; + } final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId); try { return await fetchWithCacheFallback>( @@ -126,14 +131,13 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { return uri.replace(queryParameters: params).toString(); } - /// Jellyfin playback URL resolution. + /// MediaBrowser playback URL resolution. /// - /// Always POSTs `/Items/{id}/PlaybackInfo` so Jellyfin can resolve external + /// Always POSTs `/Items/{id}/PlaybackInfo` so the server can resolve external /// audio/subtitle streams server-side. Uses the returned `TranscodingUrl` /// when the caller asked for a capped quality; otherwise — and on any - /// DirectPlay decision — builds the static direct stream URL - /// (`/Videos/{id}/stream?Static=true&api_key=...`) itself, because Jellyfin - /// never returns a direct-play URL of its own. + /// DirectPlay decision — builds the shared static direct stream URL + /// (`/Videos/{id}/stream?Static=true&api_key=...`) itself. /// /// The returned `MediaSourceInfo` is what the player uses for track-picker /// labels and auto-track selection by language. @@ -764,13 +768,28 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { 'PlayMethod': playMethod ?? 'DirectPlay', 'RepeatMode': 'RepeatNone', 'PlaybackOrder': 'Default', - 'PlaySessionId': ?playSessionId, + 'PlaySessionId': ?_resolvePlaySessionId(playSessionId, itemId), 'LiveStreamId': ?liveStreamId, }, ); throwIfHttpError(response); } + /// Session id for a `/Sessions/Playing*` body. + /// + /// Normally the caller forwards the id returned by the PlaybackInfo + /// negotiation. Callers that never negotiated one — the offline + /// watch-progress sync, which replays a recorded position — leave it null, + /// which Emby rejects with HTTP 400 (see + /// [MediaBrowserDialect.requiresPlaySessionId]). The synthesized id is + /// derived from [itemId] so the started/progress/stopped triple of one replay + /// lands on a single server-side session row instead of orphaning each call. + String? _resolvePlaySessionId(String? playSessionId, String itemId) { + if (playSessionId != null) return playSessionId; + if (!dialect.requiresPlaySessionId) return null; + return 'plezy-replay-$itemId'; + } + /// Tell the server the user has started playing [itemId]. /// /// [duration] is accepted for interface symmetry with Plex but ignored — @@ -847,7 +866,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { 'MediaSourceId': ?mediaSourceId, 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), 'Failed': false, - 'PlaySessionId': ?playSessionId, + 'PlaySessionId': ?_resolvePlaySessionId(playSessionId, itemId), 'LiveStreamId': ?liveStreamId, }, ); diff --git a/lib/services/jellyfin_client/parts/playlists.dart b/lib/services/jellyfin_client/parts/playlists.dart index 0e82d83d..cc4c121f 100644 --- a/lib/services/jellyfin_client/parts/playlists.dart +++ b/lib/services/jellyfin_client/parts/playlists.dart @@ -37,16 +37,28 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals { return LibraryPage(items: const [], totalCount: 0, offset: offset); } + // Emby returns the entire item index when `/Items` carries a `MediaTypes` + // filter alongside `IncludeItemTypes=Playlist`, and never types a playlist + // DTO, so it lists every playlist and the requested type only decides how + // the results are labelled. See + // [MediaBrowserDialect.playlistsFilterByMediaType]. + final filterByMediaType = dialect.playlistsFilterByMediaType; + final labelType = requestedType.isEmpty ? 'video' : requestedType; + final response = await _http.get( '/Items', queryParameters: { 'userId': connection.userId, 'IncludeItemTypes': 'Playlist', 'Recursive': 'true', - 'MediaTypes': ?mediaType, + 'MediaTypes': ?(filterByMediaType ? mediaType : null), 'StartIndex': offset.toString(), 'Limit': pageSize.toString(), - 'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags', + // `DateModified` carries the modification time on Emby, which leaves + // `DateLastSaved` null for playlists. Unlike the detail route, the list + // route honours `Fields` strictly, so the mapper's fallback is dead + // unless the field is requested here. + 'Fields': 'Overview,DateCreated,DateLastSaved,DateModified,ChildCount,Tags', ...jellyfinImageQueryParameters, }, abort: abort, @@ -56,7 +68,7 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals { response.data, offset: offset, requestedSize: pageSize, - map: (raw) => raw.map(_playlistFromJson).toList(), + map: (raw) => raw.map((json) => _playlistFromJson(json, labelType: labelType)).toList(), ); } @@ -66,7 +78,7 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals { if (item == null) return null; return MediaPlaylist( id: item.id, - backend: MediaBackend.jellyfin, + backend: dialect.backend, title: item.title ?? t.playlists.playlist, summary: item.summary, smart: false, @@ -141,13 +153,13 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals { @override Future deletePlaylist(MediaPlaylist playlist) async { - // Jellyfin treats playlists as items — same delete endpoint. + // Both MediaBrowser dialects treat playlists as items — same delete endpoint. final response = await _http.delete('/Items/${_segment(playlist.id)}'); throwIfHttpError(response); return true; } - /// Jellyfin's move endpoint takes an absolute index, so [afterItem] is + /// The MediaBrowser move endpoint takes an absolute index, so [afterItem] is /// ignored — its sibling Plex impl needs it for `?after=`. The "wrong /// backend" / "missing playlistItemId" branches still return `false` /// (business not-applicable, not a network error) so callers can revert @@ -193,18 +205,23 @@ mixin _JellyfinPlaylistMethods on _JellyfinClientInternals { return true; } - MediaPlaylist _playlistFromJson(Map json) { + /// [labelType] backs `MediaType` when the server omits it — always the case + /// on Emby, which leaves playlists untyped. + MediaPlaylist _playlistFromJson(Map json, {String labelType = 'video'}) { final id = json['Id'] as String? ?? ''; return MediaPlaylist( id: id, - backend: MediaBackend.jellyfin, + backend: dialect.backend, title: json['Name'] as String? ?? t.playlists.playlist, summary: json['Overview'] as String?, smart: false, - playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? 'video', + playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? labelType, leafCount: json['ChildCount'] as int?, addedAt: jellyfinIsoToEpochSeconds(json['DateCreated'] as String?), - updatedAt: jellyfinIsoToEpochSeconds(json['DateLastSaved'] as String?), + // Emby leaves `DateLastSaved` null on a playlist and carries the + // timestamp in `DateModified`; the item and library mappers already use + // this same fallback. + updatedAt: jellyfinIsoToEpochSeconds(json['DateLastSaved'] as String? ?? json['DateModified'] as String?), thumbPath: _absolutizeImagePath(_imageTagPath(id, json['ImageTags'])), serverId: serverId, serverName: serverName, diff --git a/lib/services/jellyfin_client/parts/watch_state.dart b/lib/services/jellyfin_client/parts/watch_state.dart index eb362295..17a85477 100644 --- a/lib/services/jellyfin_client/parts/watch_state.dart +++ b/lib/services/jellyfin_client/parts/watch_state.dart @@ -3,38 +3,43 @@ part of '../../jellyfin_client.dart'; mixin _JellyfinWatchStateMethods on _JellyfinClientInternals { @override Future markWatched(MediaItem item) async { - final response = await _http.post( - '/UserPlayedItems/${_segment(item.id)}', - queryParameters: {'userId': connection.userId}, - ); + final response = await _http.post(paths.playedItem(item.id), queryParameters: {'userId': connection.userId}); throwIfHttpError(response); } @override Future markUnwatched(MediaItem item) async { - final response = await _http.delete( - '/UserPlayedItems/${_segment(item.id)}', - queryParameters: {'userId': connection.userId}, - ); + final response = await _http.delete(paths.playedItem(item.id), queryParameters: {'userId': connection.userId}); + throwIfHttpError(response); + } + + /// Hide [item] from Continue Watching while keeping its resume position. + /// + /// Emby-only. `POST /Users/{uid}/Items/{id}/HideFromResume?Hide=true` drops + /// the row from `/Users/{uid}/Items/Resume` and leaves + /// `UserData.PlaybackPositionTicks` untouched (verified on Emby 4.9.5). + /// Jellyfin 10.11 has no equivalent route, so it keeps throwing and + /// [ServerCapabilities.continueWatchingRemoval] keeps the affordance hidden. + @override + Future removeFromContinueWatching(MediaItem item) async { + if (!dialect.supportsContinueWatchingRemoval) { + throw UnsupportedError('${dialect.productName} does not support removing items from Continue Watching.'); + } + final response = await _http.post(paths.hideFromResume(item.id), queryParameters: {'Hide': 'true'}); throwIfHttpError(response); } - @override - Future removeFromContinueWatching(MediaItem item) async { - throw UnsupportedError('Jellyfin does not support removing items from Continue Watching.'); - } - @override Future rate(MediaItem item, double rating) async { - // Lossy mapping — Jellyfin only stores a binary like/dislike. Treat - // a negative input as "clear the rating" (DELETE), >= 6/10 as a like + // Lossy mapping — the MediaBrowser API only stores a binary like/dislike. + // Treat a negative input as "clear the rating" (DELETE), >= 6/10 as a like // (POST Likes=true), and the rest as a dislike (POST Likes=false). // No longer reachable from the rate sheet, which uses [setFavorite] - // for Jellyfin instead; kept as transport for the abstract member. + // for MediaBrowser servers instead; kept as transport for the abstract member. final response = rating < 0 - ? await _http.delete('/UserItems/${_segment(item.id)}/Rating', queryParameters: {'userId': connection.userId}) + ? await _http.delete(paths.itemRating(item.id), queryParameters: {'userId': connection.userId}) : await _http.post( - '/UserItems/${_segment(item.id)}/Rating', + paths.itemRating(item.id), queryParameters: {'userId': connection.userId, 'Likes': (rating >= 6.0).toString()}, ); throwIfHttpError(response); @@ -44,9 +49,9 @@ mixin _JellyfinWatchStateMethods on _JellyfinClientInternals { Future setFavorite(MediaItem item, bool isFavorite) => _setItemFavorite(item.id, isFavorite); /// Toggle the per-user `IsFavorite` flag for [itemId]. Backs [setFavorite] - /// and the live-TV favorite-channel adapter; works on any Jellyfin item. + /// and the live-TV favorite-channel adapter; works on either MediaBrowser dialect. Future _setItemFavorite(String itemId, bool isFavorite) async { - final path = '/UserFavoriteItems/${_segment(itemId)}'; + final path = paths.favoriteItem(itemId); final response = isFavorite ? await _http.post(path, queryParameters: {'userId': connection.userId}) : await _http.delete(path, queryParameters: {'userId': connection.userId}); diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index a7da25af..fa32259b 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -3,23 +3,28 @@ import 'dart:async'; import 'package:http/http.dart' as http; import '../exceptions/media_server_exceptions.dart'; +import '../media/media_browser_dialect.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`). +/// Result of a successful MediaBrowser server URL probe (`/System/Info/Public`). class JellyfinServerInfo { final String serverName; - /// Server's `Id` field — Jellyfin's machine identifier (UUID hex). + /// Server's `Id` field — its stable machine identifier. final String machineId; /// Server's reported version string. final String version; - const JellyfinServerInfo({required this.serverName, required this.machineId, required this.version}); + /// Dialect detected from public system info, or `null` when the response has + /// no trustworthy Jellyfin/Emby discriminator. + final MediaBrowserDialect? dialect; + + const JellyfinServerInfo({required this.serverName, required this.machineId, required this.version, this.dialect}); } class JellyfinEndpointRaceResult { @@ -100,8 +105,9 @@ class JellyfinEndpointUserInputCandidates { class JellyfinEndpointDiscovery { static const int defaultPort = 8096; - JellyfinEndpointDiscovery({this._testHttpClientFactory}); + JellyfinEndpointDiscovery({this.dialect = MediaBrowserDialect.jellyfin, this._testHttpClientFactory}); + final MediaBrowserDialect dialect; final http.Client Function()? _testHttpClientFactory; MediaServerHttpClient _buildHttpClient({required String baseUrl}) { @@ -140,10 +146,15 @@ class JellyfinEndpointDiscovery { 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?'); + throw MediaServerUrlException('Server response missing Id/ServerName — not a ${dialect.productName} server?'); } return ( - serverInfo: JellyfinServerInfo(serverName: name, machineId: id, version: data['Version'] as String? ?? ''), + serverInfo: JellyfinServerInfo( + serverName: name, + machineId: id, + version: data['Version'] as String? ?? '', + dialect: MediaBrowserDialect.detectFromPublicSystemInfo(data), + ), effectiveBaseUrl: effectiveBaseUrl, ); } on MediaServerUrlException { @@ -160,7 +171,7 @@ class JellyfinEndpointDiscovery { } } - /// Races public Jellyfin probes and returns persistence-safe endpoints. + /// Races public MediaBrowser server probes and returns persistence-safe endpoints. /// /// [baseUrlsToPersist] contains caller-selected persistence candidates. /// Candidates that reported another machine are excluded; candidates that @@ -175,7 +186,7 @@ class JellyfinEndpointDiscovery { }) async { final urls = normalizeBaseUrls(baseUrls); if (urls.isEmpty) { - throw MediaServerUrlException('Enter at least one Jellyfin server URL'); + throw MediaServerUrlException('Enter at least one ${dialect.productName} server URL'); } final persistUrls = baseUrlsToPersist == null ? urls : normalizeBaseUrls(baseUrlsToPersist); @@ -199,7 +210,7 @@ class JellyfinEndpointDiscovery { EndpointRaceSelection? bestSelection; await for (final selection in raceEndpointCandidates( - label: 'Jellyfin server URL', + label: '${dialect.productName} server URL', candidates: candidates, preferredUrl: preferred, urlOf: (candidate) => candidate.url, @@ -232,7 +243,7 @@ class JellyfinEndpointDiscovery { final selected = bestSelection ?? firstSelection; if (selected == null || selected.result.serverInfo == null) { - throw MediaServerUrlException('No reachable Jellyfin server found'); + throw MediaServerUrlException('No reachable ${dialect.productName} server found'); } final Map successfulResults = @@ -256,7 +267,7 @@ class JellyfinEndpointDiscovery { final selectedInfo = selectedResult.serverInfo; if (selectedInfo == null) { - throw MediaServerUrlException('No reachable Jellyfin server found'); + throw MediaServerUrlException('No reachable ${dialect.productName} server found'); } final expected = hasExpectedMachineId ? expectedMachineIdTrimmed! : selectedInfo.machineId; @@ -270,7 +281,7 @@ class JellyfinEndpointDiscovery { final candidate = _selectValidationCandidate(groupResults, expectedMachineId: expectedMachineIdTrimmed); final info = candidate == null ? null : groupResults[candidate]?.serverInfo; if (info != null && info.machineId != expected) { - throw MediaServerUrlException('The URLs point to different Jellyfin servers'); + throw MediaServerUrlException('The URLs point to different ${dialect.productName} servers'); } } } @@ -279,13 +290,13 @@ class JellyfinEndpointDiscovery { if (!validateUrlSet.contains(entry.key.url)) continue; final info = entry.value.serverInfo; if (info != null && info.machineId != expected) { - throw MediaServerUrlException('The URLs point to different Jellyfin servers'); + throw MediaServerUrlException('The URLs point to different ${dialect.productName} servers'); } } } if (selectedInfo.machineId != expected) { - throw MediaServerUrlException('The URL does not match this Jellyfin server'); + throw MediaServerUrlException('The URL does not match this ${dialect.productName} server'); } final effectiveUrls = {}; @@ -392,7 +403,7 @@ class JellyfinEndpointDiscovery { return _selectLowestLatencyCandidate(results); } - static String _resolveEffectiveBaseUrl(String requestedBaseUrl, MediaServerResponse response) { + String _resolveEffectiveBaseUrl(String requestedBaseUrl, MediaServerResponse response) { final requestedUri = response.requestUri; final effectiveUri = response.effectiveUri; if (requestedUri == null || effectiveUri == null || effectiveUri == requestedUri) { @@ -407,7 +418,9 @@ class JellyfinEndpointDiscovery { throw MediaServerUrlException('Server redirected to an unsupported URL'); } if (requestedBaseUri.host.toLowerCase() != effectiveUri.host.toLowerCase()) { - throw MediaServerUrlException('Server redirected to a different host. Enter the final Jellyfin URL directly'); + throw MediaServerUrlException( + 'Server redirected to a different host. Enter the final ${dialect.productName} URL directly', + ); } if (requestedBaseUri.scheme.toLowerCase() == 'https' && effectiveScheme != 'https') { throw MediaServerUrlException('Server redirected from HTTPS to an insecure URL'); @@ -415,18 +428,23 @@ class JellyfinEndpointDiscovery { const publicInfoPath = '/System/Info/Public'; if (!effectiveUri.path.endsWith(publicInfoPath)) { - throw MediaServerUrlException('Server redirected to an unsupported URL. Enter the final Jellyfin URL directly'); + throw MediaServerUrlException( + 'Server redirected to an unsupported URL. Enter the final ${dialect.productName} URL directly', + ); } final basePath = effectiveUri.path.substring(0, effectiveUri.path.length - publicInfoPath.length); return normalizeBaseUrl(effectiveUri.replace(path: basePath, query: null, fragment: null).toString()); } - /// Normalizes a concrete Jellyfin base URL without inventing a scheme or port. + /// Normalizes a concrete MediaBrowser base URL without inventing a scheme or port. static String normalizeBaseUrl(String input) => canonicalizeBaseUrl(input); /// Expands a user-typed add/edit form entry into temporary probe candidates. /// These guesses are for discovery only; failed guesses should not be stored. - static List expandInputToBaseUrls(String input) { + static List expandInputToBaseUrls( + String input, { + MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin, + }) { final trimmed = canonicalizeBaseUrl(input); if (trimmed.isEmpty) return const []; if (_hasScheme(trimmed)) return [trimmed]; @@ -448,7 +466,9 @@ class JellyfinEndpointDiscovery { } else { add(parsed.replace(scheme: 'http', port: defaultPort)); add(parsed.replace(scheme: 'https')); - add(parsed.replace(scheme: 'https', port: defaultPort)); + for (final port in dialect.httpsPortGuesses) { + add(parsed.replace(scheme: 'https', port: port)); + } add(parsed.replace(scheme: 'http')); } return List.unmodifiable(result); @@ -460,7 +480,10 @@ class JellyfinEndpointDiscovery { return raw.split(RegExp(r'[\n,]+')).map((url) => url.trim()).where((url) => url.isNotEmpty).toList(growable: false); } - static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable input) { + static JellyfinEndpointUserInputCandidates buildUserInputCandidates( + Iterable input, { + MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin, + }) { final probeBaseUrls = []; final explicitBaseUrls = []; final validationBaseUrlGroups = >[]; @@ -488,7 +511,7 @@ class JellyfinEndpointDiscovery { validationBaseUrlGroups.add([normalized]); } else { final group = []; - for (final candidate in expandInputToBaseUrls(normalized)) { + for (final candidate in expandInputToBaseUrls(normalized, dialect: dialect)) { addProbe(candidate); group.add(candidate); } diff --git a/lib/services/jellyfin_lan_discovery_service.dart b/lib/services/jellyfin_lan_discovery_service.dart index 7ff2644b..021a5e6a 100644 --- a/lib/services/jellyfin_lan_discovery_service.dart +++ b/lib/services/jellyfin_lan_discovery_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import '../media/media_browser_dialect.dart'; import '../utils/app_logger.dart'; import '../utils/udp_broadcast_sockets.dart'; import 'jellyfin_endpoint_discovery.dart'; @@ -10,17 +11,19 @@ class DiscoveredJellyfinServer { final String address; final String id; final String name; + final MediaBrowserDialect dialect; - DiscoveredJellyfinServer({required this.address, required this.id, required this.name}); + DiscoveredJellyfinServer({required this.address, required this.id, required this.name, required this.dialect}); } class JellyfinLanDiscoveryService { static const int discoveryPort = 7359; - static const String discoveryMessage = 'who is JellyfinServer?'; - /// Sends two discovery packets 350 ms apart, then listens for - /// [responseWindow] after the second packet. + /// Sends the selected dialect's discovery packet twice, 350 ms apart, then + /// listens for [responseWindow] after the second packet. Jellyfin and Emby + /// answer only their own payload, while both use the same response shape. Future> discover({ + required MediaBrowserDialect dialect, Duration responseWindow = const Duration(seconds: 2), InternetAddress? broadcastAddress, }) async { @@ -29,19 +32,19 @@ class JellyfinLanDiscoveryService { try { socketSet = await UdpBroadcastSockets.bind(); socketSet.listen((datagram) { - final server = parseDiscoveryResponse(datagram.data); + final server = parseDiscoveryResponse(datagram.data, dialect: dialect); if (server == null) return; discovered.putIfAbsent(server.id, () => server); - }, debugLabel: 'Jellyfin LAN discovery'); + }, debugLabel: '${dialect.productName} LAN discovery'); - final data = utf8.encode(discoveryMessage); + final data = utf8.encode(dialect.lanDiscoveryMessage); final target = broadcastAddress ?? UdpBroadcastSockets.limitedBroadcastAddress; socketSet.send(data, target, discoveryPort); await Future.delayed(const Duration(milliseconds: 350)); socketSet.send(data, target, discoveryPort); await Future.delayed(responseWindow); } catch (e, st) { - appLogger.w('Jellyfin LAN discovery failed', error: e, stackTrace: st); + appLogger.w('${dialect.productName} LAN discovery failed', error: e, stackTrace: st); } finally { await socketSet?.close(); } @@ -56,12 +59,14 @@ class JellyfinLanDiscoveryService { if (name != 0) return name; final address = a.address.compareTo(b.address); if (address != 0) return address; - return a.id.compareTo(b.id); + final id = a.id.compareTo(b.id); + if (id != 0) return id; + return a.dialect.id.compareTo(b.dialect.id); }); return List.unmodifiable(sorted); } - static DiscoveredJellyfinServer? parseDiscoveryResponse(List data) { + static DiscoveredJellyfinServer? parseDiscoveryResponse(List data, {required MediaBrowserDialect dialect}) { try { final decoded = jsonDecode(utf8.decode(data)); if (decoded is! Map) return null; @@ -73,7 +78,7 @@ class JellyfinLanDiscoveryService { final normalized = JellyfinEndpointDiscovery.normalizeBaseUrl(address); if (normalized.isEmpty || id.trim().isEmpty || name.trim().isEmpty) return null; - return DiscoveredJellyfinServer(address: normalized, id: id.trim(), name: name.trim()); + return DiscoveredJellyfinServer(address: normalized, id: id.trim(), name: name.trim(), dialect: dialect); } catch (_) { return null; } diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index 71c3af18..4246e1b4 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -1,4 +1,4 @@ -import '../media/media_backend.dart'; +import '../media/media_browser_dialect.dart'; import '../media/ids.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; @@ -180,17 +180,22 @@ class JellyfinMappers { return '/Items/${_segment(id)}/Images/$type$indexPart$tagPart'; } - /// Map a Jellyfin `BaseItemDto` (the `Items[]` shape returned by most + /// Map a MediaBrowser `BaseItemDto` (the `Items[]` shape returned by most /// browse endpoints) into a [MediaItem]. Returns `null` when the server /// payload is missing `Id` — the mapped item would otherwise carry an /// empty-string id that breaks cache keys and image URLs (e.g. /// `/Items//Images/Primary`). Callers should filter nulls with /// `.whereType()`. + /// + /// [dialect] stamps the produced item so downstream UI resolves the right + /// backend badge/label. Jellyfin and Emby DTOs are field-identical, so the + /// mapping itself is shared. static MediaItem? mediaItem( Map item, { required ServerId serverId, String? serverName, required JellyfinImageAbsolutizer? absolutizer, + MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin, }) { final id = item['Id'] as String?; if (id == null || id.isEmpty) return null; @@ -212,6 +217,7 @@ class JellyfinMappers { : [seriesBackdropPath]; final mapped = JellyfinMediaItem( + dialect: dialect, id: id, kind: kind, guid: id, @@ -273,13 +279,13 @@ class JellyfinMappers { rating: (item['CommunityRating'] as num?)?.toDouble(), ratings: _ratingSources(item, kind), isFavorite: _userData(item)?['IsFavorite'] as bool?, - genres: _stringList(item['Genres']), + genres: _stringListOrNamePairs(item['Genres'], item['GenreItems']), directors: _peopleByType(item['People'], 'Director'), writers: _peopleByType(item['People'], 'Writer'), producers: _peopleByType(item['People'], 'Producer'), countries: _stringList(item['ProductionLocations']), collections: null, - labels: _stringList(item['Tags']), + labels: _stringListOrNamePairs(item['Tags'], item['TagItems']), styles: null, moods: null, roles: _actors(item['People']), @@ -297,18 +303,23 @@ class JellyfinMappers { return absolutizer == null ? mapped : absolutizer.applyTo(mapped); } - /// Map a Jellyfin "view" (returned by `/Users/{userId}/Views`) into a + /// Map a MediaBrowser "view" (returned by `/Users/{userId}/Views`) into a /// [MediaLibrary]. The CollectionType field maps onto [MediaKind] roughly. /// Returns `null` when the view is missing `Id` — same rationale as /// [mediaItem]. - static MediaLibrary? library(Map view, {required ServerId serverId, String? serverName}) { + static MediaLibrary? library( + Map view, { + required ServerId serverId, + String? serverName, + MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin, + }) { final id = view['Id'] as String?; if (id == null || id.isEmpty) return null; final collectionType = view['CollectionType'] as String?; final type = view['Type'] as String?; return MediaLibrary( id: id, - backend: MediaBackend.jellyfin, + backend: dialect.backend, title: view['Name'] as String? ?? t.libraries.fallbackTitle, kind: _libraryKindFromCollectionType(collectionType, type), defaultBrowseKinds: _defaultBrowseKindsFromCollectionType(collectionType, type), @@ -451,6 +462,25 @@ class JellyfinMappers { return stringListFromRaw(list); } + /// A `Genres`/`Tags` style list, falling back to its `…Items` name-pair + /// sibling when the plain array is absent. + /// + /// Emby never returns the plain `Tags` array on an item DTO — only + /// `TagItems` — whatever `Fields` the request asks for (measured on Emby + /// 4.9.5), so reading the plain key alone silently drops every tag. + static List? _stringListOrNamePairs(Object? plain, Object? namePairs) { + final direct = stringListFromRaw(plain); + if (direct != null && direct.isNotEmpty) return direct; + if (namePairs is! List) return direct; + final result = []; + for (final entry in namePairs) { + if (entry is! Map) continue; + final name = entry['Name']; + if (name is String && name.trim().isNotEmpty) result.add(name.trim()); + } + return nullIfEmptyList(result) ?? direct; + } + static List? _peopleByType(Object? list, String type) { if (list is! List) return null; final result = []; diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart index 08267e31..2594debb 100644 --- a/lib/services/jellyfin_sequential_launcher.dart +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -17,14 +17,18 @@ import 'jellyfin_client.dart'; import 'media_list_playback_launcher.dart'; import 'playlist_items_loader.dart'; -/// Backend-neutral launcher for Jellyfin collections, playlists, and folders. +/// Backend-neutral launcher for MediaBrowser collections, playlists, and +/// folders. /// -/// Jellyfin has no server-side queue resource — the client fetches -/// children (collection) or playlist items, applies shuffle locally, -/// and hands the flat list to [PlaybackStateProvider] via -/// [PlaybackStateProvider.setPlaybackFromLocalQueue] which the player -/// already consumes (mirrors the path -/// [EpisodeNavigationService] uses for episode windows). +/// Jellyfin and Emby have no server-side queue resource — the client fetches +/// children (collection) or playlist items, applies shuffle locally, and hands +/// the flat list to [PlaybackStateProvider] via +/// [PlaybackStateProvider.setPlaybackFromLocalQueue], which the player already +/// consumes (mirrors the path [EpisodeNavigationService] uses for episode +/// windows). +/// +/// The persisted `jellyfin:` queue-id prefix predates Emby support and covers +/// both dialects. It must remain stable so existing saved queues keep working. class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { final BuildContext context; @@ -99,9 +103,10 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { ); } - /// Launch playback from a Jellyfin folder row. Jellyfin has no server-side - /// queue resource, so folders use the same local queue path as collections. - /// The client query is video-only; music-only folders return [PlayQueueEmpty]. + /// Launch playback from a MediaBrowser folder row. Neither dialect has a + /// server-side queue resource, so folders use the same local queue path as + /// collections. The client query is video-only; music-only folders return + /// [PlayQueueEmpty]. @override Future launchFromFolder({ required MediaItem folder, diff --git a/lib/services/media_browser_paths.dart b/lib/services/media_browser_paths.dart new file mode 100644 index 00000000..c3088d6a --- /dev/null +++ b/lib/services/media_browser_paths.dart @@ -0,0 +1,64 @@ +import '../media/media_browser_dialect.dart'; + +/// Route builders for the endpoints where the Jellyfin and Emby dialects of the +/// MediaBrowser API diverge. +/// +/// Jellyfin 10.9 renamed a batch of user-scoped routes to unprefixed forms +/// (`/Users/{uid}/PlayedItems/{id}` → `/UserPlayedItems/{id}`) and introduced +/// `/Users/Me`. Emby only ever shipped the user-scoped spellings and fails on +/// the new ones — `/Users/Me` returns 500 `Unrecognized Guid format` because +/// `Me` is bound as a user id, and the rest 404. Keeping every divergent route +/// here lets the client parts stay dialect-agnostic. +/// +/// Routes that are identical on both dialects (`/Items`, `/Users/{uid}/Views`, +/// `/Users/{uid}/Items/{id}`, `/Users/{uid}/Items/Latest`, `/Shows/*`, +/// `/Sessions/Playing*`, `/Items/{id}/PlaybackInfo`, image and stream routes) +/// deliberately do not appear here. +class MediaBrowserPaths { + const MediaBrowserPaths({required this.dialect, required this.userId}); + + final MediaBrowserDialect dialect; + final String userId; + + String get _user => '/Users/${Uri.encodeComponent(userId)}'; + + static String _id(String itemId) => Uri.encodeComponent(itemId); + + /// The authenticated user's own DTO — health probe and user-preference read. + String get currentUser => dialect.requiresUserScopedItemRoutes ? _user : '/Users/Me'; + + /// Continue Watching / resumable items. + String get resumeItems => dialect.requiresUserScopedItemRoutes ? '$_user/Items/Resume' : '/UserItems/Resume'; + + /// Played flag write route (`POST` to mark, `DELETE` to unmark). + String playedItem(String itemId) => + dialect.requiresUserScopedItemRoutes ? '$_user/PlayedItems/${_id(itemId)}' : '/UserPlayedItems/${_id(itemId)}'; + + /// Favourite flag write route (`POST` to add, `DELETE` to remove). + String favoriteItem(String itemId) => dialect.requiresUserScopedItemRoutes + ? '$_user/FavoriteItems/${_id(itemId)}' + : '/UserFavoriteItems/${_id(itemId)}'; + + /// Thumbs-up/down write route (`POST ?Likes=`, `DELETE` to clear). + String itemRating(String itemId) => + dialect.requiresUserScopedItemRoutes ? '$_user/Items/${_id(itemId)}/Rating' : '/UserItems/${_id(itemId)}/Rating'; + + /// Trailers attached to a movie/series. + String localTrailers(String itemId) => dialect.requiresUserScopedItemRoutes + ? '$_user/Items/${_id(itemId)}/LocalTrailers' + : '/Items/${_id(itemId)}/LocalTrailers'; + + /// Extras/behind-the-scenes children. + String specialFeatures(String itemId) => dialect.requiresUserScopedItemRoutes + ? '$_user/Items/${_id(itemId)}/SpecialFeatures' + : '/Items/${_id(itemId)}/SpecialFeatures'; + + /// Hide an item from Continue Watching without touching its playback + /// position (`?Hide=true` to hide, `?Hide=false` to restore). + /// + /// Emby-only: Jellyfin 10.11 has no equivalent under either spelling + /// (measured 404 for both `/UserItems/{id}/HideFromResume` and the + /// user-scoped form), which is why [ServerCapabilities.jellyfin] leaves + /// `continueWatchingRemoval` false while [ServerCapabilities.emby] sets it. + String hideFromResume(String itemId) => '$_user/Items/${_id(itemId)}/HideFromResume'; +} diff --git a/lib/services/media_list_playback_launcher.dart b/lib/services/media_list_playback_launcher.dart index 09e3b069..3ed110cc 100644 --- a/lib/services/media_list_playback_launcher.dart +++ b/lib/services/media_list_playback_launcher.dart @@ -46,10 +46,10 @@ class PlayQueueError extends PlayQueueResult { /// Backend-neutral playback launcher for collections and playlists. /// /// Plex uses server-side `/playQueues` (one round trip, server tracks -/// queue state). Jellyfin has no equivalent — the concrete Jellyfin launcher -/// builds an in-memory queue from playable descendants or playlist items. -/// [MediaListPlaybackLauncher.forItem] picks the implementation by inspecting -/// the item's backend. +/// queue state). MediaBrowser servers have no equivalent — the concrete +/// [JellyfinSequentialLauncher] builds an in-memory queue from playable +/// descendants or playlist items. [MediaListPlaybackLauncher.forItem] picks +/// the implementation by inspecting the item's backend. abstract class MediaListPlaybackLauncher { /// Launch playback from a collection (a [MediaItem] with /// `kind == MediaKind.collection`) or a [MediaPlaylist]. @@ -57,7 +57,7 @@ abstract class MediaListPlaybackLauncher { /// [startItem] (optional) starts playback at that item rather than the head /// of the queue — used by the playlist detail screen's "tap an item to /// start here" interaction. Plex passes it as `key` to `/playQueues`; - /// Jellyfin rotates the locally-built queue. Ignored when [shuffle] is + /// MediaBrowser rotates the locally-built queue. Ignored when [shuffle] is /// true. Future launchFromCollectionOrPlaylist({ required Object item, @@ -67,17 +67,17 @@ abstract class MediaListPlaybackLauncher { }); /// Launch shuffled playback for a show or season. Plex builds a server-side - /// `/playQueues` with `shuffle=1`; Jellyfin fetches the full episode list - /// via `fetchClientSideEpisodeQueue`, shuffles locally, and publishes - /// through `setPlaybackFromLocalQueue` (same path as the sequential - /// queue from `EpisodeNavigationService`). + /// `/playQueues` with `shuffle=1`; MediaBrowser fetches the full episode + /// list via `fetchClientSideEpisodeQueue`, shuffles locally, and publishes + /// through `setPlaybackFromLocalQueue` (same path as the sequential queue + /// from `EpisodeNavigationService`). Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}); /// Launch playback from a folder row of the library tree. Everything each /// backend needs is stamped onto [folder]: Plex builds a server-side /// `/playQueues` from [MediaItem.backendFolderKey] (returning a - /// [PlayQueueError] when the row carries none), Jellyfin fetches the - /// folder's playable descendants and publishes a local queue. + /// [PlayQueueError] when the row carries none), while MediaBrowser fetches + /// the folder's playable descendants and publishes a local queue. Future launchFromFolder({ required MediaItem folder, required bool shuffle, @@ -88,7 +88,7 @@ abstract class MediaListPlaybackLauncher { /// [MediaItem.backend] / [MediaPlaylist.backend]. static MediaListPlaybackLauncher forItem(BuildContext context, Object item) { final backend = _backendOf(item); - if (backend == MediaBackend.jellyfin) { + if (backend.usesMediaBrowserApi) { return JellyfinSequentialLauncher(context: context); } return PlexPlayQueueLauncher.forContext(context, item); diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 2235c380..8aab57ca 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -43,7 +43,7 @@ bool _isMediaServerAuthFailure(Object error) => /// The internal map and public accessors are typed against the /// [MediaServerClient] interface so consumers don't depend on the concrete /// backend. Onboarding helpers branch on backend (Plex `PlexServer`, -/// Jellyfin `JellyfinConnection`) and instantiate the matching client. +/// MediaBrowser `JellyfinConnection`) and instantiate the matching client. class MultiServerManager { MultiServerManager({ PlexClientFactory plexClientFactory = PlexClient.create, @@ -126,16 +126,18 @@ class MultiServerManager { } /// Whether [compoundId] is still the client bound as the active user for - /// [machineId]. Async Jellyfin work must re-check this before publishing a - /// result — a profile switch can rebind the machine mid-probe. + /// [machineId]. Async MediaBrowser work must re-check this before publishing + /// a result — a profile switch can rebind the machine mid-probe. bool _isActiveJellyfin(String machineId, String compoundId) => _activeJellyfinMachine[machineId] == compoundId; - /// All Jellyfin clients ever added, keyed by the compound connection id - /// (`{serverMachineId}/{userId}`). Lets two users on the same Jellyfin - /// server coexist — adding the second user's client won't tear down the - /// first user's in-flight operations. [_clients] holds the currently - /// "active" entry per machineId for everyone-pass-machineId-as-serverId - /// consumers (cache resolver, visibility filter, MediaItem.serverId). + /// All MediaBrowser clients ever added, keyed by the compound connection id + /// (`{serverMachineId}/{userId}`). This lets users and dialects coexist + /// without tearing down another connection's in-flight operations. [_clients] + /// holds the currently "active" entry per machineId for consumers that pass + /// the public machine id as the server id. + /// + /// These private members retain their Jellyfin-era names because one + /// [JellyfinClient] implements both the Jellyfin and Emby dialects. final Map _jellyfinByCompoundId = {}; final Map _activeJellyfinMachine = {}; final Map _jellyfinHealthByCompoundId = {}; @@ -159,15 +161,15 @@ class MultiServerManager { /// Debounce timer for connectivity events — collapses rapid network flapping Timer? _connectivityDebounce; - /// Get all registered server IDs (Plex + Jellyfin). + /// Get all registered server IDs (Plex + MediaBrowser). /// /// Sourced from [_clients] rather than [_plexServers] because /// [_plexServers] only holds the Plex-specific [PlexServer] structs - /// (host/port metadata used for connection-racing). Jellyfin connections + /// (host/port metadata used for connection-racing). MediaBrowser connections /// are registered as clients only — falling back to [_plexServers] would /// silently exclude them and callers (the active-profile binder, library /// refresh gates) would behave as if the manager were empty for - /// Jellyfin-only profiles. + /// MediaBrowser-only profiles. List get serverIds => _clients.keys.toList(); List get onlineServerIds => _serverStatus.entries.where((e) => e.value).map((e) => e.key).toList(); @@ -212,10 +214,9 @@ class MultiServerManager { return getClient(serverId); } - /// Get the [PlexClient] for a server, or `null` if the server is Jellyfin - /// (or not registered). Use for Plex-only flows (Live TV, server prefs, - /// endpoint optimization) that don't yet have a backend-neutral - /// equivalent on [MediaServerClient]. + /// Get the [PlexClient] for a server, or `null` if the server uses the + /// MediaBrowser API (or is not registered). Use for Plex-only flows that + /// don't yet have a backend-neutral equivalent on [MediaServerClient]. PlexClient? getPlexClient(ServerId serverId) { final client = _clients[serverId]; return client is PlexClient ? client : null; @@ -607,23 +608,23 @@ class MultiServerManager { return bound; } - /// Add a Jellyfin server backed by an authenticated [JellyfinConnection]. - /// Returns true on success. + /// Add a MediaBrowser server backed by an authenticated + /// [JellyfinConnection]. Returns true on success. /// /// When a live client already exists for the same compound id and the /// connection is equivalent (see [canReuseJellyfinClient]), that client is /// reused instead of recreated — profile rebinds re-add unchanged /// connections routinely, and tearing the client down would abort its - /// in-flight requests. A material change (token, deviceId, URL set) still - /// replaces the client. This mirrors the Plex rebind path, where + /// in-flight requests. A material change (dialect, token, deviceId, URL set) + /// still replaces the client. This mirrors the Plex rebind path, where /// [refreshTokensForProfile] reuses the online client via an in-place /// token update. /// - /// Jellyfin clients use the shared endpoint-racing flow when multiple URLs - /// are configured, then instantiate the client against the lowest-latency - /// reachable URL. + /// MediaBrowser 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 + /// Two users on the same MediaBrowser server are tracked separately in /// [_jellyfinByCompoundId]; only one is "active" per machineId at a time. /// Adding the second user's connection doesn't close the first user's /// client (preserves any in-flight operations on the prior profile). @@ -640,7 +641,7 @@ class MultiServerManager { var endpointSelectionValidated = false; if (connection.baseUrls.length > 1) { try { - final endpoint = await JellyfinEndpointDiscovery().raceEndpoints( + final endpoint = await JellyfinEndpointDiscovery(dialect: connection.dialect).raceEndpoints( connection.baseUrls, preferredUrl: connection.baseUrl, expectedMachineId: connection.serverMachineId, @@ -657,7 +658,7 @@ class MultiServerManager { endpointSelectionValidated = true; } catch (e, st) { appLogger.w( - 'Jellyfin endpoint race failed; using only the stored active endpoint', + '${connection.dialect.productName} endpoint race failed; using only the stored active endpoint', error: e.runtimeType, stackTrace: st, ); @@ -709,13 +710,20 @@ class MultiServerManager { _jellyfinHealthByCompoundId[compoundId] = health; _applyHealth(ServerId(machineId), health); - appLogger.i('Added Jellyfin server: ${resolvedConnection.serverName}${healthy ? '' : ' (unhealthy)'}'); + appLogger.i( + 'Added ${resolvedConnection.dialect.productName} server: ' + '${resolvedConnection.serverName}${healthy ? '' : ' (unhealthy)'}', + ); if (_connectivitySubscription == null && healthy) { _startNetworkMonitoring(); } return healthy; } catch (e, stackTrace) { - appLogger.e('Failed to add Jellyfin server ${connection.serverName}', error: e, stackTrace: stackTrace); + appLogger.e( + 'Failed to add ${connection.dialect.productName} server ${connection.serverName}', + error: e, + stackTrace: stackTrace, + ); return false; } } @@ -723,6 +731,7 @@ class MultiServerManager { /// Whether the live client bound to [live] can serve [incoming] without /// being recreated. Recreation is required when a field baked into the /// client at construction time changes: + /// - `dialect` controls route and capability behavior; /// - `accessToken` / `deviceId` are embedded in the auth headers when the /// HTTP client is built; /// - `baseUrls` fixes the failover candidate set. Compared as a set: both @@ -736,7 +745,8 @@ class MultiServerManager { /// precedes this check. @visibleForTesting static bool canReuseJellyfinClient({required JellyfinConnection live, required JellyfinConnection incoming}) { - return live.accessToken == incoming.accessToken && + return live.dialect == incoming.dialect && + live.accessToken == incoming.accessToken && live.deviceId == incoming.deviceId && setEquals(live.baseUrls.toSet(), incoming.baseUrls.toSet()); } @@ -857,8 +867,8 @@ class MultiServerManager { } /// Test connection health for all servers. The probe is backend-defined: - /// Plex hits `/identity` (HTTP 200), Jellyfin hits `/Users/Me` (auth-required) - /// so a server with a revoked token is correctly reported as offline. + /// Plex hits `/identity`; MediaBrowser uses the dialect's current-user route. + /// Both are auth-required so a revoked token is reported as offline. Future checkServerHealth() async { // Coalesce concurrent calls — return the in-flight future if one exists if (_activeHealthCheck != null) return _activeHealthCheck!; @@ -1104,17 +1114,19 @@ class MultiServerManager { } } - /// Attempt reconnection for a single offline Jellyfin server. + /// Attempt reconnection for a single offline MediaBrowser server. /// - /// Jellyfin has a single fixed base URL — there's no connection-racing to - /// run, just a health round-trip. The existing [JellyfinClient] is reused - /// (the access token persists in [JellyfinConnection]); on success we flip - /// the machine slot back to online so MediaServer-aware UI un-greys the + /// Reuse the existing [JellyfinClient], whose request-level failover owns its + /// endpoint set, and perform an authenticated health round-trip. On success, + /// flip the machine slot back to online so MediaServer-aware UI un-greys the /// entry. Future _reconnectJellyfinServer(String machineId, JellyfinClient client) async { final expectedCompoundId = client.connection.id; try { - appLogger.d('Attempting reconnection for Jellyfin server ${client.connection.serverName}'); + appLogger.d( + 'Attempting reconnection for ${client.connection.dialect.productName} server ' + '${client.connection.serverName}', + ); final status = await client.checkHealth(); _jellyfinHealthByCompoundId[expectedCompoundId] = status; if (!_isActiveJellyfin(machineId, expectedCompoundId)) { @@ -1211,9 +1223,9 @@ class MultiServerManager { }); } - /// Fire-and-forget safe: both backends' `checkHealth` catch every failure - /// and fold it into a [HealthStatus], and the scheduled reconnection guards - /// its own errors — this future must never complete with one. + /// Fire-and-forget safe: both concrete clients' `checkHealth` implementations + /// catch every failure and fold it into a [HealthStatus], and the scheduled + /// reconnection guards its own errors — this future must never complete with one. Future _verifyServerEndpointsExhausted(ServerId serverId) async { final client = _clients[serverId]; if (client == null || !_endpointHealthChecks.add(serverId)) return; diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 72d93f64..cc972c1d 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart'; import '../database/app_database.dart'; import '../database/download_operations.dart'; -import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; @@ -25,8 +24,8 @@ import 'watch_state_resolver.dart'; /// Service for managing offline watch progress and syncing it back to the /// owning server. Backend-neutral over [MediaServerClient] — Plex actions -/// hit `/:/scrobble` and `/:/timeline`, Jellyfin actions hit -/// `/UserPlayedItems/{id}` and `/Sessions/Playing*` through the same queue. +/// hit `/:/scrobble` and `/:/timeline`, while MediaBrowser actions use their +/// dialect's played-item route and `/Sessions/Playing*` through the same queue. /// /// Handles: /// - Queuing progress updates when offline @@ -53,7 +52,7 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Get watched threshold for a server. Cascades: /// 1. Plex's fetched server prefs (`/:/prefs`) - /// 2. Jellyfin's fixed [MediaServerClient.watchedThreshold] (0.9) + /// 2. MediaBrowser's fixed [MediaServerClient.watchedThreshold] (0.9) /// 3. Cached value in SettingsService (mirrored by PlexClient.fetchServerPrefs) /// 4. Default 90% double getWatchedThreshold(ServerId serverId) { @@ -61,9 +60,9 @@ class OfflineWatchSyncService extends ChangeNotifier { if (client is PlexClient && client.serverPrefs.isNotEmpty) { return client.watchedThreshold; } - if (client != null && client.backend != MediaBackend.plex) { - // Jellyfin (and any future neutral backend) — the client exposes a - // fixed threshold that mirrors the wire-protocol behaviour. + if (client != null && client.backend.usesMediaBrowserApi) { + // MediaBrowser clients expose the fixed threshold that mirrors their + // wire-protocol behaviour. return client.watchedThreshold; } // No client bound (offline) or Plex prefs not loaded yet — use the @@ -455,9 +454,9 @@ class OfflineWatchSyncService extends ChangeNotifier { } final client = _serverManager.getClient(ServerId(action.serverId)); if (client == null) return null; - if (client.backend == MediaBackend.jellyfin && client.cacheServerId != action.serverId) { + if (client.backend.usesMediaBrowserApi && client.cacheServerId != action.serverId) { appLogger.w( - 'Refusing to sync unscoped Jellyfin action ${action.id} for ${action.serverId}:${action.ratingKey}; ' + 'Refusing to sync unscoped MediaBrowser action ${action.id} for ${action.serverId}:${action.ratingKey}; ' 'no queued client scope is available', ); return null; @@ -574,15 +573,15 @@ class OfflineWatchSyncService extends ChangeNotifier { break; case 'progress': - // Push resumable progress, or a completed offline playback. Jellyfin's + // Push resumable progress, or a completed offline playback. // `/Sessions/Playing/Stopped` ignores events without an open session - // row, so non-Plex backends still get a lightweight Started call. + // row, so MediaBrowser backends still get a lightweight Started call. if (action.viewOffset != null) { final duration = action.duration == null ? null : Duration(milliseconds: action.duration!); final position = action.shouldMarkWatched && duration != null ? duration : Duration(milliseconds: action.viewOffset!); - if (!action.shouldMarkWatched || client.backend != MediaBackend.plex) { + if (!action.shouldMarkWatched || client.backend.usesMediaBrowserApi) { try { await client.reportPlaybackStarted(itemId: action.ratingKey, position: position, duration: duration); } catch (e) { @@ -603,8 +602,8 @@ class OfflineWatchSyncService extends ChangeNotifier { } // If progress exceeded threshold, also mark as watched. On backends - // that mark played from the stopped report above (Jellyfin) this only - // emits the local watch event — an explicit markWatched would + // that mark played from the stopped report above (MediaBrowser), this + // only emits the local watch event — an explicit markWatched would // double-scrobble via the Trakt plugin (#1287). if (action.shouldMarkWatched) { await client.markWatchedFromPlaybackStop(item); diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 24f948be..0466c3a7 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -3,7 +3,6 @@ import '../media/ids.dart'; import '../mpv/mpv.dart'; -import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; import '../media/media_source_info.dart'; @@ -17,17 +16,17 @@ import '../utils/watch_state_notifier.dart'; /// Tracks playback progress and reports it to the active media server. /// -/// Both Plex and Jellyfin go through the unified +/// Plex and both MediaBrowser dialects go through the unified /// [MediaServerClient.reportPlayback*] surface — Plex maps the three signals -/// onto `/:/timeline` updates with appropriate `state`, Jellyfin uses the -/// three `/Sessions/Playing*` endpoints. +/// onto `/:/timeline` updates with appropriate `state`, while MediaBrowser +/// uses the three `/Sessions/Playing*` endpoints. /// /// Local watched state flips as soon as the position crosses the client's /// [MediaServerClient.watchedThreshold] (per-server pref on Plex, fixed 90% on -/// Jellyfin). The *server-side* mark is a separate decision: both backends -/// already mark an item played from a threshold crossing they observe in the +/// MediaBrowser). The *server-side* mark is a separate decision: each backend +/// already marks an item played from a threshold crossing it observes in the /// reports this tracker sends, so an explicit mark is issued only for sessions -/// that gave them no such crossing (#1287, #1740). +/// that gave it no such crossing (#1287, #1740). class PlaybackProgressTracker { /// Server client for online progress updates (null when offline). Pinned /// for the tracker's lifetime — one playback session against the server @@ -429,7 +428,7 @@ class PlaybackProgressTracker { /// Records what the backend actually received, then re-evaluates whether the /// explicit mark is still needed. /// - /// Both backends mark an item played from a watched-threshold *crossing* + /// Every supported backend marks an item played from a watched-threshold *crossing* /// observed inside a single reporting session — a report below the threshold /// followed by one at or above it. Absolute position is not enough: a session /// whose every report sits above the threshold, or one resuming past it, is @@ -579,7 +578,7 @@ class PlaybackProgressTracker { int? _currentAudioStreamIndex(MediaSourceInfo info) { final playerAudioTracks = player.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList(); - if (metadata.backend == MediaBackend.jellyfin && + if (metadata.backend.usesMediaBrowserApi && (info.audioTracks.any((track) => track.isExternal) || playerAudioTracks.length <= 1)) { final selectedSourceTrack = _selectedSourceAudioTrack(info); if (selectedSourceTrack != null) return selectedSourceTrack.id; diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index 05f4754c..ac24ea6d 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -22,7 +22,7 @@ import 'plex_mappers.dart'; /// endpoint shape and parse cached JSON into [MediaItem] via /// [PlexMappers.mediaItemFromCacheJson]. class PlexApiCache extends ApiCache { - static final _singleton = ApiCacheSingleton(MediaBackend.plex, 'PlexApiCache'); + static final _singleton = ApiCacheSingleton(const {MediaBackend.plex}, 'PlexApiCache'); static PlexApiCache get instance => _singleton.instance; PlexApiCache._(super.db); diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 056b80e8..0bfe25f4 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -1037,7 +1037,7 @@ class TrackSelectionService { if (matchedMpvTrack != null) { return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected); } - } else if (metadata.backend == MediaBackend.jellyfin) { + } else if (metadata.backend.usesMediaBrowserApi) { final defaultStreamIndex = info.defaultAudioStreamIndex; final defaultTrack = defaultStreamIndex != null ? info.audioTracks @@ -1155,7 +1155,7 @@ class TrackSelectionService { } // Priority 2: Trust the server's selected track. Plex computes this from - // account/show/per-item prefs; Jellyfin exposes DefaultSubtitleStreamIndex. + // account/show/per-item prefs; MediaBrowser exposes DefaultSubtitleStreamIndex. final info = plexMediaInfo; if (info != null) { final serverSelectedTrack = info.subtitleTracks.where((track) => track.selected).firstOrNull; @@ -1177,7 +1177,7 @@ class TrackSelectionService { if (waitForPendingSource && !_hasCompleteDirectSourceCatalogFor(serverSelectedTrack, availableTracks)) { return null; } - } else if (metadata.backend == MediaBackend.jellyfin) { + } else if (metadata.backend.usesMediaBrowserApi) { final defaultStreamIndex = info.defaultSubtitleStreamIndex; if (defaultStreamIndex == -1) { return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.serverSelected); @@ -1209,7 +1209,7 @@ class TrackSelectionService { } // Priority 3: Apply server profile subtitle mode when the backend exposes - // one (Jellyfin). Plex keeps using the selected-stream path above. + // one (MediaBrowser). Plex keeps using the selected-stream path above. final profileSelectedTrack = _selectSubtitleTrackByProfile(availableTracks, selectedAudioTrack); if (profileSelectedTrack != null) return profileSelectedTrack; diff --git a/lib/widgets/backend_badge.dart b/lib/widgets/backend_badge.dart index 3f6d6f66..35884be6 100644 --- a/lib/widgets/backend_badge.dart +++ b/lib/widgets/backend_badge.dart @@ -3,8 +3,8 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../media/media_backend.dart'; -/// Tiny SVG badge for a [MediaBackend] (Plex chevron / Jellyfin mark). -/// Both assets render in `currentColor` so they pick up whatever foreground +/// Tiny SVG badge for a [MediaBackend] (Plex chevron / Jellyfin or Emby mark). +/// All assets render in `currentColor` so they pick up whatever foreground /// the parent provides — pass [color] to override, otherwise inherits from /// [DefaultTextStyle] / `IconTheme`. class BackendBadge extends StatelessWidget { @@ -24,6 +24,7 @@ class BackendBadge extends StatelessWidget { final asset = switch (backend) { MediaBackend.plex => 'assets/plex_chevron.svg', MediaBackend.jellyfin => 'assets/jellyfin_icon.svg', + MediaBackend.emby => 'assets/emby_icon.svg', }; return SvgPicture.asset( asset, diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart index 81820f02..c75e4833 100644 --- a/lib/widgets/library_management_sheet.dart +++ b/lib/widgets/library_management_sheet.dart @@ -99,9 +99,9 @@ Future showLibraryManagementSheet( } List _getLibraryMenuItems(MediaLibrary library) { - // Refresh metadata is the only admin action both backends support — Plex - // hits `/library/sections/{id}/refresh?force=1`, Jellyfin posts to - // `/Items/{id}/Refresh` (the library view is itself an item). + // Refresh metadata is the only admin action every backend supports — Plex + // hits `/library/sections/{id}/refresh?force=1`; MediaBrowser servers post + // to `/Items/{id}/Refresh` (the library view is itself an item). final refresh = ContextMenuItem( value: 'refresh', icon: Symbols.sync_rounded, @@ -112,7 +112,7 @@ List _getLibraryMenuItems(MediaLibrary library) { isDestructive: true, ); // Scan / analyze / empty trash hit Plex-only endpoints, so backend - // capability gating keeps them out of Jellyfin menus. The library-qualified + // capability gating keeps them out of MediaBrowser menus. The library-qualified // resolver independently requires the exact owning Plex server. if (library.backend != MediaBackend.plex) return [refresh]; return [ diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index d939b1db..d19c27cd 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -84,13 +84,13 @@ bool isAdminActionAllowedForMediaItem({ /// Whether "Delete from server" may be offered for an item. /// -/// Deliberately not folded into [isAdminActionAllowedForMediaItem]: on Jellyfin +/// Deliberately not folded into [isAdminActionAllowedForMediaItem]: on MediaBrowser servers /// the admin bit says nothing about deletion. `BaseItem.IsAuthorizedToDelete` /// consults `EnableContentDeletion` and the per-library grant only, and only /// the auto-created first user gets the former for free — so an administrator /// can lack the right (issue #1749) and a plain user can hold it. The server's /// per-item answer ([resolvedItemPermission], from -/// [MediaDeletionPermissionClient]) is therefore the sole Jellyfin condition, +/// [MediaDeletionPermissionClient]) is therefore the sole MediaBrowser condition, /// and anything unknown — offline, request failed, timed out, item invisible — /// stays hidden rather than offering a button that 401s. /// @@ -102,7 +102,7 @@ bool isMediaDeletionAllowed({ required bool isAdminActionAllowed, }) => switch (itemBackend) { null => false, - MediaBackend.jellyfin => resolvedItemPermission == true, + MediaBackend.jellyfin || MediaBackend.emby => resolvedItemPermission == true, MediaBackend.plex => isAdminActionAllowed, }; @@ -274,7 +274,7 @@ class MediaContextMenuState extends State { final isCollection = mediaKind == MediaKind.collection; // Backend-aware gate: a few menu items remain Plex-only because the - // server-side feature has no Jellyfin equivalent (match/unmatch). + // server-side feature has no MediaBrowser equivalent (match/unmatch). // No fallback: items without a backend marker show only neutral actions — // dispatching a Plex-only action against an unknown-backend item could // crash or hit the wrong server. @@ -290,7 +290,7 @@ class MediaContextMenuState extends State { // Check if user has admin privileges. Backend-neutral: Plex uses the // server-owned flag (folded with the active Plex Home profile's admin - // bit, when applicable); Jellyfin uses `JellyfinConnection.isAdministrator` + // bit, when applicable); MediaBrowser servers use `JellyfinConnection.isAdministrator` // captured at sign-in. final multiServerProvider = Provider.of(context, listen: false); final activeProfile = context.read().active; @@ -476,7 +476,7 @@ class MediaContextMenuState extends State { ); } - // Match / Unmatch — Plex-only (Jellyfin doesn't expose match agents). + // Match / Unmatch — Plex-only (MediaBrowser servers don't expose match agents). if (isPlex && isAdmin && (mediaKind == MediaKind.movie || mediaKind == MediaKind.show)) { final isUnmatched = _isUnmatched(mediaItem); menuActions.add( @@ -492,8 +492,8 @@ class MediaContextMenuState extends State { } // Remove from Collection (only when viewing items within a collection). - // Plex-only — uses `removeFromCollection` API; Jellyfin's collection - // membership API isn't wired here yet. + // Plex-only — uses `removeFromCollection` API; MediaBrowser collection + // membership APIs aren't wired here yet. if (isPlex && widget.collectionId != null) { menuActions.add( _MenuAction( @@ -620,7 +620,7 @@ class MediaContextMenuState extends State { // Add to... (for episodes, movies, shows, and seasons). Plex-only — // uses `buildMetadataUri` + `addToPlaylist` / `addToCollection`. The - // Jellyfin item-add API is different and not wired here yet. + // MediaBrowser item-add APIs are different and not wired here yet. if (isPlex && (mediaKind == MediaKind.episode || mediaKind == MediaKind.movie || @@ -630,9 +630,9 @@ class MediaContextMenuState extends State { } // Delete media item (for episodes, movies, shows, and seasons). Routed - // through `MediaServerClient.deleteMediaItem`, which both Plex and - // Jellyfin implement (DELETE /library/metadata/{id} and - // DELETE /Items/{id} respectively); the kind and permission checks were + // through `MediaServerClient.deleteMediaItem`, which every backend + // implements (DELETE /library/metadata/{id} for Plex and + // DELETE /Items/{id} for MediaBrowser servers); the kind and permission checks were // resolved together above. if (canDeleteFromServer) { menuActions.add( diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index b12727fd..31426d66 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -568,6 +568,7 @@ class _RatingBottomSheetState extends State { String _backendLabel(MediaBackend backend) => switch (backend) { MediaBackend.plex => 'Plex', MediaBackend.jellyfin => 'Jellyfin', + MediaBackend.emby => 'Emby', }; } diff --git a/pubspec.yaml b/pubspec.yaml index 7b7a3ba3..a0f7683e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -148,6 +148,7 @@ flutter: - assets/plezy_adaptive_foreground.svg - assets/plex_chevron.svg - assets/jellyfin_icon.svg + - assets/emby_icon.svg - assets/trakt_circlemark.svg - assets/mal_mark.svg - assets/anilist_mark.svg diff --git a/test/connection/connection_models_test.dart b/test/connection/connection_models_test.dart index a23ae2e6..d6c330d8 100644 --- a/test/connection/connection_models_test.dart +++ b/test/connection/connection_models_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; /// Backend-agnostic [Connection] sealed-class tests. The /// `connection_registry_test` already covers DB persistence; these focus on @@ -16,12 +17,22 @@ void main() { }); test('fromId throws on unknown id (no silent fallback)', () { - expect(() => ConnectionKind.fromId('emby'), throwsA(isA())); + expect(() => ConnectionKind.fromId('kodi'), throwsA(isA())); }); test('backend mapping is total', () { expect(ConnectionKind.plex.backend, MediaBackend.plex); expect(ConnectionKind.jellyfin.backend, MediaBackend.jellyfin); + expect(ConnectionKind.emby.backend, MediaBackend.emby); + }); + + test('dialect maps only the MediaBrowser kinds and round-trips', () { + expect(ConnectionKind.plex.dialect, isNull); + expect(ConnectionKind.jellyfin.dialect, MediaBrowserDialect.jellyfin); + expect(ConnectionKind.emby.dialect, MediaBrowserDialect.emby); + for (final dialect in MediaBrowserDialect.values) { + expect(ConnectionKind.fromDialect(dialect).dialect, dialect); + } }); }); @@ -178,9 +189,59 @@ void main() { }); test('kind and backend match Jellyfin', () { + expect(base.dialect, MediaBrowserDialect.jellyfin); expect(base.kind, ConnectionKind.jellyfin); expect(base.backend, MediaBackend.jellyfin); }); + + test('an Emby dialect drives kind, backend and the persisted discriminator', () { + final emby = base.copyWith(dialect: MediaBrowserDialect.emby); + + expect(emby.kind, ConnectionKind.emby); + expect(emby.kind.id, 'emby'); + expect(emby.backend, MediaBackend.emby); + // The dialect lives in the `connections.kind` column, never in the + // encrypted config payload, so exactly one discriminator is on disk. + expect(emby.toConfigJson().containsKey('dialect'), isFalse); + }); + + test('fromConfigJson restores the dialect handed in by the registry', () { + final restored = JellyfinConnection.fromConfigJson( + id: 'srv-1/user-1', + json: base.toConfigJson(), + status: ConnectionStatus.online, + createdAt: base.createdAt, + dialect: MediaBrowserDialect.emby, + ); + + expect(restored.dialect, MediaBrowserDialect.emby); + expect(restored.kind, ConnectionKind.emby); + expect(restored.accessToken, 'tok-abc'); + }); + + test('fromConfigJson defaults to Jellyfin for rows written before Emby support', () { + final restored = JellyfinConnection.fromConfigJson( + id: 'legacy', + json: const {'baseUrl': 'https://jellyfin.example.com'}, + status: ConnectionStatus.unknown, + createdAt: DateTime.utc(2026), + ); + + expect(restored.dialect, MediaBrowserDialect.jellyfin); + expect(restored.kind, ConnectionKind.jellyfin); + }); + + test('empty-payload serverName falls back to the dialect product name', () { + final emby = JellyfinConnection.fromConfigJson( + id: 'orphan', + json: const {}, + status: ConnectionStatus.unknown, + createdAt: DateTime.utc(2026), + dialect: MediaBrowserDialect.emby, + ); + + expect(emby.serverName, 'Emby'); + }); }); group('PlexAccountConnection serialization', () { diff --git a/test/connection/connection_registry_test.dart b/test/connection/connection_registry_test.dart index 1a50beac..c479f5a3 100644 --- a/test/connection/connection_registry_test.dart +++ b/test/connection/connection_registry_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; import 'package:plezy/services/credential_vault.dart'; import 'package:plezy/services/plex_auth_service.dart'; @@ -34,6 +35,21 @@ JellyfinConnection _jellyfin({String id = 'srv-1', String userName = 'edde', int ); } +JellyfinConnection _emby({String id = 'emby-1', String accessToken = 'emby-token', int createdAtMs = 1_000_000}) { + return JellyfinConnection( + id: id, + baseUrl: 'https://emby.local', + serverName: 'Emby Home', + serverMachineId: 'emby-machine-$id', + userId: 'user-$id', + userName: 'edde', + accessToken: accessToken, + deviceId: 'dev-1', + dialect: MediaBrowserDialect.emby, + createdAt: DateTime.fromMillisecondsSinceEpoch(createdAtMs), + ); +} + PlexAccountConnection _plex({String id = 'plex-1'}) { return PlexAccountConnection( id: id, @@ -105,18 +121,49 @@ void main() { expect((jelly as JellyfinConnection).baseUrl, 'https://jellyfin.local'); }); + test('Emby upsert preserves its persisted discriminator and connection dialect', () async { + await registry.upsert(_emby(id: 'e')); + + final restored = await registry.get('e') as JellyfinConnection; + final row = await (db.select(db.connections)..where((table) => table.id.equals('e'))).getSingle(); + + expect(restored.dialect, MediaBrowserDialect.emby); + expect(restored.kind, ConnectionKind.emby); + expect(restored.kind.id, 'emby'); + expect(row.kind, 'emby'); + }); + + test('Jellyfin and Emby rows coexist and round-trip to their own dialects', () async { + await registry.upsert(_jellyfin(id: 'j')); + await registry.upsert(_emby(id: 'e')); + + final jellyfin = await registry.get('j') as JellyfinConnection; + final emby = await registry.get('e') as JellyfinConnection; + final rows = await db.select(db.connections).get(); + final kindById = {for (final row in rows) row.id: row.kind}; + + expect(jellyfin.dialect, MediaBrowserDialect.jellyfin); + expect(jellyfin.kind, ConnectionKind.jellyfin); + expect(emby.dialect, MediaBrowserDialect.emby); + expect(emby.kind, ConnectionKind.emby); + expect(kindById, {'j': 'jellyfin', 'e': 'emby'}); + }); + test('upsert encrypts tokens at rest and decrypts on read', () async { await registry.upsert(_plex(id: 'p')); await registry.upsert(_jellyfin(id: 'j')); + await registry.upsert(_emby(id: 'e', accessToken: 'emby-raw-token')); final rows = await db.select(db.connections).get(); expect(rows.singleWhere((r) => r.id == 'p').configJson, isNot(contains('tok-p'))); expect(rows.singleWhere((r) => r.id == 'p').configJson, isNot(contains('server-token-p'))); expect(rows.singleWhere((r) => r.id == 'j').configJson, isNot(contains('tok-j'))); + expect(rows.singleWhere((r) => r.id == 'e').configJson, isNot(contains('emby-raw-token'))); expect((await registry.get('p') as PlexAccountConnection).accountToken, 'tok-p'); expect((await registry.get('p') as PlexAccountConnection).servers.single.accessToken, 'server-token-p'); expect((await registry.get('j') as JellyfinConnection).accessToken, 'tok-j'); + expect((await registry.get('e') as JellyfinConnection).accessToken, 'emby-raw-token'); }); test('read migrates legacy plaintext Plex server tokens', () async { diff --git a/test/media/media_browser_dialect_test.dart b/test/media/media_browser_dialect_test.dart new file mode 100644 index 00000000..8b293945 --- /dev/null +++ b/test/media/media_browser_dialect_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; + +/// Contract tests for the Jellyfin/Emby dialect discriminator. +/// +/// The detection fixtures are verbatim `/System/Info/Public` bodies captured +/// from Jellyfin 10.10.7 and Emby 4.9.5, so a shape change on either server +/// surfaces here rather than as a mis-labelled connection. +void main() { + group('MediaBrowserDialect ids', () { + test('id round-trips through fromId', () { + for (final dialect in MediaBrowserDialect.values) { + expect(MediaBrowserDialect.fromId(dialect.id), dialect); + } + }); + + test('fromId throws on an unknown id', () { + expect(() => MediaBrowserDialect.fromId('plex'), throwsA(isA())); + }); + + test('ids match the MediaBackend ids they map to', () { + for (final dialect in MediaBrowserDialect.values) { + expect(dialect.backend.id, dialect.id); + expect(dialect.backend.dialect, dialect); + } + }); + + test('fromIdOrJellyfin tolerates legacy rows that carry no dialect', () { + expect(MediaBrowserDialect.fromIdOrJellyfin(null), MediaBrowserDialect.jellyfin); + expect(MediaBrowserDialect.fromIdOrJellyfin(''), MediaBrowserDialect.jellyfin); + expect(MediaBrowserDialect.fromIdOrJellyfin('nonsense'), MediaBrowserDialect.jellyfin); + expect(MediaBrowserDialect.fromIdOrJellyfin('emby'), MediaBrowserDialect.emby); + }); + }); + + group('MediaBrowserDialect capabilities', () { + test('Jellyfin-only features are off for Emby', () { + expect(MediaBrowserDialect.jellyfin.supportsQuickConnect, isTrue); + expect(MediaBrowserDialect.emby.supportsQuickConnect, isFalse); + + expect(MediaBrowserDialect.jellyfin.supportsTrickplay, isTrue); + expect(MediaBrowserDialect.emby.supportsTrickplay, isFalse); + + expect(MediaBrowserDialect.jellyfin.supportsMediaSegments, isTrue); + expect(MediaBrowserDialect.emby.supportsMediaSegments, isFalse); + + // Emby resolves /Audio/{id}/Lyrics to audio streaming with `Lyrics` as + // the container and starts a failing ffmpeg process, so this gate is + // load-bearing rather than cosmetic. + expect(MediaBrowserDialect.jellyfin.supportsLyrics, isTrue); + expect(MediaBrowserDialect.emby.supportsLyrics, isFalse); + + expect(MediaBrowserDialect.jellyfin.supportsAggregateItemFilters, isTrue); + expect(MediaBrowserDialect.emby.supportsAggregateItemFilters, isFalse); + }); + + test('only Emby needs the pre-10.9 user-scoped item routes', () { + expect(MediaBrowserDialect.emby.requiresUserScopedItemRoutes, isTrue); + expect(MediaBrowserDialect.jellyfin.requiresUserScopedItemRoutes, isFalse); + }); + + test('LAN discovery payloads are distinct so the datagram identifies the dialect', () { + expect(MediaBrowserDialect.jellyfin.lanDiscoveryMessage, 'who is JellyfinServer?'); + expect(MediaBrowserDialect.emby.lanDiscoveryMessage, 'who is EmbyServer?'); + }); + + test('Emby adds its 8920 HTTPS default to the port guesses', () { + expect(MediaBrowserDialect.jellyfin.httpsPortGuesses, [8096]); + expect(MediaBrowserDialect.emby.httpsPortGuesses, contains(8920)); + expect(MediaBrowserDialect.emby.httpsPortGuesses, contains(8096)); + }); + + test('product names are the untranslated brand names', () { + expect(MediaBrowserDialect.jellyfin.productName, 'Jellyfin'); + expect(MediaBrowserDialect.emby.productName, 'Emby'); + }); + }); + + group('MediaBrowserDialect.detectFromPublicSystemInfo', () { + test('identifies a real Jellyfin 10.10.7 body by ProductName', () { + expect( + MediaBrowserDialect.detectFromPublicSystemInfo(const { + 'LocalAddress': 'http://172.17.0.3:8096', + 'ServerName': '0c1d332b2f44', + 'Version': '10.10.7', + 'ProductName': 'Jellyfin Server', + 'OperatingSystem': '', + 'Id': 'c88f271ded7e42cf87e6b12c287906ac', + 'StartupWizardCompleted': true, + }), + MediaBrowserDialect.jellyfin, + ); + }); + + test('identifies a real Emby 4.9.5 body by its RemoteAddresses array', () { + expect( + MediaBrowserDialect.detectFromPublicSystemInfo(const { + 'LocalAddresses': [], + 'RemoteAddresses': [], + 'ServerName': '7befeeb2e8c9', + 'Version': '4.9.5.0', + 'Id': '9b6b1ea5ad4c4409a89f0f5e40607022', + }), + MediaBrowserDialect.emby, + ); + }); + + test('an explicit Emby ProductName wins over shape sniffing', () { + expect( + MediaBrowserDialect.detectFromPublicSystemInfo(const {'ProductName': 'Emby Server', 'Id': 'x'}), + MediaBrowserDialect.emby, + ); + }); + + test('returns null when neither signal is present so the caller keeps the user choice', () { + expect(MediaBrowserDialect.detectFromPublicSystemInfo(const {'ServerName': 'x', 'Id': 'y'}), isNull); + expect(MediaBrowserDialect.detectFromPublicSystemInfo(const {'ProductName': ''}), isNull); + }); + }); + + group('MediaBackend MediaBrowser predicate', () { + test('usesMediaBrowserApi covers Jellyfin and Emby but not Plex', () { + expect(MediaBackend.plex.usesMediaBrowserApi, isFalse); + expect(MediaBackend.jellyfin.usesMediaBrowserApi, isTrue); + expect(MediaBackend.emby.usesMediaBrowserApi, isTrue); + expect(MediaBackend.plex.dialect, isNull); + }); + + test('emby round-trips through the persisted id helpers', () { + expect(MediaBackend.emby.id, 'emby'); + expect(MediaBackend.fromId('emby'), MediaBackend.emby); + expect(MediaBackend.fromString('emby'), MediaBackend.emby); + }); + + test('a missing backend id still falls back to Plex for pre-Jellyfin cache rows', () { + expect(MediaBackend.fromString(null), MediaBackend.plex); + }); + }); +} diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart index fd5e8bcc..6e5053fa 100644 --- a/test/media/media_item_test.dart +++ b/test/media/media_item_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_part.dart'; @@ -639,6 +640,46 @@ void main() { expect(decoded.id, 'legacy'); expect(decoded.kind, MediaKind.movie); }); + + test('an Emby item persists its own backend id and restores the dialect', () { + const original = JellyfinMediaItem( + dialect: MediaBrowserDialect.emby, + // Emby item ids are short numeric strings, not GUIDs. + id: '7330', + kind: MediaKind.movie, + title: 'Movie 001', + playlistItemId: 'entry-1', + ); + + final json = original.toJson(); + final decoded = MediaItem.fromJson(json); + + // One discriminator on the wire: the union key carries the resolved + // backend and the dialect is rebuilt from it. + expect(json['backend'], 'emby'); + expect(json.containsKey('dialect'), isFalse); + expect(decoded, isA()); + expect(decoded.backend, MediaBackend.emby); + expect((decoded as JellyfinMediaItem).dialect, MediaBrowserDialect.emby); + expect(decoded.playlistItemId, 'entry-1'); + expect(decoded.id, '7330'); + }); + + test('the compat factory routes both MediaBrowser backends to one variant', () { + final emby = MediaItem(id: 'e1', backend: MediaBackend.emby, kind: MediaKind.movie); + final jellyfin = MediaItem(id: 'j1', backend: MediaBackend.jellyfin, kind: MediaKind.movie); + + expect(emby, isA()); + expect(jellyfin, isA()); + expect(emby.backend, MediaBackend.emby); + expect(jellyfin.backend, MediaBackend.jellyfin); + }); + + test('copyWith preserves the Emby dialect', () { + final emby = MediaItem(id: 'e1', backend: MediaBackend.emby, kind: MediaKind.movie) as JellyfinMediaItem; + + expect(emby.copyWith(title: 'renamed').backend, MediaBackend.emby); + }); }); group('MediaItem.displayTitle', () { diff --git a/test/metadata_edit/jellyfin_metadata_edit_adapter_emby_test.dart b/test/metadata_edit/jellyfin_metadata_edit_adapter_emby_test.dart new file mode 100644 index 00000000..f777569f --- /dev/null +++ b/test/metadata_edit/jellyfin_metadata_edit_adapter_emby_test.dart @@ -0,0 +1,169 @@ +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/connection/connection.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/metadata_edit/jellyfin_metadata_edit_adapter.dart'; +import 'package:plezy/services/jellyfin_client.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; +import '../test_helpers/media_items.dart'; + +void main() { + test('Emby save mirrors genres and tags into the name-pair arrays', () async { + final postedBodies = []; + final client = _clientForDto(connection: testEmbyConnection(), dto: _embyItem(), postedBodies: postedBodies); + addTearDown(client.close); + final adapter = JellyfinMetadataEditAdapter(client); + final draft = await adapter.load(_sourceItem(MediaBackend.emby)); + + draft.setValue('genre', ['Adventure', 'Comedy']); + draft.setValue('label', ['family', 'favorite']); + + expect(await adapter.save(draft), isTrue); + expect(postedBodies, hasLength(1)); + final body = jsonDecode(postedBodies.single) as Map; + expect(body['GenreItems'], [ + {'Name': 'Adventure'}, + {'Name': 'Comedy'}, + ]); + expect(body['TagItems'], [ + {'Name': 'family'}, + {'Name': 'favorite'}, + ]); + }); + + test('Jellyfin save does not send the name-pair arrays', () async { + final postedBodies = []; + final client = _clientForDto(connection: _jellyfinConnection(), dto: _jellyfinItem(), postedBodies: postedBodies); + addTearDown(client.close); + final adapter = JellyfinMetadataEditAdapter(client); + final draft = await adapter.load(_sourceItem(MediaBackend.jellyfin)); + + draft.setValue('genre', ['Adventure', 'Comedy']); + draft.setValue('label', ['family', 'favorite']); + + expect(await adapter.save(draft), isTrue); + expect(postedBodies, hasLength(1)); + final body = jsonDecode(postedBodies.single) as Map; + expect(body['Genres'], ['Adventure', 'Comedy']); + expect(body['Tags'], ['family', 'favorite']); + expect(body.containsKey('GenreItems'), isFalse); + expect(body.containsKey('TagItems'), isFalse); + }); + + test("an Emby DTO's tags are read from TagItems", () async { + final client = _clientForDto(connection: testEmbyConnection(), dto: _embyItem(), postedBodies: []); + addTearDown(client.close); + final adapter = JellyfinMetadataEditAdapter(client); + + final draft = await adapter.load(_sourceItem(MediaBackend.emby)); + + expect(draft.values['label'], ['archive']); + expect(draft.values['genre'], ['Action']); + }); + + test('a save that does not touch tags preserves them', () async { + final postedBodies = []; + final client = _clientForDto(connection: testEmbyConnection(), dto: _embyItem(), postedBodies: postedBodies); + addTearDown(client.close); + final adapter = JellyfinMetadataEditAdapter(client); + final draft = await adapter.load(_sourceItem(MediaBackend.emby)); + + draft.setValue('summary', 'Updated summary'); + + expect(await adapter.save(draft), isTrue); + expect(postedBodies, hasLength(1)); + final body = jsonDecode(postedBodies.single) as Map; + expect(body['TagItems'], [ + {'Name': 'archive'}, + ]); + }); + + test("the adapter reports the dialect's backend", () { + final embyClient = _clientForDto(connection: testEmbyConnection(), dto: _embyItem(), postedBodies: []); + final jellyfinClient = _clientForDto( + connection: _jellyfinConnection(), + dto: _jellyfinItem(), + postedBodies: [], + ); + addTearDown(embyClient.close); + addTearDown(jellyfinClient.close); + + expect(JellyfinMetadataEditAdapter(embyClient).backend, MediaBackend.emby); + expect(JellyfinMetadataEditAdapter(jellyfinClient).backend, MediaBackend.jellyfin); + }); +} + +JellyfinClient _clientForDto({ + required JellyfinConnection connection, + required Map dto, + required List postedBodies, +}) { + return JellyfinClient.forTesting( + connection: connection, + httpClient: MockClient((request) async { + if (request.method == 'GET' && request.url.path == '/Users/${connection.userId}/Items/item-1') { + return jsonResponse(dto); + } + if (request.method == 'POST' && request.url.path == '/Items/item-1') { + postedBodies.add(request.body); + return http.Response('', 204); + } + return http.Response('Unexpected ${request.method} ${request.url}', 500); + }), + ); +} + +JellyfinConnection _jellyfinConnection() { + return JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jf.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'User', + accessToken: 'token', + deviceId: 'device-1', + isAdministrator: false, + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); +} + +MediaItem _sourceItem(MediaBackend backend) { + return testMediaItem(id: 'item-1', backend: backend, kind: MediaKind.movie); +} + +Map _embyItem() { + return { + 'Id': 'item-1', + 'Name': 'Movie', + 'Type': 'Movie', + 'Overview': 'Original summary', + 'ProviderIds': {}, + 'Genres': [], + 'GenreItems': [ + {'Name': 'Action', 'Id': 3}, + ], + 'TagItems': [ + {'Name': 'archive', 'Id': 7}, + ], + }; +} + +Map _jellyfinItem() { + return { + 'Id': 'item-1', + 'Name': 'Movie', + 'Type': 'Movie', + 'Overview': 'Original summary', + 'ProviderIds': {}, + 'Genres': ['Drama'], + 'Tags': ['Favorite'], + }; +} diff --git a/test/screens/settings/add_connection_screen_test.dart b/test/screens/settings/add_connection_screen_test.dart new file mode 100644 index 00000000..e579cd28 --- /dev/null +++ b/test/screens/settings/add_connection_screen_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_wrapper.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/screens/settings/add_connection_screen.dart'; +import 'package:plezy/screens/settings/add_jellyfin_screen.dart'; +import 'package:plezy/screens/settings/add_plex_account_screen.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/widgets/backend_badge.dart'; + +/// The "Add connection" picker is the only route to a new server, so a backend +/// missing from this list is unreachable no matter how complete its client is. +void main() { + Widget app(Widget home) => MaterialApp( + theme: monoTheme(dark: true), + home: InputModeTracker(child: home), + ); + + Profile profile(String id) => + Profile.local(id: id, displayName: id, sortOrder: 0, createdAt: DateTime.fromMillisecondsSinceEpoch(0)); + + testWidgets('offers Plex, Jellyfin and Emby, each with its own badge', (tester) async { + await tester.pumpWidget(app(const AddConnectionScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Sign in with Plex'), findsOneWidget); + expect(find.text('Connect to Jellyfin'), findsOneWidget); + expect(find.text('Connect to Emby'), findsOneWidget); + + final badges = tester.widgetList(find.byType(BackendBadge)).map((b) => b.backend).toList(); + expect(badges, containsAll([MediaBackend.plex, MediaBackend.jellyfin, MediaBackend.emby])); + }); + + /// The pushed sign-in screen starts a 2s LAN discovery sweep and a + /// platform package-info read, neither of which settles under + /// `pumpAndSettle`. Bounded frames are enough: the route's widget exists as + /// soon as the push completes. + Future tapCard(WidgetTester tester, String label) async { + await tester.tap(find.text(label)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + return tester.widget(find.byType(AddJellyfinScreen)); + } + + testWidgets('the Emby card opens the sign-in screen bound to the Emby dialect', (tester) async { + await tester.pumpWidget(app(const AddConnectionScreen())); + await tester.pumpAndSettle(); + + final screen = await tapCard(tester, 'Connect to Emby'); + expect(screen.dialect, MediaBrowserDialect.emby); + expect(screen.targetProfile, isNull); + expect(find.text('Add Emby server'), findsOneWidget); + }); + + testWidgets('the Jellyfin card still opens the Jellyfin dialect', (tester) async { + await tester.pumpWidget(app(const AddConnectionScreen())); + await tester.pumpAndSettle(); + + final screen = await tapCard(tester, 'Connect to Jellyfin'); + expect(screen.dialect, MediaBrowserDialect.jellyfin); + expect(find.text('Add Jellyfin server'), findsOneWidget); + }); + + testWidgets('the Plex card is unaffected by the new option', (tester) async { + await tester.pumpWidget(app(const AddConnectionScreen())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Sign in with Plex')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(AddPlexAccountScreen), findsOneWidget); + expect(find.byType(AddJellyfinScreen), findsNothing); + }); + + testWidgets('a scoped Emby card names the profile it will bind to', (tester) async { + final target = profile('Living Room'); + await tester.pumpWidget(app(AddConnectionScreen(targetProfile: target))); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to your Emby server. Binds to Living Room.'), findsOneWidget); + expect(find.text('Sign in to your Jellyfin server. Binds to Living Room.'), findsOneWidget); + + final screen = await tapCard(tester, 'Connect to Emby'); + expect(screen.dialect, MediaBrowserDialect.emby); + expect(screen.targetProfile?.id, target.id); + }); + + testWidgets('the D-pad steps through all three backend cards', (tester) async { + await tester.pumpWidget(app(const AddConnectionScreen())); + await tester.pumpAndSettle(); + + // The cards share a debugLabel, so track focus-node identity instead. + final visited = {}; + for (var i = 0; i < 6; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + final focused = FocusManager.instance.primaryFocus; + if (focused != null) visited.add(focused); + } + + expect(find.byType(FocusableWrapper), findsNWidgets(3)); + expect(visited.length, greaterThanOrEqualTo(3), reason: 'D-pad did not reach every backend card'); + }); +} diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 7dfc70dc..30dc0de7 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -12,6 +12,7 @@ import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; import 'package:plezy/profiles/active_profile_binder.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; @@ -400,7 +401,12 @@ void main() { child: _testApp( AddJellyfinScreen( localDiscoveryFactory: () async => [ - DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'), + DiscoveredJellyfinServer( + address: 'http://192.168.1.20:8096', + id: 'srv-1', + name: 'Home', + dialect: MediaBrowserDialect.jellyfin, + ), ], ), ), @@ -582,6 +588,56 @@ void main() { expect(find.text('Home'), findsOneWidget); }); + testWidgets('the Emby dialect renames the screen and never offers Quick Connect', (tester) async { + resetSharedPreferencesForTest(); + // The same handler advertises Quick Connect as enabled. Emby has no + // /QuickConnect/* routes at all, so the affordance must be gated on the + // dialect rather than on what the server claims. + await tester.pumpWidget( + _testApp( + AddJellyfinScreen( + dialect: MediaBrowserDialect.emby, + authServiceFactory: () => _jellyfinAuthService(quickConnectEnabled: true), + localDiscoveryFactory: _noLocalServers, + ), + ), + ); + await tester.pump(); + + expect(find.text('Add Emby server'), findsOneWidget); + expect(find.text('Add Jellyfin server'), findsNothing); + final urlField = tester.widget(find.byType(TextField).first); + expect(urlField.decoration?.hintText, 'https://emby.example.com'); + + await tester.enterText(find.byType(TextField).first, 'https://emby.example.com'); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + + expect(find.text('Use Quick Connect'), findsNothing); + // The password form is still reachable — Emby's only sign-in path. + expect(find.text('Sign in'), findsOneWidget); + }); + + testWidgets('the Jellyfin dialect still offers Quick Connect when the server has it', (tester) async { + resetSharedPreferencesForTest(); + await tester.pumpWidget( + _testApp( + AddJellyfinScreen( + authServiceFactory: () => _jellyfinAuthService(quickConnectEnabled: true), + localDiscoveryFactory: _noLocalServers, + ), + ), + ); + await tester.pump(); + + expect(find.text('Add Jellyfin server'), findsOneWidget); + await tester.enterText(find.byType(TextField).first, 'https://jf.example.com'); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + + expect(find.text('Use Quick Connect'), findsOneWidget); + }); + testWidgets('Quick Connect shows the code prominently and cancel returns to the form', (tester) async { resetSharedPreferencesForTest(); await tester.pumpWidget( @@ -639,7 +695,12 @@ void main() { authServiceFactory: () => _jellyfinAuthService(quickConnectEnabled: true, initiateDelay: const Duration(milliseconds: 50)), localDiscoveryFactory: () async => [ - DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'), + DiscoveredJellyfinServer( + address: 'http://192.168.1.20:8096', + id: 'srv-1', + name: 'Home', + dialect: MediaBrowserDialect.jellyfin, + ), ], ), ), @@ -690,7 +751,12 @@ void main() { AddJellyfinScreen( authServiceFactory: () => _jellyfinAuthService(), localDiscoveryFactory: () async => [ - DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'), + DiscoveredJellyfinServer( + address: 'http://192.168.1.20:8096', + id: 'srv-1', + name: 'Home', + dialect: MediaBrowserDialect.jellyfin, + ), ], ), ), @@ -713,8 +779,18 @@ void main() { child: _testApp( AddJellyfinScreen( localDiscoveryFactory: () async => [ - DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'), - DiscoveredJellyfinServer(address: 'http://192.168.1.30:8096', id: 'srv-2', name: 'Office'), + DiscoveredJellyfinServer( + address: 'http://192.168.1.20:8096', + id: 'srv-1', + name: 'Home', + dialect: MediaBrowserDialect.jellyfin, + ), + DiscoveredJellyfinServer( + address: 'http://192.168.1.30:8096', + id: 'srv-2', + name: 'Office', + dialect: MediaBrowserDialect.jellyfin, + ), ], ), ), diff --git a/test/screens/setup_database_recovery_test.dart b/test/screens/setup_database_recovery_test.dart index 540615b1..3bee0e75 100644 --- a/test/screens/setup_database_recovery_test.dart +++ b/test/screens/setup_database_recovery_test.dart @@ -64,7 +64,8 @@ void main() { expect(find.text(t.auth.localDataRecoveryRequired), findsOneWidget); expect(find.text(t.auth.signInWithPlex), findsOneWidget); - expect(find.text(t.auth.connectToJellyfin), findsOneWidget); + expect(find.text(t.auth.connectToMediaBrowser(product: 'Jellyfin')), findsOneWidget); + expect(find.text(t.auth.connectToMediaBrowser(product: 'Emby')), findsOneWidget); }); testWidgets('fresh AuthScreen has normal actions without recovery notice', (tester) async { @@ -73,6 +74,7 @@ void main() { expect(find.text(t.auth.localDataRecoveryRequired), findsNothing); expect(find.text(t.auth.signInWithPlex), findsOneWidget); - expect(find.text(t.auth.connectToJellyfin), findsOneWidget); + expect(find.text(t.auth.connectToMediaBrowser(product: 'Jellyfin')), findsOneWidget); + expect(find.text(t.auth.connectToMediaBrowser(product: 'Emby')), findsOneWidget); }); } diff --git a/test/services/jellyfin_auth_service_test.dart b/test/services/jellyfin_auth_service_test.dart index e8b866a0..0c37caa0 100644 --- a/test/services/jellyfin_auth_service_test.dart +++ b/test/services/jellyfin_auth_service_test.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; import 'package:plezy/services/jellyfin_auth_service.dart'; import 'package:plezy/services/jellyfin_endpoint_discovery.dart'; import 'package:plezy/utils/log_redaction_manager.dart'; @@ -22,18 +23,26 @@ http.Response _bareOk(String body) => http.Response(body, 200, headers: {'conten http.Response _status(int code, [Object? json]) => http.Response(json == null ? '' : jsonEncode(json), code, headers: {'content-type': 'application/json'}); -JellyfinConnection _existingConn({String accessToken = 'tok-old'}) => testJellyfinConnection( +JellyfinConnection _existingConn({ + String accessToken = 'tok-old', + MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin, +}) => testJellyfinConnection( userName: 'edde', accessToken: accessToken, deviceId: 'dev-xyz', createdAt: DateTime.fromMillisecondsSinceEpoch(0), + dialect: dialect, ); -JellyfinConnectionAuthService _service({required _Handler handler}) { +JellyfinConnectionAuthService _service({ + required _Handler handler, + MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin, +}) { return JellyfinConnectionAuthService( clientName: 'Plezy', clientVersion: 'test', deviceName: 'TestDevice', + dialect: dialect, testHttpClientFactory: () => MockClient((req) async => handler(req)), ); } @@ -766,6 +775,195 @@ void main() { }); }); + group('Emby dialect', () { + test('validate uses the user-scoped current-user route instead of /Users/Me', () async { + final paths = []; + final svc = _service( + dialect: MediaBrowserDialect.emby, + handler: (req) { + paths.add(req.url.path); + if (req.url.path == '/Users/Me') { + return _status(500, {'error': 'Unrecognized Guid format'}); + } + return _ok({'Id': 'user-1'}); + }, + ); + + expect(await svc.validate(_existingConn(dialect: MediaBrowserDialect.emby)), isTrue); + expect(paths, ['/Users/user-1']); + }); + + test('checking Quick Connect support sends no unsupported Emby request', () async { + final paths = []; + final svc = _service( + dialect: MediaBrowserDialect.emby, + handler: (req) { + paths.add(req.url.path); + expect(req.url.path, isNot(startsWith('/QuickConnect/'))); + return _status(404); + }, + ); + + expect(await svc.isQuickConnectEnabled('https://emby.example.com'), isFalse); + expect(paths, isEmpty); + }); + + test('initiating Quick Connect rejects locally without an Emby request', () async { + final paths = []; + final svc = _service( + dialect: MediaBrowserDialect.emby, + handler: (req) { + paths.add(req.url.path); + expect(req.url.path, isNot(startsWith('/QuickConnect/'))); + return _status(404); + }, + ); + + final error = await _captureError( + svc.initiateQuickConnect(baseUrl: 'https://emby.example.com', deviceId: 'dev-xyz'), + ); + + expect( + error, + isA() + .having((exception) => exception.message, 'message', 'Quick Connect rejected by server') + .having((exception) => exception.statusCode, 'statusCode', isNull), + ); + expect(paths, isEmpty); + }); + + test('authenticating by Quick Connect rejects locally without an Emby request', () async { + final paths = []; + final svc = _service( + dialect: MediaBrowserDialect.emby, + handler: (req) { + paths.add(req.url.path); + expect(req.url.path, isNot(startsWith('/QuickConnect/'))); + return _status(404); + }, + ); + + final error = await _captureError( + svc.authenticateByQuickConnect( + baseUrl: 'https://emby.example.com', + secret: 'quick-secret', + deviceId: 'dev-xyz', + ), + ); + + expect( + error, + isA() + .having((exception) => exception.message, 'message', 'Quick Connect rejected by server') + .having((exception) => exception.statusCode, 'statusCode', isNull), + ); + expect(paths, isEmpty); + }); + + test('password authentication builds an Emby-persisted connection discriminator', () async { + final svc = _service( + dialect: MediaBrowserDialect.emby, + handler: (req) { + expect(req.url.path, '/Users/AuthenticateByName'); + return _ok({ + 'AccessToken': 'tok-new', + 'User': {'Id': 'user-7', 'Name': 'edde'}, + }); + }, + ); + + final connection = await svc.authenticateByName( + baseUrl: 'https://emby.example.com', + username: 'edde', + password: 'pw', + deviceId: 'dev-xyz', + serverInfo: _serverInfo, + ); + + expect(connection.dialect, MediaBrowserDialect.emby); + expect(connection.kind, ConnectionKind.emby); + expect(connection.kind.id, 'emby'); + }); + + test('detected server dialect overrides the picker and an unknown response preserves it', () async { + Future authenticate(Map publicInfo) { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') return _ok(publicInfo); + if (req.url.path == '/Users/AuthenticateByName') { + return _ok({ + 'AccessToken': 'tok-new', + 'User': {'Id': 'user-7', 'Name': 'edde'}, + }); + } + return _status(404); + }, + ); + return svc.authenticateByName( + baseUrl: 'https://server.example.com', + username: 'edde', + password: 'pw', + deviceId: 'dev-xyz', + ); + } + + final detected = await authenticate({ + 'LocalAddresses': [], + 'RemoteAddresses': [], + 'ServerName': 'Emby Home', + 'Version': '4.9.5.0', + 'Id': 'emby-server', + }); + final unknown = await authenticate({'ServerName': 'Unknown Home', 'Id': 'unknown-server', 'Version': '4.9.5.0'}); + + expect(detected.dialect, MediaBrowserDialect.emby); + expect(detected.kind, ConnectionKind.emby); + expect(unknown.dialect, MediaBrowserDialect.jellyfin); + expect(unknown.kind, ConnectionKind.jellyfin); + }); + + test('password authentication and logout remain wire-identical to Jellyfin', () async { + Future> capture(MediaBrowserDialect dialect) async { + final requests = <(String, String, String)>[]; + final svc = _service( + dialect: dialect, + handler: (req) { + final request = req as http.Request; + requests.add((request.method, request.url.path, request.body)); + if (request.url.path == '/Users/AuthenticateByName') { + return _ok({ + 'AccessToken': 'tok-new', + 'User': {'Id': 'user-7', 'Name': 'edde'}, + }); + } + if (request.url.path == '/Sessions/Logout') return _ok({}); + return _status(404); + }, + ); + final connection = await svc.authenticateByName( + baseUrl: 'https://server.example.com', + username: 'edde', + password: 'pw', + deviceId: 'dev-xyz', + serverInfo: _serverInfo, + ); + await svc.signOut(connection); + return requests; + } + + final jellyfinRequests = await capture(MediaBrowserDialect.jellyfin); + final embyRequests = await capture(MediaBrowserDialect.emby); + final expected = <(String, String, String)>[ + ('POST', '/Users/AuthenticateByName', '{"Username":"edde","Pw":"pw"}'), + ('POST', '/Sessions/Logout', ''), + ]; + + expect(jellyfinRequests, expected); + expect(embyRequests, expected); + expect(embyRequests, jellyfinRequests); + }); + }); + group('Jellyfin authentication request identity', () { test('password login sends the complete MediaBrowser header', () async { late http.BaseRequest request; diff --git a/test/services/jellyfin_client_emby_dialect_test.dart b/test/services/jellyfin_client_emby_dialect_test.dart new file mode 100644 index 00000000..c67b3d91 --- /dev/null +++ b/test/services/jellyfin_client_emby_dialect_test.dart @@ -0,0 +1,1550 @@ +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/media/library_query.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; +import '../test_helpers/media_items.dart'; + +class _RequestCapture { + _RequestCapture(this._respond); + + final http.Response Function(http.Request request) _respond; + final List log = []; + final List<({String method, Uri url, String body})> requests = []; + + Future handle(http.Request request) async { + log.add('${request.method} ${request.url.path}?${request.url.query}'); + requests.add((method: request.method, url: request.url, body: request.body)); + return _respond(request); + } +} + +MediaItem _item(MediaBackend backend, {MediaKind kind = MediaKind.movie}) => + testMediaItem(id: 'item-1', backend: backend, kind: kind, serverId: 'srv-1'); + +Future _reportPlaybackTriple(JellyfinClient client, {String? playSessionId}) async { + await client.reportPlaybackStarted( + itemId: 'item-1', + position: const Duration(seconds: 1), + playSessionId: playSessionId, + ); + await client.reportPlaybackProgress( + itemId: 'item-1', + position: const Duration(seconds: 2), + duration: const Duration(minutes: 1), + playSessionId: playSessionId, + ); + await client.reportPlaybackStopped( + itemId: 'item-1', + position: const Duration(seconds: 3), + playSessionId: playSessionId, + ); +} + +Map _chapterItem({List>? chapters}) => { + 'Id': 'item-1', + 'Name': 'Episode', + 'Type': 'Episode', + 'RunTimeTicks': 1200000000, + 'Chapters': + chapters ?? + [ + {'Name': 'OP', 'StartPositionTicks': 100000000}, + {'Name': 'Episode', 'StartPositionTicks': 450000000}, + {'Name': 'ED', 'StartPositionTicks': 900000000}, + ], +}; + +void main() { + group('MediaBrowser user-scoped routes', () { + test('Emby scopes current-user probes while Jellyfin keeps /Users/Me', () async { + final embyRequests = _RequestCapture((_) => jsonResponse({'Configuration': {}})); + final jellyfinRequests = _RequestCapture((_) => jsonResponse({'Configuration': {}})); + final emby = testEmbyClient(handler: embyRequests.handle); + final jellyfin = testJellyfinClient(handler: jellyfinRequests.handle); + addTearDown(emby.close); + addTearDown(jellyfin.close); + + expect(await emby.checkHealth(), HealthStatus.online); + expect(await emby.isHealthy(), isTrue); + expect(await emby.fetchUserProfile(), isNotNull); + expect(await jellyfin.checkHealth(), HealthStatus.online); + expect(await jellyfin.isHealthy(), isTrue); + expect(await jellyfin.fetchUserProfile(), isNotNull); + + expect(embyRequests.log, ['GET /Users/user-1?', 'GET /Users/user-1?', 'GET /Users/user-1?']); + expect(jellyfinRequests.log, ['GET /Users/Me?', 'GET /Users/Me?', 'GET /Users/Me?']); + expect(embyRequests.log, isNot(contains('GET /Users/Me?'))); + expect(embyRequests.requests.every((request) => request.body.isEmpty), isTrue); + expect(jellyfinRequests.requests.every((request) => request.body.isEmpty), isTrue); + }); + + test('Emby scopes watched, favorite, and rating writes while Jellyfin keeps unprefixed routes', () async { + final embyRequests = _RequestCapture((_) => http.Response('', 204)); + final jellyfinRequests = _RequestCapture((_) => http.Response('', 204)); + final emby = testEmbyClient(handler: embyRequests.handle); + final jellyfin = testJellyfinClient(handler: jellyfinRequests.handle); + addTearDown(emby.close); + addTearDown(jellyfin.close); + final embyItem = _item(MediaBackend.emby); + final jellyfinItem = _item(MediaBackend.jellyfin); + + await emby.markWatched(embyItem); + await emby.markUnwatched(embyItem); + await emby.setFavorite(embyItem, true); + await emby.setFavorite(embyItem, false); + await emby.rate(embyItem, 7); + await emby.rate(embyItem, -1); + await jellyfin.markWatched(jellyfinItem); + await jellyfin.markUnwatched(jellyfinItem); + await jellyfin.setFavorite(jellyfinItem, true); + await jellyfin.setFavorite(jellyfinItem, false); + await jellyfin.rate(jellyfinItem, 7); + await jellyfin.rate(jellyfinItem, -1); + + expect(embyRequests.log, [ + 'POST /Users/user-1/PlayedItems/item-1?userId=user-1', + 'DELETE /Users/user-1/PlayedItems/item-1?userId=user-1', + 'POST /Users/user-1/FavoriteItems/item-1?userId=user-1', + 'DELETE /Users/user-1/FavoriteItems/item-1?userId=user-1', + 'POST /Users/user-1/Items/item-1/Rating?userId=user-1&Likes=true', + 'DELETE /Users/user-1/Items/item-1/Rating?userId=user-1', + ]); + expect(jellyfinRequests.log, [ + 'POST /UserPlayedItems/item-1?userId=user-1', + 'DELETE /UserPlayedItems/item-1?userId=user-1', + 'POST /UserFavoriteItems/item-1?userId=user-1', + 'DELETE /UserFavoriteItems/item-1?userId=user-1', + 'POST /UserItems/item-1/Rating?userId=user-1&Likes=true', + 'DELETE /UserItems/item-1/Rating?userId=user-1', + ]); + expect(embyRequests.requests.every((request) => request.body.isEmpty), isTrue); + expect(jellyfinRequests.requests.every((request) => request.body.isEmpty), isTrue); + }); + + test('Emby reads Continue Watching from /Items while Jellyfin keeps /UserItems/Resume', () async { + http.Response respond(http.Request request) => jsonResponse({'Items': [], 'TotalRecordCount': 0}); + + final embyRequests = _RequestCapture(respond); + final jellyfinRequests = _RequestCapture(respond); + final emby = testEmbyClient(handler: embyRequests.handle); + final jellyfin = testJellyfinClient(handler: jellyfinRequests.handle); + addTearDown(emby.close); + addTearDown(jellyfin.close); + + expect(await emby.fetchContinueWatching(count: 1), isEmpty); + expect(await jellyfin.fetchContinueWatching(count: 1), isEmpty); + + expect( + embyRequests.requests.map((request) => '${request.method} ${request.url.path}').toList(), + // Both Emby legs go to /Items: the resume leg because its dedicated route + // cannot be filtered or paged there, and the Next Up leg because it is a + // recency query over played episodes (which returns none here, so no + // per-series query follows). See the resume-semantics and Next Up groups. + ['GET /Items', 'GET /Items'], + reason: embyRequests.log.join('\n'), + ); + expect( + embyRequests.requests.first.url.queryParameters['Filters'], + 'IsResumable', + reason: 'the Emby resume leg lost its server-side filter', + ); + expect( + jellyfinRequests.requests.map((request) => '${request.method} ${request.url.path}').toList(), + ['GET /UserItems/Resume', 'GET /Shows/NextUp'], + reason: jellyfinRequests.log.join('\n'), + ); + final embyResume = embyRequests.requests.first; + final jellyfinResume = jellyfinRequests.requests.first; + expect(embyResume.url.queryParameters, { + 'userId': 'user-1', + // The server-side progress filter Emby's dedicated resume route lacks. + 'Filters': 'IsResumable', + 'SortBy': 'DatePlayed', + 'SortOrder': 'Descending', + 'Limit': '1', + // Emby withholds these from list rows unless asked, so the hub field set + // is widened for it and only for it. + 'Fields': 'Overview,ProductionYear,OfficialRating,PremiereDate,DateCreated,UserDataLastPlayedDate', + 'MediaTypes': 'Video', + 'Recursive': 'true', + 'EnableTotalRecordCount': 'false', + 'EnableImageTypes': 'Primary,Backdrop,Logo', + 'ImageTypeLimit': '3', + }); + expect(jellyfinResume.url.queryParameters, { + 'userId': 'user-1', + 'Limit': '1', + // Jellyfin volunteers the row metadata and filters its own resume route, + // so its request string is unchanged from before Emby existed. + 'Fields': 'Overview', + 'MediaTypes': 'Video', + 'Recursive': 'true', + 'EnableTotalRecordCount': 'false', + 'EnableImageTypes': 'Primary,Backdrop,Logo', + 'ImageTypeLimit': '3', + }); + expect(embyRequests.requests.every((request) => request.body.isEmpty), isTrue); + expect(jellyfinRequests.requests.every((request) => request.body.isEmpty), isTrue); + }); + + test('an Emby resume row carries the played date it asked for', () async { + final requests = _RequestCapture((request) { + if (request.url.queryParameters['Filters'] == 'IsResumable') { + return jsonResponse({ + 'Items': [ + { + 'Id': 'movie-1', + 'Name': 'In Progress', + 'Type': 'Movie', + 'DateCreated': '2020-01-01T00:00:00.0000000Z', + 'UserData': {'LastPlayedDate': '2026-08-04T20:22:11.0000000Z', 'PlaybackPositionTicks': 2400000000}, + }, + ], + }); + } + return jsonResponse({'Items': []}); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final rows = await client.fetchContinueWatching(count: 1); + + expect(rows.single.lastViewedAt, DateTime.utc(2026, 8, 4, 20, 22, 11).millisecondsSinceEpoch ~/ 1000); + expect( + rows.single.recencySortKey, + greaterThan(DateTime.utc(2020, 1, 2).millisecondsSinceEpoch ~/ 1000), + reason: 'the resume row degraded to its addedAt', + ); + final resume = requests.requests.firstWhere((request) => request.url.queryParameters.containsKey('Filters')); + expect(resume.url.queryParameters['Fields'], contains('UserDataLastPlayedDate')); + }); + + test('Emby browse rows ask for the metadata Jellyfin volunteers', () async { + // Measured on Emby 4.9.5: a list row omits ProductionYear, + // OfficialRating and PremiereDate unless Fields names them, while + // Jellyfin 10.11 returns all three in every row. Without the widened set + // every Emby card loses its year and age-rating badge. + final embyRequests = _RequestCapture((_) => jsonResponse({'Items': [], 'TotalRecordCount': 0})); + final jellyfinRequests = _RequestCapture((_) => jsonResponse({'Items': [], 'TotalRecordCount': 0})); + final emby = testEmbyClient(handler: embyRequests.handle); + final jellyfin = testJellyfinClient(handler: jellyfinRequests.handle); + addTearDown(emby.close); + addTearDown(jellyfin.close); + + await emby.fetchLibraryContent('lib-1', const LibraryQuery(limit: 5)); + await jellyfin.fetchLibraryContent('lib-1', const LibraryQuery(limit: 5)); + + final embyFields = embyRequests.requests.last.url.queryParameters['Fields']!.split(','); + final jellyfinFields = jellyfinRequests.requests.last.url.queryParameters['Fields']!.split(','); + + expect(embyFields, containsAll(['ProductionYear', 'OfficialRating', 'PremiereDate', 'DateCreated'])); + // Jellyfin's request string must not gain them. + expect(jellyfinFields, isNot(contains('ProductionYear'))); + expect(jellyfinFields, isNot(contains('OfficialRating'))); + // Everything the base set already carried survives on both. + expect(embyFields, containsAll(jellyfinFields)); + // No duplicate entries when the base set already names one of them. + expect(embyFields.toSet().length, embyFields.length, reason: 'duplicated field: $embyFields'); + }); + + test('a field set that already names a withheld field is not duplicated', () async { + final requests = _RequestCapture((_) => jsonResponse({'Items': [], 'TotalRecordCount': 0})); + final emby = testEmbyClient(handler: requests.handle); + addTearDown(emby.close); + + // The episode-queue set already carries PremiereDate. + await emby.fetchClientSideEpisodeQueue('series-1'); + + final fields = requests.requests.last.url.queryParameters['Fields']!.split(','); + expect(fields.where((field) => field == 'PremiereDate').length, 1, reason: 'duplicated PremiereDate: $fields'); + expect( + fields, + containsAll(['UserData', 'PremiereDate', 'ProductionYear', 'OfficialRating', 'DateCreated']), + ); + }); + + test('Emby scopes trailers and special features while Jellyfin keeps item routes', () async { + final embyRequests = _RequestCapture((_) => jsonResponse({'Items': []})); + final jellyfinRequests = _RequestCapture((_) => jsonResponse({'Items': []})); + final emby = testEmbyClient(handler: embyRequests.handle); + final jellyfin = testJellyfinClient(handler: jellyfinRequests.handle); + addTearDown(emby.close); + addTearDown(jellyfin.close); + + expect(await emby.fetchExtras('item-1'), isEmpty); + expect(await jellyfin.fetchExtras('item-1'), isEmpty); + + expect(embyRequests.log, [ + 'GET /Users/user-1/Items/item-1/LocalTrailers?' + 'userId=user-1&EnableImageTypes=Primary%2CBackdrop%2CLogo&ImageTypeLimit=3', + 'GET /Users/user-1/Items/item-1/SpecialFeatures?' + 'userId=user-1&EnableImageTypes=Primary%2CBackdrop%2CLogo&ImageTypeLimit=3', + ]); + expect(jellyfinRequests.log, [ + 'GET /Items/item-1/LocalTrailers?' + 'userId=user-1&EnableImageTypes=Primary%2CBackdrop%2CLogo&ImageTypeLimit=3', + 'GET /Items/item-1/SpecialFeatures?' + 'userId=user-1&EnableImageTypes=Primary%2CBackdrop%2CLogo&ImageTypeLimit=3', + ]); + expect(embyRequests.requests.every((request) => request.body.isEmpty), isTrue); + expect(jellyfinRequests.requests.every((request) => request.body.isEmpty), isTrue); + }); + }); + + group('MediaBrowser row played dates', () { + test('every Emby row set asks for the played date', () async { + // A watched row with no played date makes the watch-state cache stamp + // DateTime.now(), so this is not only about recency-ordered shelves: the + // episode rows an offline sync walks need it too. + final requests = _RequestCapture((_) => jsonResponse({'Items': []})); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + await client.fetchLibraryContent('lib-1', const LibraryQuery(limit: 5)); + await client.fetchChildren('season-1'); + await client.fetchContinueWatching(count: 5); + + final withFields = requests.requests.where((request) => request.url.queryParameters.containsKey('Fields')); + expect(withFields, isNotEmpty, reason: requests.log.join('\n')); + for (final request in withFields) { + expect( + request.url.queryParameters['Fields'], + contains('UserDataLastPlayedDate'), + reason: 'row set without a played date: ${request.url.path}', + ); + } + }); + + test('Jellyfin request strings stay free of the Emby-only token', () async { + final requests = _RequestCapture((_) => jsonResponse({'Items': []})); + final client = testJellyfinClient(handler: requests.handle); + addTearDown(client.close); + + await client.fetchLibraryContent('lib-1', const LibraryQuery(limit: 5)); + await client.fetchChildren('season-1'); + + for (final request in requests.requests) { + expect(request.url.query, isNot(contains('UserDataLastPlayedDate'))); + } + }); + }); + + group('MediaBrowser Next Up shelf', () { + test('Emby rebuilds the shelf per series in last-played order', () async { + // Emby only computes Next Up per series: unscoped /Shows/NextUp returns + // nothing there under any parameter combination. The client therefore asks + // /Items for recently played episodes and then queries each series. + final requests = _RequestCapture((request) { + if (request.url.path == '/Items') { + return jsonResponse({ + 'Items': [ + // Deliberately repeats series-b so the dedupe and ordering show. + {'Id': 'ep-b1', 'SeriesId': 'series-b', 'Type': 'Episode'}, + {'Id': 'ep-a1', 'SeriesId': 'series-a', 'Type': 'Episode'}, + {'Id': 'ep-b0', 'SeriesId': 'series-b', 'Type': 'Episode'}, + ], + }); + } + final seriesId = request.url.queryParameters['SeriesId']; + return jsonResponse({ + 'Items': [ + {'Id': 'next-$seriesId', 'Name': 'Next of $seriesId', 'Type': 'Episode', 'SeriesId': seriesId}, + ], + }); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final rows = await client.fetchMoreHubItemsPage('home.nextup', start: 0, size: 10); + + // Phase 1 asks for played episodes newest-first; phase 2 scopes per series. + final recency = requests.requests.firstWhere((request) => request.url.path == '/Items'); + expect(recency.url.queryParameters['Filters'], 'IsPlayed'); + expect(recency.url.queryParameters['SortBy'], 'DatePlayed'); + expect(recency.url.queryParameters['SortOrder'], 'Descending'); + expect(recency.url.queryParameters['IncludeItemTypes'], 'Episode'); + + final scoped = requests.requests + .where((request) => request.url.path == '/Shows/NextUp') + .map((request) => request.url.queryParameters['SeriesId']) + .toList(); + expect(scoped, ['series-b', 'series-a'], reason: 'one scoped query per distinct series, in recency order'); + expect(scoped.every((id) => id != null), isTrue); + + // Rows keep the series recency order, not completion order. + expect(rows.items.map((item) => item.id), ['next-series-b', 'next-series-a']); + expect(rows.totalCount, 2); + }); + + test('a failed series lookup drops only that series', () async { + final requests = _RequestCapture((request) { + if (request.url.path == '/Items') { + return jsonResponse({ + 'Items': [ + {'Id': 'ep-a', 'SeriesId': 'series-a', 'Type': 'Episode'}, + {'Id': 'ep-b', 'SeriesId': 'series-b', 'Type': 'Episode'}, + ], + }); + } + if (request.url.queryParameters['SeriesId'] == 'series-a') { + return jsonResponse({'Error': 'boom'}, status: 500); + } + return jsonResponse({ + 'Items': [ + {'Id': 'next-series-b', 'Name': 'Survivor', 'Type': 'Episode', 'SeriesId': 'series-b'}, + ], + }); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final rows = await client.fetchMoreHubItemsPage('home.nextup', start: 0, size: 10); + + expect(rows.items.map((item) => item.id), ['next-series-b']); + }); + + test('Emby with no played episodes issues no per-series query', () async { + final requests = _RequestCapture((_) => jsonResponse({'Items': []})); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final rows = await client.fetchMoreHubItemsPage('home.nextup', start: 0, size: 10); + + expect(rows.items, isEmpty); + expect(requests.requests.where((request) => request.url.path == '/Shows/NextUp'), isEmpty); + }); + + test('paging the reconstructed shelf past its end keeps the total and adds no requests', () async { + final requests = _RequestCapture((request) { + if (request.url.path == '/Items' && request.url.queryParameters['Filters'] == 'IsPlayed') { + return jsonResponse({ + 'Items': [ + for (var i = 0; i < 3; i++) {'Id': 'ep-$i', 'SeriesId': 'series-$i', 'Type': 'Episode'}, + ], + }); + } + final seriesId = request.url.queryParameters['SeriesId']; + return jsonResponse({ + 'Items': [ + {'Id': 'next-$seriesId', 'Name': 'Next', 'Type': 'Episode', 'SeriesId': seriesId}, + ], + }); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final first = await client.fetchMoreHubItemsPage('home.nextup', start: 0, size: 2); + expect(first.items.map((item) => item.id), ['next-series-0', 'next-series-1']); + expect(first.totalCount, 3); + + final beyond = await client.fetchMoreHubItemsPage('home.nextup', start: 99, size: 2); + expect(beyond.items, isEmpty); + expect(beyond.totalCount, 3, reason: 'total changed past the end of the shelf'); + expect(beyond.offset, 99); + + // Each page costs one recency query plus one query per distinct series — + // never more, however far past the end the caller asks. + final perPage = 1 + 3; + expect(requests.requests, hasLength(perPage * 2), reason: requests.log.join('\n')); + }); + + test('Jellyfin keeps the single unscoped Next Up request', () async { + final requests = _RequestCapture( + (_) => jsonResponse({ + 'Items': [ + {'Id': 'next-1', 'Name': 'Next', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 1, + }), + ); + final client = testJellyfinClient(handler: requests.handle); + addTearDown(client.close); + + final rows = await client.fetchMoreHubItemsPage('home.nextup', start: 0, size: 10); + + expect(requests.requests.map((request) => request.url.path).toList(), ['/Shows/NextUp']); + expect(requests.requests.single.url.queryParameters.containsKey('SeriesId'), isFalse); + expect(rows.items.map((item) => item.id), ['next-1']); + }); + }); + + group('MediaBrowser resume semantics', () { + /// Emby's resume route also returns zero-position *next* episodes; Jellyfin's + /// returns only genuinely started items. + Map resumePayload() => { + 'Items': [ + { + 'Id': 'movie-1', + 'Name': 'Half Watched Movie', + 'Type': 'Movie', + 'RunTimeTicks': 9000000000, + 'UserData': {'PlaybackPositionTicks': 2400000000, 'Played': false}, + }, + { + 'Id': 'ep-next', + 'Name': 'Next Episode', + 'Type': 'Episode', + 'RunTimeTicks': 9000000000, + 'UserData': {'PlaybackPositionTicks': 0, 'Played': false}, + }, + ], + 'TotalRecordCount': 2, + }; + + test('Emby reads resume from /Items with a server-side IsResumable filter', () async { + // Emby's dedicated resume route returns every started series' next episode + // alongside the genuinely in-progress items and sorts the latter LAST, so + // it cannot be paged. /Items?Filters=IsResumable returns only real progress. + final requests = _RequestCapture((request) { + if (request.url.path == '/Items' && request.url.queryParameters['Filters'] == 'IsResumable') { + return jsonResponse({ + 'Items': [(resumePayload()['Items'] as List).first], + 'TotalRecordCount': 1, + }); + } + return jsonResponse({'Items': [], 'TotalRecordCount': 0}); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final hubs = await client.fetchGlobalHubs(limit: 10); + final continueHub = hubs.firstWhere((hub) => hub.id.endsWith('continue')); + + expect(continueHub.items.map((item) => item.id), ['movie-1']); + // Never the resume route, which cannot be filtered or paged on Emby. + expect(requests.requests.where((request) => request.url.path.endsWith('/Items/Resume')), isEmpty); + final resumeRequest = requests.requests.firstWhere( + (request) => request.url.queryParameters['Filters'] == 'IsResumable', + ); + expect(resumeRequest.url.queryParameters['SortBy'], 'DatePlayed'); + expect(resumeRequest.url.queryParameters['SortOrder'], 'Descending'); + }); + + test('Jellyfin keeps its dedicated resume route unfiltered', () async { + final requests = _RequestCapture((request) { + if (request.url.path == '/UserItems/Resume') return jsonResponse(resumePayload()); + return jsonResponse({'Items': [], 'TotalRecordCount': 0}); + }); + final client = testJellyfinClient(handler: requests.handle); + addTearDown(client.close); + + final hubs = await client.fetchGlobalHubs(limit: 10); + final continueHub = hubs.firstWhere((hub) => hub.id.endsWith('continue')); + + // Jellyfin's route is already filtered server-side; the client must not + // second-guess it, and its request shape must be unchanged. + expect(continueHub.items.map((item) => item.id), ['movie-1', 'ep-next']); + final resumeRequest = requests.requests.firstWhere((request) => request.url.path == '/UserItems/Resume'); + expect(resumeRequest.url.queryParameters.containsKey('Filters'), isFalse); + expect(resumeRequest.url.queryParameters.containsKey('SortBy'), isFalse); + }); + + test('the paged Emby continue hub uses the same filtered route', () async { + final requests = _RequestCapture((request) { + if (request.url.path == '/Items' && request.url.queryParameters['Filters'] == 'IsResumable') { + return jsonResponse({ + 'Items': [(resumePayload()['Items'] as List).first], + 'TotalRecordCount': 1, + }); + } + return jsonResponse({'Items': [], 'TotalRecordCount': 0}); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final page = await client.fetchMoreHubItemsPage('home.continue', start: 0, size: 10); + + expect(page.items.map((item) => item.id), ['movie-1']); + expect(page.totalCount, 1, reason: 'server-side total lost'); + expect(requests.requests.single.url.queryParameters['StartIndex'], '0'); + }); + + test('a reconstructed Emby Next Up preview honours the requested limit', () async { + // 10 started series, preview asks for 3: the shelf must be sliced, not + // returned at the series-lookup cap. + final requests = _RequestCapture((request) { + if (request.url.path == '/Items' && request.url.queryParameters['Filters'] == 'IsPlayed') { + return jsonResponse({ + 'Items': [ + for (var i = 0; i < 10; i++) {'Id': 'ep-$i', 'SeriesId': 'series-$i', 'Type': 'Episode'}, + ], + }); + } + final seriesId = request.url.queryParameters['SeriesId']; + if (seriesId != null) { + return jsonResponse({ + 'Items': [ + {'Id': 'next-$seriesId', 'Name': 'Next', 'Type': 'Episode', 'SeriesId': seriesId}, + ], + }); + } + return jsonResponse({'Items': [], 'TotalRecordCount': 0}); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final hubs = await client.fetchGlobalHubs(limit: 3); + final nextUp = hubs.firstWhere((hub) => hub.id.endsWith('nextup')); + + expect(nextUp.items, hasLength(3), reason: 'preview ignored the requested limit'); + expect(nextUp.items.map((item) => item.id), ['next-series-0', 'next-series-1', 'next-series-2']); + }); + + test('the Emby recency probe never moves the active endpoint', () async { + var exhausted = 0; + final requests = _RequestCapture((request) { + if (request.url.path == '/Items' && request.url.queryParameters['Filters'] == 'IsPlayed') { + return jsonResponse({'Error': 'boom'}, status: 500); + } + return jsonResponse({'Items': [], 'TotalRecordCount': 0}); + }); + final client = testEmbyClient( + connection: testEmbyConnection(baseUrls: const ['https://emby.example.com', 'https://fallback.example.com']), + handler: requests.handle, + onAllEndpointsExhausted: () => exhausted++, + ); + addTearDown(client.close); + + final hubs = await client.fetchGlobalHubs(limit: 5); + + // A failing best-effort shelf must degrade, not trigger failover. + expect(hubs.where((hub) => hub.id.endsWith('nextup')), isEmpty); + expect(exhausted, 0, reason: 'the recency probe escalated to endpoint exhaustion'); + expect( + requests.requests.every((request) => request.url.host == 'emby.example.com'), + isTrue, + reason: 'the recency probe switched endpoints', + ); + }); + }); + + group('MediaBrowser Continue Watching ordering', () { + Map episodeRow(String id, String seriesId, String created) => { + 'Id': id, + 'Name': id, + 'Type': 'Episode', + 'SeriesId': seriesId, + 'SeriesName': seriesId, + 'DateCreated': created, + }; + + test('Emby stamps each reconstructed row with its series play date', () async { + // A Next Up episode has never been played, so its own UserData carries no + // date. Without the stamp the shelf's sort key falls back to when the + // episode was added to the library — which is what `DateCreated` is here, + // deliberately inverted against the recency order. + final requests = _RequestCapture((request) { + final query = request.url.queryParameters; + if (query['Filters'] == 'IsResumable') return jsonResponse({'Items': []}); + if (query['Filters'] == 'IsPlayed') { + return jsonResponse({ + 'Items': [ + { + 'Id': 'played-recent', + 'SeriesId': 'series-recent', + 'Type': 'Episode', + 'UserData': {'LastPlayedDate': '2026-08-04T21:00:00.0000000Z'}, + }, + { + 'Id': 'played-older', + 'SeriesId': 'series-older', + 'Type': 'Episode', + 'UserData': {'LastPlayedDate': '2026-08-01T21:00:00.0000000Z'}, + }, + ], + }); + } + final seriesId = query['SeriesId']!; + return jsonResponse({ + 'Items': [ + { + 'Id': 'next-$seriesId', + 'Name': 'next-$seriesId', + 'Type': 'Episode', + 'SeriesId': seriesId, + // Inverted against the play order. + 'DateCreated': seriesId == 'series-older' + ? '2030-01-01T00:00:00.0000000Z' + : '2020-01-01T00:00:00.0000000Z', + }, + ], + }); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final shelf = await client.fetchContinueWatching(count: 10); + + expect(shelf.map((item) => item.id), ['next-series-recent', 'next-series-older']); + expect(shelf.map((item) => item.lastViewedAt), [ + DateTime.utc(2026, 8, 4, 21).millisecondsSinceEpoch ~/ 1000, + DateTime.utc(2026, 8, 1, 21).millisecondsSinceEpoch ~/ 1000, + ], reason: 'rows were not stamped with their series play date'); + // The recency query must ask for the date, and the per-series enrichment + // (ParentId + Fields=UserData) must never run: the stamp makes it redundant. + final probe = requests.requests.firstWhere((request) => request.url.queryParameters['Filters'] == 'IsPlayed'); + expect(probe.url.queryParameters['Fields'], contains('UserDataLastPlayedDate')); + expect( + requests.requests.where((request) => request.url.queryParameters.containsKey('ParentId')), + isEmpty, + reason: 'redundant per-series enrichment ran on Emby', + ); + }); + + test('Jellyfin still enriches and re-sorts by recency', () async { + final requests = _RequestCapture((request) { + final query = request.url.queryParameters; + if (request.url.path == '/UserItems/Resume') { + return jsonResponse({'Items': []}); + } + if (request.url.path == '/Shows/NextUp') { + return jsonResponse({ + 'Items': [ + episodeRow('ep-a', 'series-a', '2020-01-01T00:00:00.0000000Z'), + episodeRow('ep-b', 'series-b', '2020-01-01T00:00:00.0000000Z'), + ], + }); + } + // The per-series enrichment: series-b was played more recently. + final parentId = query['ParentId']; + return jsonResponse({ + 'Items': [ + { + 'Id': 'played-$parentId', + 'Type': 'Episode', + 'UserData': { + 'LastPlayedDate': parentId == 'series-b' + ? '2026-08-04T21:00:00.0000000Z' + : '2026-08-01T21:00:00.0000000Z', + }, + }, + ], + }); + }); + final client = testJellyfinClient(handler: requests.handle); + addTearDown(client.close); + + final shelf = await client.fetchContinueWatching(count: 10); + + // series-b enriched to the newer date, so it must lead. + expect(shelf.map((item) => item.id), ['ep-b', 'ep-a']); + expect( + requests.requests.where((request) => request.url.queryParameters.containsKey('ParentId')), + isNotEmpty, + reason: 'Jellyfin lost its recency enrichment', + ); + }); + }); + + group('MediaBrowser Next Up budget', () { + test('a stalled pass is bounded by one whole-pass wall clock', () async { + // Per-request timeouts cannot bound this: MediaServerHttpClient applies + // each to the connect and receive phases independently, and the probe and + // the per-series lookups would otherwise bound separately and add up. One + // timer covers the whole pass, probe included. + final stalled = Completer(); + addTearDown(() { + if (!stalled.isCompleted) stalled.complete(); + }); + final client = testEmbyClient( + handler: (request) async { + if (request.url.queryParameters['Filters'] == 'IsResumable') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'movie-1', 'Name': 'In Progress', 'Type': 'Movie'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + // Both the recency probe and every per-series lookup hang. + await stalled.future; + return http.Response('{"Items":[]}', 200, headers: {'content-type': 'application/json'}); + }, + ); + addTearDown(client.close); + + final sw = Stopwatch()..start(); + final shelf = await client.fetchContinueWatching(count: 10); + sw.stop(); + + expect(shelf.map((item) => item.id), ['movie-1'], reason: 'the resume leg was sunk by the stalled pass'); + expect( + sw.elapsed, + lessThan(const Duration(seconds: 6)), + reason: 'the pass ran past its whole-pass budget (${sw.elapsed})', + ); + }); + }); + + group('MediaBrowser Next Up stamping', () { + test('a repeated series keeps its newest play and odd dtos still map', () async { + final requests = _RequestCapture((request) { + if (request.url.queryParameters['Filters'] == 'IsResumable') { + return jsonResponse({'Items': []}); + } + if (request.url.queryParameters['Filters'] == 'IsPlayed') { + return jsonResponse({ + 'Items': [ + // Newest first, and the same series binged twice: the first row is + // the one that decides its rank. + { + 'Id': 'ep-new', + 'SeriesId': 'series-a', + 'Type': 'Episode', + 'UserData': {'LastPlayedDate': '2026-08-04T21:00:00.0000000Z'}, + }, + { + 'Id': 'ep-old', + 'SeriesId': 'series-a', + 'Type': 'Episode', + 'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'}, + }, + // A played episode the server gave no date for at all. + {'Id': 'ep-undated', 'SeriesId': 'series-b', 'Type': 'Episode'}, + ], + }); + } + final seriesId = request.url.queryParameters['SeriesId']; + return jsonResponse({ + 'Items': [ + { + 'Id': 'next-$seriesId', + 'Name': 'next-$seriesId', + 'Type': 'Episode', + 'SeriesId': seriesId, + 'DateCreated': '2021-05-06T00:00:00.0000000Z', + // Carries its own UserData, which the stamp must preserve. + 'UserData': {'PlaybackPositionTicks': 30000000, 'Played': false}, + }, + ], + }); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final shelf = await client.fetchContinueWatching(count: 10); + + expect(shelf.map((item) => item.id), ['next-series-a', 'next-series-b']); + // series-a takes the newer of its two plays, not the last one seen. + expect(shelf.first.lastViewedAt, DateTime.utc(2026, 8, 4, 21).millisecondsSinceEpoch ~/ 1000); + // The row's own UserData survived the stamp. + expect(shelf.first.viewOffsetMs, 3000); + // An undated series degrades to its addedAt rather than being dropped. + expect(shelf.last.lastViewedAt, isNull); + expect(shelf.last.addedAt, isNotNull); + // One request per distinct series, not per played episode. + expect(requests.requests.where((request) => request.url.path == '/Shows/NextUp'), hasLength(2)); + }); + }); + + group('MediaBrowser Next Up wall clock', () { + test('a response that stalls between phases still lands inside the budget', () async { + // MediaServerHttpClient times the connect and receive phases + // independently, so headers just under the per-request timeout followed by + // a stalled body outlive it. Only racing the probe against the pass + // deadline bounds this. + final stalledBody = StreamController>(); + addTearDown(stalledBody.close); + final client = testEmbyClient( + httpClient: MockClient.streaming((request, _) async { + Map query() => request.url.queryParameters; + if (query()['Filters'] == 'IsResumable') { + final body = utf8.encode( + jsonEncode({ + 'Items': [ + {'Id': 'movie-1', 'Name': 'In Progress', 'Type': 'Movie'}, + ], + }), + ); + return http.StreamedResponse(Stream.value(body), 200, headers: {'content-type': 'application/json'}); + } + if (query()['Filters'] == 'IsPlayed') { + // Headers under the 3s per-request timeout, then a body that never + // arrives: 2.5s + 3s receive would be 5.5s unraced. + await Future.delayed(const Duration(milliseconds: 2500)); + return http.StreamedResponse(stalledBody.stream, 200, headers: {'content-type': 'application/json'}); + } + return http.StreamedResponse( + Stream.value(utf8.encode('{"Items":[]}')), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + + final sw = Stopwatch()..start(); + final shelf = await client.fetchContinueWatching(count: 10); + sw.stop(); + + expect(shelf.map((item) => item.id), ['movie-1']); + expect( + sw.elapsed, + lessThan(const Duration(milliseconds: 4800)), + reason: 'the stalled probe outlived the pass deadline (${sw.elapsed})', + ); + }); + }); + + group('MediaBrowser Next Up cutoff', () { + test('Emby drops series last played before the cutoff Jellyfin enforces', () async { + // Emby's /Shows/NextUp ignores NextUpDateCutoff, so the window has to be + // applied to the recency scan instead. + final requests = _RequestCapture((request) { + if (request.url.queryParameters['Filters'] == 'IsResumable') { + return jsonResponse({'Items': []}); + } + if (request.url.queryParameters['Filters'] == 'IsPlayed') { + return jsonResponse({ + 'Items': [ + { + 'Id': 'ep-recent', + 'SeriesId': 'series-recent', + 'Type': 'Episode', + 'UserData': {'LastPlayedDate': DateTime.now().toUtc().toIso8601String()}, + }, + { + 'Id': 'ep-abandoned', + 'SeriesId': 'series-abandoned', + 'Type': 'Episode', + 'UserData': {'LastPlayedDate': '2019-01-01T00:00:00.0000000Z'}, + }, + // No date at all: kept, because only the server can withhold it. + {'Id': 'ep-undated', 'SeriesId': 'series-undated', 'Type': 'Episode'}, + ], + }); + } + final seriesId = request.url.queryParameters['SeriesId']; + return jsonResponse({ + 'Items': [ + {'Id': 'next-$seriesId', 'Type': 'Episode', 'SeriesId': seriesId}, + ], + }); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final shelf = await client.fetchContinueWatching(count: 10); + + expect(shelf.map((item) => item.id), ['next-series-recent', 'next-series-undated']); + // The abandoned series must not even cost a per-series request. + expect( + requests.requests.where((request) => request.url.queryParameters['SeriesId'] == 'series-abandoned'), + isEmpty, + reason: 'queried a series outside the cutoff', + ); + }); + }); + + group('MediaBrowser Next Up cancellation', () { + test('a caller cancellation is disruption, not an empty shelf', () async { + // The pass swallows its *own* deadline so the shelf degrades gracefully. + // A cancellation the caller asked for must still propagate, so a paged + // caller can tell "disrupted" from "the user has nothing to watch next". + final abort = AbortController(); + final client = testEmbyClient( + handler: (request) async { + if (request.url.queryParameters['Filters'] == 'IsPlayed') { + abort.abort(); + await Future.delayed(const Duration(milliseconds: 20)); + } + return http.Response('{"Items":[]}', 200, headers: {'content-type': 'application/json'}); + }, + ); + addTearDown(client.close); + + await expectLater( + client.fetchMoreHubItemsPage('home.nextup', start: 0, size: 5, abort: abort), + throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), + ); + }); + + test('a cancellation reported as a timeout is still a cancellation', () async { + // The transport may honour the abort as an ordinary timeout, which + // `_safeFetchItemsArray` treats as best-effort empty. The caller's abort + // must still win over publishing an empty shelf. + final abort = AbortController(); + final client = testEmbyClient( + handler: (request) async { + if (request.url.queryParameters['Filters'] == 'IsPlayed') { + abort.abort(); + // Resolve as an ordinary empty response, never as a cancellation. + return http.Response('{"Items":[]}', 200, headers: {'content-type': 'application/json'}); + } + return http.Response('{"Items":[]}', 200, headers: {'content-type': 'application/json'}); + }, + ); + addTearDown(client.close); + + await expectLater( + client.fetchMoreHubItemsPage('home.nextup', start: 0, size: 5, abort: abort), + throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), + ); + }); + + test('a cancellation during the per-series phase is not a partial page', () async { + // The per-series loop breaks on either abort. A partial shelf must not be + // published as though those series had no next episode. + final abort = AbortController(); + final client = testEmbyClient( + handler: (request) async { + if (request.url.queryParameters['Filters'] == 'IsPlayed') { + return http.Response( + jsonEncode({ + 'Items': [ + for (var i = 0; i < 12; i++) {'Id': 'ep-$i', 'SeriesId': 'series-$i', 'Type': 'Episode'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Shows/NextUp') { + // Cancel once the first batch has produced rows, so the loop breaks + // with a non-empty partial result. + abort.abort(); + final seriesId = request.url.queryParameters['SeriesId']; + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'next-$seriesId', 'Type': 'Episode', 'SeriesId': seriesId}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{"Items":[]}', 200, headers: {'content-type': 'application/json'}); + }, + ); + addTearDown(client.close); + + await expectLater( + client.fetchMoreHubItemsPage('home.nextup', start: 0, size: 5, abort: abort), + throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), + ); + }); + }); + + group('MediaBrowser library filter facets', () { + test('Emby reassembles all four filter facets from its per-facet routes', () async { + final requests = _RequestCapture((request) { + final names = switch (request.url.path) { + '/Genres' => ['Drama', 'Action'], + // Emby serves the ratings facet here, NOT at /Items/OfficialRatings + // (which 404s) — the wrong spelling once made this facet look absent. + '/OfficialRatings' => ['R', 'PG-13'], + '/Tags' => ['Holiday', 'Archive'], + '/Years' => ['1999', '2024', '2010'], + _ => [], + }; + return jsonResponse({ + 'Items': names.map((name) => {'Name': name}).toList(), + 'TotalRecordCount': names.length, + }); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final result = await client.fetchLibraryFiltersWithValues('lib-1', libraryKind: MediaKind.movie); + + expect(requests.log.toSet(), { + 'GET /Genres?UserId=user-1&ParentId=lib-1&Recursive=true', + 'GET /OfficialRatings?UserId=user-1&ParentId=lib-1&Recursive=true', + 'GET /Tags?UserId=user-1&ParentId=lib-1&Recursive=true', + 'GET /Years?UserId=user-1&ParentId=lib-1&Recursive=true', + }); + expect(requests.requests, hasLength(4), reason: requests.log.join('\n')); + expect(requests.requests.where((request) => request.url.path == '/Items/Filters'), isEmpty); + for (final request in requests.requests) { + expect(request.method, 'GET'); + expect(request.url.queryParameters, {'UserId': 'user-1', 'ParentId': 'lib-1', 'Recursive': 'true'}); + expect(request.body, isEmpty); + } + expect(result.cachedValues['genre']!.map((value) => value.key).toList(), ['Action', 'Drama']); + expect(result.cachedValues['contentRating']!.map((value) => value.key).toList(), ['PG-13', 'R']); + expect(result.cachedValues['tag']!.map((value) => value.key).toList(), ['Archive', 'Holiday']); + expect(result.cachedValues['year']!.map((value) => value.key).toList(), ['2024', '2010', '1999']); + expect(result.filters.map((filter) => filter.filter), contains('contentRating')); + }); + + test('Emby preserves successful filter facets when one facet fails', () async { + final requests = _RequestCapture((request) { + if (request.url.path == '/Tags') { + return jsonResponse({'Error': 'tags failed'}, status: 500); + } + final names = switch (request.url.path) { + '/Genres' => ['Action'], + '/OfficialRatings' => ['PG'], + _ => ['2024'], + }; + return jsonResponse({ + 'Items': names.map((name) => {'Name': name}).toList(), + 'TotalRecordCount': names.length, + }); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final result = await client.fetchLibraryFiltersWithValues('lib-1', libraryKind: MediaKind.movie); + + expect(requests.requests, hasLength(4), reason: requests.log.join('\n')); + expect(result.cachedValues['genre']!.map((value) => value.key).toList(), ['Action']); + expect(result.cachedValues['contentRating']!.map((value) => value.key).toList(), ['PG']); + expect(result.cachedValues['year']!.map((value) => value.key).toList(), ['2024']); + expect(result.cachedValues['tag'] ?? const [], isEmpty); + }); + + test('Jellyfin keeps the single aggregate filter request', () async { + final requests = _RequestCapture( + (_) => jsonResponse({ + 'Genres': ['Action'], + 'OfficialRatings': ['PG'], + 'Tags': ['Archive'], + 'Years': [2024], + }), + ); + final client = testJellyfinClient(handler: requests.handle); + addTearDown(client.close); + + final result = await client.fetchLibraryFiltersWithValues('lib-1', libraryKind: MediaKind.movie); + + expect(requests.log, ['GET /Items/Filters?userId=user-1&ParentId=lib-1']); + expect(requests.requests.single.url.queryParameters, {'userId': 'user-1', 'ParentId': 'lib-1'}); + expect( + requests.requests.where( + (request) => const {'/Genres', '/OfficialRatings', '/Tags', '/Years'}.contains(request.url.path), + ), + isEmpty, + ); + expect(result.cachedValues['contentRating']!.map((value) => value.key).toList(), ['PG']); + }); + }); + + group('MediaBrowser playback session identity', () { + test('Emby synthesizes one item-derived PlaySessionId for a replay triple', () async { + final requests = _RequestCapture((_) => http.Response('', 204)); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + await _reportPlaybackTriple(client); + + expect(requests.log, [ + 'POST /Sessions/Playing?', + 'POST /Sessions/Playing/Progress?', + 'POST /Sessions/Playing/Stopped?', + ]); + final bodies = requests.requests.map((request) => jsonDecode(request.body) as Map).toList(); + expect(bodies.map((body) => body['PlaySessionId']).toList(), [ + 'plezy-replay-item-1', + 'plezy-replay-item-1', + 'plezy-replay-item-1', + ]); + expect(bodies.every((body) => body['ItemId'] == 'item-1'), isTrue); + }); + + test('Emby preserves an explicit PlaySessionId across the replay triple', () async { + final requests = _RequestCapture((_) => http.Response('', 204)); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + await _reportPlaybackTriple(client, playSessionId: 'caller-session'); + + expect(requests.log, [ + 'POST /Sessions/Playing?', + 'POST /Sessions/Playing/Progress?', + 'POST /Sessions/Playing/Stopped?', + ]); + final bodies = requests.requests.map((request) => jsonDecode(request.body) as Map); + expect(bodies.map((body) => body['PlaySessionId']).toList(), [ + 'caller-session', + 'caller-session', + 'caller-session', + ]); + }); + + test('Jellyfin keeps PlaySessionId absent when the caller supplies none', () async { + final requests = _RequestCapture((_) => http.Response('', 204)); + final client = testJellyfinClient(handler: requests.handle); + addTearDown(client.close); + + await _reportPlaybackTriple(client); + + expect(requests.log, [ + 'POST /Sessions/Playing?', + 'POST /Sessions/Playing/Progress?', + 'POST /Sessions/Playing/Stopped?', + ]); + final bodies = requests.requests.map((request) => jsonDecode(request.body) as Map).toList(); + expect(bodies, [ + { + 'ItemId': 'item-1', + 'PositionTicks': 10000000, + 'CanSeek': true, + 'IsPaused': false, + 'IsMuted': false, + 'PlayMethod': 'DirectPlay', + 'RepeatMode': 'RepeatNone', + 'PlaybackOrder': 'Default', + }, + { + 'ItemId': 'item-1', + 'PositionTicks': 20000000, + 'CanSeek': true, + 'IsPaused': false, + 'IsMuted': false, + 'PlayMethod': 'DirectPlay', + 'RepeatMode': 'RepeatNone', + 'PlaybackOrder': 'Default', + }, + {'ItemId': 'item-1', 'PositionTicks': 30000000, 'Failed': false}, + ]); + expect(bodies.every((body) => !body.containsKey('PlaySessionId')), isTrue); + }); + }); + + group('MediaBrowser playlist dialect', () { + test('Emby omits MediaTypes and labels untyped playlists with the requested type', () async { + var responseIndex = 0; + final requests = _RequestCapture((_) { + responseIndex++; + return jsonResponse({ + 'Items': [ + {'Id': 'playlist-$responseIndex', 'Name': 'Playlist $responseIndex', 'Type': 'Playlist'}, + ], + 'TotalRecordCount': 1, + }); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final video = await client.fetchPlaylistsPage(playlistType: 'video'); + final audio = await client.fetchPlaylistsPage(playlistType: 'audio'); + + expect( + requests.requests.map((request) => '${request.method} ${request.url.path}').toList(), + ['GET /Items', 'GET /Items'], + reason: requests.log.join('\n'), + ); + for (final request in requests.requests) { + expect(request.url.queryParameters, { + 'userId': 'user-1', + 'IncludeItemTypes': 'Playlist', + 'Recursive': 'true', + 'StartIndex': '0', + 'Limit': '200', + 'Fields': 'Overview,DateCreated,DateLastSaved,DateModified,ChildCount,Tags', + 'EnableImageTypes': 'Primary,Backdrop,Logo', + 'ImageTypeLimit': '3', + }); + expect(request.url.queryParameters.containsKey('MediaTypes'), isFalse); + expect(request.body, isEmpty); + } + expect(video.items.single.playlistType, 'video'); + expect(audio.items.single.playlistType, 'audio'); + }); + + test('an Emby playlist timestamp comes from DateModified, which must be requested', () async { + // Emby leaves DateLastSaved null on playlists and, unlike the detail + // route, its list route honours `Fields` strictly — so the mapper's + // fallback is dead unless DateModified is in the request. + final requests = _RequestCapture( + (request) => jsonResponse({ + 'Items': [ + { + 'Id': 'pl-1', + 'Name': 'Road Trip', + 'DateCreated': '2026-01-01T00:00:00.0000000Z', + 'DateModified': '2026-02-03T04:05:06.0000000Z', + }, + ], + 'TotalRecordCount': 1, + }), + ); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final page = await client.fetchPlaylistsPage(playlistType: 'video'); + + expect(requests.requests.single.url.queryParameters['Fields'], contains('DateModified')); + expect(page.items.single.updatedAt, DateTime.utc(2026, 2, 3, 4, 5, 6).millisecondsSinceEpoch ~/ 1000); + expect(page.items.single.addedAt, DateTime.utc(2026).millisecondsSinceEpoch ~/ 1000); + }); + + test('Jellyfin keeps MediaTypes and lets the DTO media type win over the requested label', () async { + final requests = _RequestCapture((request) { + final requestedType = request.url.queryParameters['MediaTypes']; + return jsonResponse({ + 'Items': [ + { + 'Id': 'playlist-${requestedType!.toLowerCase()}', + 'Name': '$requestedType Playlist', + 'Type': 'Playlist', + 'MediaType': requestedType == 'Video' ? 'Audio' : 'Video', + }, + ], + 'TotalRecordCount': 1, + }); + }); + final client = testJellyfinClient(handler: requests.handle); + addTearDown(client.close); + + final video = await client.fetchPlaylistsPage(playlistType: 'video'); + final audio = await client.fetchPlaylistsPage(playlistType: 'audio'); + + expect( + requests.requests.map((request) => '${request.method} ${request.url.path}').toList(), + ['GET /Items', 'GET /Items'], + reason: requests.log.join('\n'), + ); + expect(requests.requests.map((request) => request.url.queryParameters['MediaTypes']).toList(), [ + 'Video', + 'Audio', + ]); + for (final request in requests.requests) { + expect(request.url.queryParameters['IncludeItemTypes'], 'Playlist'); + expect(request.body, isEmpty); + } + expect(video.items.single.playlistType, 'audio'); + expect(audio.items.single.playlistType, 'video'); + }); + }); + + group('MediaBrowser Continue Watching removal', () { + test('Emby hides an item from Continue Watching through the user-scoped route', () async { + final requests = _RequestCapture((_) => http.Response('', 204)); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + await client.removeFromContinueWatching(testMediaItem(id: 'item-1', backend: MediaBackend.emby)); + + // Emby 4.9.5 answers this 200 and keeps UserData.PlaybackPositionTicks, + // which is why the capability is advertised rather than throwing. + expect(requests.log, ['POST /Users/user-1/Items/item-1/HideFromResume?Hide=true']); + expect(client.capabilities.continueWatchingRemoval, isTrue); + }); + + test('Jellyfin still refuses Continue Watching removal without a request', () async { + final requests = _RequestCapture((_) => http.Response('', 204)); + final client = testJellyfinClient(handler: requests.handle); + addTearDown(client.close); + + // Jellyfin 10.11 404s on both HideFromResume spellings, so the operation + // must stay unsupported and issue nothing. + await expectLater( + client.removeFromContinueWatching(testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin)), + throwsA(isA()), + ); + expect(requests.log, isEmpty); + expect(client.capabilities.continueWatchingRemoval, isFalse); + }); + }); + + group('MediaBrowser name-pair item fields', () { + test('Emby item tags and genres survive the browse path via the name-pair arrays', () async { + // Real Emby DTO shape: the plain arrays are absent, the name-pair + // siblings carry the values. Reading only `Tags` dropped every label. + final client = testEmbyClient( + handler: (_) async => jsonResponse({ + 'Id': '7330', + 'Name': 'Movie 001', + 'Type': 'Movie', + 'Genres': [], + 'GenreItems': [ + {'Name': 'Action', 'Id': 3}, + {'Name': 'Science Fiction', 'Id': 4}, + ], + 'TagItems': [ + {'Name': 'smoke-label', 'Id': 7}, + ], + }), + ); + addTearDown(client.close); + + final item = (await client.fetchItem('7330'))!; + + expect(item.labels, ['smoke-label']); + expect(item.genres, ['Action', 'Science Fiction']); + expect(item.backend, MediaBackend.emby); + }); + + test('Jellyfin keeps reading the plain arrays and ignores absent name pairs', () async { + final client = testJellyfinClient( + handler: (_) async => jsonResponse({ + 'Id': 'item-1', + 'Name': 'Movie', + 'Type': 'Movie', + 'Genres': ['Drama'], + 'Tags': ['archive'], + }), + ); + addTearDown(client.close); + + final item = (await client.fetchItem('item-1'))!; + + expect(item.genres, ['Drama']); + expect(item.labels, ['archive']); + }); + + test('the plain array wins when a server sends both', () async { + final client = testEmbyClient( + handler: (_) async => jsonResponse({ + 'Id': '1', + 'Name': 'Both', + 'Type': 'Movie', + 'Tags': ['plain'], + 'TagItems': [ + {'Name': 'pair'}, + ], + }), + ); + addTearDown(client.close); + + expect((await client.fetchItem('1'))!.labels, ['plain']); + }); + }); + + group('MediaBrowser feature gates', () { + test('Emby returns no lyrics without a request while Jellyfin keeps its lyrics route', () async { + final embyRequests = _RequestCapture((_) => http.Response('harmful request', 500)); + final jellyfinRequests = _RequestCapture( + (_) => jsonResponse({ + 'Lyrics': [ + {'Text': 'Line one'}, + ], + }), + ); + final emby = testEmbyClient(handler: embyRequests.handle); + final jellyfin = testJellyfinClient(handler: jellyfinRequests.handle); + addTearDown(emby.close); + addTearDown(jellyfin.close); + + expect(await emby.fetchLyrics(_item(MediaBackend.emby, kind: MediaKind.track)), isNull); + final lyrics = await jellyfin.fetchLyrics(_item(MediaBackend.jellyfin, kind: MediaKind.track)); + + expect(embyRequests.log, isEmpty); + expect(jellyfinRequests.log, ['GET /Audio/item-1/Lyrics?']); + expect(lyrics, isNotNull); + expect(lyrics!.lines.map((line) => line.text).toList(), ['Line one']); + }); + + test('Emby derives playback markers from chapters without requesting media segments', () async { + final requests = _RequestCapture((request) { + if (request.url.path == '/Users/user-1/Items/item-1') { + return jsonResponse(_chapterItem()); + } + return http.Response('unexpected ${request.url}', 500); + }); + final client = testEmbyClient(handler: requests.handle); + addTearDown(client.close); + + final extras = await client.fetchPlaybackExtras('item-1'); + + expect( + requests.requests.map((request) => '${request.method} ${request.url.path}').toList(), + ['GET /Users/user-1/Items/item-1'], + reason: requests.log.join('\n'), + ); + expect(requests.requests.where((request) => request.url.path == '/MediaSegments/item-1'), isEmpty); + expect(extras.markers.map((marker) => marker.type).toList(), ['intro', 'credits']); + expect(extras.markers.first.startTimeOffset, 10000); + expect(extras.markers.first.endTimeOffset, 45000); + expect(extras.markers.last.startTimeOffset, 90000); + expect(extras.markers.last.endTimeOffset, 120000); + }); + + test('Jellyfin keeps requesting native media segments for playback markers', () async { + final requests = _RequestCapture((request) { + if (request.url.path == '/Users/user-1/Items/item-1') { + return jsonResponse(_chapterItem(chapters: const [])); + } + if (request.url.path == '/MediaSegments/item-1') { + return jsonResponse({ + 'Items': [ + {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, + ], + }); + } + return http.Response('unexpected ${request.url}', 500); + }); + final client = testJellyfinClient(handler: requests.handle); + addTearDown(client.close); + + final extras = await client.fetchPlaybackExtras('item-1'); + + expect( + requests.requests.map((request) => '${request.method} ${request.url.path}').toList(), + ['GET /Users/user-1/Items/item-1', 'GET /MediaSegments/item-1'], + reason: requests.log.join('\n'), + ); + expect(requests.requests.last.url.query, isEmpty); + expect(extras.markers.map((marker) => marker.type).toList(), ['intro']); + expect(extras.markers.single.startTimeOffset, 5000); + expect(extras.markers.single.endTimeOffset, 45000); + }); + + test('Emby disables scrub thumbnails while Jellyfin keeps them enabled without network traffic', () { + final embyRequests = _RequestCapture((_) => http.Response('unexpected', 500)); + final jellyfinRequests = _RequestCapture((_) => http.Response('unexpected', 500)); + final emby = testEmbyClient(handler: embyRequests.handle); + final jellyfin = testJellyfinClient(handler: jellyfinRequests.handle); + addTearDown(emby.close); + addTearDown(jellyfin.close); + + expect(emby.capabilities.scrubThumbnails, isFalse); + expect(jellyfin.capabilities.scrubThumbnails, isTrue); + expect(embyRequests.log, isEmpty); + expect(jellyfinRequests.log, isEmpty); + }); + }); + + group('MediaBrowser artwork upload', () { + // Both dialects reject a raw binary body with HTTP 500 — Emby 4.9.5 says + // `The input is not a valid Base-64 string`, Jellyfin 10.11 answers a bare + // `Error processing request.` — and both accept the base64 form with 204. + for (final (label, build) + in <(String, JellyfinClient Function({Future Function(http.Request)? handler}))>[ + ('Emby', testEmbyClient), + ('Jellyfin', testJellyfinClient), + ]) { + test('$label receives artwork as base64 text, not raw bytes', () async { + final requests = _RequestCapture((_) => http.Response('', 204)); + final client = build(handler: requests.handle); + addTearDown(client.close); + + const bytes = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]; + final ok = await client.uploadItemImage( + 'item-1', + imageType: 'Primary', + bytes: bytes, + contentType: 'image/jpeg', + ); + + expect(ok, isTrue); + expect(requests.log, ['POST /Items/item-1/Images/Primary?']); + final sent = requests.requests.single; + expect(sent.body, base64Encode(bytes)); + // The capture keeps method/url/body; the Content-Type header shape is + // asserted by the live path, not reachable through this record. + // The decoded payload must still be the original image. + expect(base64Decode(sent.body), bytes); + }); + } + }); +} diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index b62427df..ad7577d4 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -4486,32 +4486,40 @@ void main() { expect(requests[1].queryParameters['imageUrl'], 'https://img.example/poster.jpg'); }); - test('uploadItemImage sends binary image body and image content type', () async { + test('uploadItemImage sends the image as base64 text with the image content type', () async { + // This asserted a raw binary body until the transport was exercised + // against real servers: both dialects answer HTTP 500 for binary + // (Emby 4.9.5: `The input is not a valid Base-64 string`; Jellyfin 10.11: + // `Error processing request.`) and 204 for the base64 form. The + // `Content-Type` still names the image type — that is how the server + // picks the on-disk extension. Uri? capturedUri; - List? capturedBody; + String? capturedBody; Map? capturedHeaders; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; - capturedBody = request.bodyBytes; + capturedBody = request.body; capturedHeaders = request.headers; return http.Response('', 204); }), ); addTearDown(client.close); + const bytes = [0xff, 0xd8, 0xff, 0x00]; final success = await client.uploadItemImage( 'item-1', imageType: 'Primary', - bytes: [0xff, 0xd8, 0xff, 0x00], + bytes: bytes, contentType: 'image/jpeg', ); expect(success, isTrue); expect(capturedUri!.path, '/Items/item-1/Images/Primary'); - expect(capturedBody, [0xff, 0xd8, 0xff, 0x00]); - expect(capturedHeaders!['Content-Type'] ?? capturedHeaders!['content-type'], 'image/jpeg'); + expect(capturedBody, base64Encode(bytes)); + expect(base64Decode(capturedBody!), bytes); + expect(capturedHeaders!['Content-Type'] ?? capturedHeaders!['content-type'], contains('image/jpeg')); }); test('smart=true returns empty without network I/O', () async { diff --git a/test/services/jellyfin_display_metadata_test.dart b/test/services/jellyfin_display_metadata_test.dart new file mode 100644 index 00000000..32e9b282 --- /dev/null +++ b/test/services/jellyfin_display_metadata_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/jellyfin_display_metadata.dart'; + +/// HDR/Dolby Vision classification from a MediaBrowser `MediaStreams[]` entry. +/// +/// The fixtures are verbatim video-stream shapes captured from Jellyfin 10.11 +/// and Emby 4.9.5 for the same HDR10 HEVC file. They differ: Jellyfin sends +/// `VideoRangeType`, Emby does not — it sends `VideoRange: 'HDR 10'` plus its +/// own `ExtendedVideoType`. Detection must not depend on the Jellyfin-only +/// field, which is why both shapes are pinned here. +void main() { + group('HDR detection across MediaBrowser dialects', () { + // Emby 4.9.5, HDR10 HEVC. Note the absent VideoRangeType. + const embyHdr10 = { + 'Type': 'Video', + 'Codec': 'hevc', + 'Profile': 'Main 10', + 'BitDepth': 10, + 'VideoRange': 'HDR 10', + 'ColorTransfer': 'smpte2084', + 'ColorPrimaries': 'bt2020', + 'ColorSpace': 'bt2020nc', + 'ExtendedVideoType': 'Hdr10', + 'Width': 640, + 'Height': 360, + 'AverageFrameRate': 24, + }; + + // Jellyfin 10.11 shape for the same content. + const jellyfinHdr10 = { + 'Type': 'Video', + 'Codec': 'hevc', + 'Profile': 'Main 10', + 'BitDepth': 10, + 'VideoRange': 'HDR', + 'VideoRangeType': 'HDR10', + 'ColorTransfer': 'smpte2084', + 'ColorPrimaries': 'bt2020', + 'ColorSpace': 'bt2020nc', + 'Width': 640, + 'Height': 360, + 'AverageFrameRate': 24, + }; + + const sdr = { + 'Type': 'Video', + 'Codec': 'h264', + 'VideoRange': 'SDR', + 'Width': 640, + 'Height': 360, + 'AverageFrameRate': 24, + }; + + test('Emby HDR10 is detected without the Jellyfin-only VideoRangeType', () { + expect(embyHdr10.containsKey('VideoRangeType'), isFalse, reason: 'fixture must reflect the real Emby shape'); + expect(jellyfinVideoStreamIsHdr(const {}, embyHdr10), isTrue); + + final criteria = jellyfinDisplayCriteriaFromStream(const {}, embyHdr10); + expect(criteria, isNotNull); + expect(criteria!.isHdr, isTrue); + expect(criteria.transfer, 'smpte2084'); + expect(criteria.primaries, 'bt2020'); + }); + + test('Jellyfin HDR10 is detected from its own field set', () { + expect(jellyfinVideoStreamIsHdr(const {}, jellyfinHdr10), isTrue); + expect(jellyfinDisplayCriteriaFromStream(const {}, jellyfinHdr10)!.isHdr, isTrue); + }); + + test('an SDR stream is not misreported as HDR on either dialect', () { + expect(jellyfinVideoStreamIsHdr(const {}, sdr), isFalse); + expect(jellyfinVideoStreamIsHdr(const {}, {...sdr, 'VideoRangeType': 'SDR'}), isFalse); + }); + + test('Dolby Vision is detected from the Dv* fields both dialects share', () { + const dovi = { + 'Type': 'Video', + 'Codec': 'hevc', + 'VideoRange': 'HDR', + 'DvProfile': 8, + 'DvBlSignalCompatibilityId': 1, + 'DvVersionMajor': 1, + 'Width': 3840, + 'Height': 2160, + }; + + expect(jellyfinVideoStreamIsDolbyVision(dovi), isTrue); + expect(jellyfinDolbyVisionProfile(dovi), 8); + expect(jellyfinVideoStreamIsHdr(const {}, dovi), isTrue); + }); + + test('a stream carrying no range signal at all is treated as SDR, not unknown', () { + const bare = {'Type': 'Video', 'Codec': 'h264', 'Width': 1920, 'Height': 1080}; + + expect(jellyfinVideoStreamIsHdr(const {}, bare), isFalse); + expect(jellyfinVideoStreamIsDolbyVision(bare), isFalse); + }); + }); +} diff --git a/test/services/jellyfin_endpoint_discovery_test.dart b/test/services/jellyfin_endpoint_discovery_test.dart index a77e6cb4..3c73bb4c 100644 --- a/test/services/jellyfin_endpoint_discovery_test.dart +++ b/test/services/jellyfin_endpoint_discovery_test.dart @@ -5,6 +5,7 @@ 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/media/media_browser_dialect.dart'; import 'package:plezy/services/jellyfin_endpoint_discovery.dart'; http.Response _info({required String id, String name = 'Home'}) => http.Response( @@ -53,13 +54,73 @@ void main() { expect(JellyfinEndpointDiscovery.normalizeBaseUrl('jf.example.com/'), 'jf.example.com'); }); - test('expands bare host input into Jellyfin URL candidates', () { - expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('jf.example.com'), [ - 'http://jf.example.com:8096', - 'https://jf.example.com', - 'https://jf.example.com:8096', - 'http://jf.example.com', - ]); + test('Jellyfin bare host expansion preserves its ordered URL candidates', () { + const expected = ['http://host.lan:8096', 'https://host.lan', 'https://host.lan:8096', 'http://host.lan']; + + expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('host.lan'), expected); + expect(JellyfinEndpointDiscovery.buildUserInputCandidates(['host.lan']).probeBaseUrls, expected); + }); + + test('Emby bare host expansion includes the 8920 HTTPS candidate in order', () { + const expected = [ + 'http://host.lan:8096', + 'https://host.lan', + 'https://host.lan:8920', + 'https://host.lan:8096', + 'http://host.lan', + ]; + + expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('host.lan', dialect: MediaBrowserDialect.emby), expected); + expect( + JellyfinEndpointDiscovery.buildUserInputCandidates([ + 'host.lan', + ], dialect: MediaBrowserDialect.emby).probeBaseUrls, + expected, + ); + }); + + test('explicit URLs are never expanded for either dialect', () { + const explicitUrl = 'https://host.lan:9443/emby'; + + for (final dialect in MediaBrowserDialect.values) { + expect(JellyfinEndpointDiscovery.expandInputToBaseUrls(explicitUrl, dialect: dialect), [ + explicitUrl, + ], reason: dialect.id); + final candidates = JellyfinEndpointDiscovery.buildUserInputCandidates([explicitUrl], dialect: dialect); + expect(candidates.probeBaseUrls, [explicitUrl], reason: dialect.id); + expect(candidates.explicitBaseUrls, [explicitUrl], reason: dialect.id); + } + }); + + test('probe records Jellyfin, Emby, and unknown public-info dialects', () async { + Future probe(Map publicInfo) { + final discovery = JellyfinEndpointDiscovery( + testHttpClientFactory: () => MockClient((request) async { + expect(request.url.path, '/System/Info/Public'); + return http.Response(jsonEncode(publicInfo), 200, headers: {'content-type': 'application/json'}); + }), + ); + return discovery.probe('https://server.example.com'); + } + + final jellyfin = await probe({ + 'Id': 'jellyfin-server', + 'ServerName': 'Jellyfin Home', + 'Version': '10.10.7', + 'ProductName': 'Jellyfin Server', + }); + final emby = await probe({ + 'Id': 'emby-server', + 'ServerName': 'Emby Home', + 'Version': '4.9.5.0', + 'LocalAddresses': [], + 'RemoteAddresses': [], + }); + final unknown = await probe({'Id': 'unknown-server', 'ServerName': 'Unknown Home', 'Version': '1.0.0'}); + + expect(jellyfin.dialect, MediaBrowserDialect.jellyfin); + expect(emby.dialect, MediaBrowserDialect.emby); + expect(unknown.dialect, isNull); }); test('expands host and port input without changing the port', () { @@ -271,6 +332,39 @@ void main() { ); }); + test('Emby empty-input errors name the selected product', () async { + final discovery = JellyfinEndpointDiscovery(dialect: MediaBrowserDialect.emby); + + await expectLater( + discovery.raceEndpoints(const []), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + 'Enter at least one Emby server URL', + ), + ), + ); + }); + + test('Emby machine-id mismatch errors name the selected product', () async { + final discovery = JellyfinEndpointDiscovery( + dialect: MediaBrowserDialect.emby, + testHttpClientFactory: () => MockClient((_) async => _info(id: 'srv-2')), + ); + + await expectLater( + discovery.raceEndpoints(['https://emby.example.com'], expectedMachineId: 'srv-1', baseUrlsToValidate: const []), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + 'The URL does not match this Emby server', + ), + ), + ); + }); + test('expected machine ID retains candidates that returned no identity', () async { final discovery = JellyfinEndpointDiscovery( testHttpClientFactory: () => MockClient((request) async { diff --git a/test/services/jellyfin_lan_discovery_service_test.dart b/test/services/jellyfin_lan_discovery_service_test.dart index 2eecafe3..9ca0d6fe 100644 --- a/test/services/jellyfin_lan_discovery_service_test.dart +++ b/test/services/jellyfin_lan_discovery_service_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; import 'package:plezy/services/jellyfin_lan_discovery_service.dart'; import 'package:plezy/utils/udp_broadcast_sockets.dart'; @@ -10,40 +11,86 @@ void main() { test('parses Jellyfin UDP discovery responses', () { final server = JellyfinLanDiscoveryService.parseDiscoveryResponse( utf8.encode(jsonEncode({'Address': 'http://192.168.1.20:8096/', 'Id': 'srv-1', 'Name': 'Home'})), + dialect: MediaBrowserDialect.jellyfin, ); expect(server, isNotNull); expect(server!.address, 'http://192.168.1.20:8096'); expect(server.id, 'srv-1'); expect(server.name, 'Home'); + expect(server.dialect, MediaBrowserDialect.jellyfin); + }); + + test('stamps the asked-for dialect onto an Emby reply', () { + // Emby 4.9.5 answers `who is EmbyServer?` with the same three keys, so + // the dialect can only come from which payload was sent. + final server = JellyfinLanDiscoveryService.parseDiscoveryResponse( + utf8.encode(jsonEncode({'Address': 'http://127.0.0.1:8096', 'Id': 'emby-1', 'Name': '7befeeb2e8c9'})), + dialect: MediaBrowserDialect.emby, + ); + + expect(server?.dialect, MediaBrowserDialect.emby); + expect(server?.address, 'http://127.0.0.1:8096'); + expect(server?.name, '7befeeb2e8c9'); }); test('does not expand bare discovery addresses while parsing', () { final server = JellyfinLanDiscoveryService.parseDiscoveryResponse( utf8.encode(jsonEncode({'Address': '192.168.1.20', 'Id': 'srv-1', 'Name': 'Home'})), + dialect: MediaBrowserDialect.jellyfin, ); expect(server?.address, '192.168.1.20'); }); test('ignores malformed discovery responses', () { - expect(JellyfinLanDiscoveryService.parseDiscoveryResponse(utf8.encode('not json')), isNull); expect( - JellyfinLanDiscoveryService.parseDiscoveryResponse(utf8.encode(jsonEncode({'Address': 'http://x'}))), + JellyfinLanDiscoveryService.parseDiscoveryResponse( + utf8.encode('not json'), + dialect: MediaBrowserDialect.jellyfin, + ), + isNull, + ); + expect( + JellyfinLanDiscoveryService.parseDiscoveryResponse( + utf8.encode(jsonEncode({'Address': 'http://x'})), + dialect: MediaBrowserDialect.jellyfin, + ), isNull, ); }); test('sorts discovered servers deterministically', () { final sorted = JellyfinLanDiscoveryService.sortDiscoveredServers([ - DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-2', name: 'Home'), - DiscoveredJellyfinServer(address: 'http://192.168.1.10:8096', id: 'srv-3', name: 'Office'), - DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'), + DiscoveredJellyfinServer( + address: 'http://192.168.1.20:8096', + id: 'srv-2', + name: 'Home', + dialect: MediaBrowserDialect.jellyfin, + ), + DiscoveredJellyfinServer( + address: 'http://192.168.1.10:8096', + id: 'srv-3', + name: 'Office', + dialect: MediaBrowserDialect.jellyfin, + ), + DiscoveredJellyfinServer( + address: 'http://192.168.1.20:8096', + id: 'srv-1', + name: 'Home', + dialect: MediaBrowserDialect.emby, + ), ]); expect(sorted.map((server) => server.id), ['srv-1', 'srv-2', 'srv-3']); }); + test('discovery messages are the two distinct measured payloads', () { + expect(MediaBrowserDialect.jellyfin.lanDiscoveryMessage, 'who is JellyfinServer?'); + expect(MediaBrowserDialect.emby.lanDiscoveryMessage, 'who is EmbyServer?'); + expect(JellyfinLanDiscoveryService.discoveryPort, 7359); + }); + test('listenDatagrams receives queued loopback datagrams', () async { final receiver = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index 744c3ea4..0dccdbc3 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_stream.dart'; @@ -96,6 +97,27 @@ void main() { expect(item.serverName, 'Home'); }); + test('Emby dialect stamps backend and preserves opaque item and media source ids', () { + final item = JellyfinMappers.mediaItem( + { + 'Id': '7330', + 'Name': 'Movie', + 'Type': 'Movie', + 'MediaSources': [ + {'Id': 'mediasource_7330', 'MediaStreams': >[]}, + ], + }, + serverId: ServerId(_serverId), + absolutizer: null, + dialect: MediaBrowserDialect.emby, + )!; + + expect(item.id, '7330'); + expect(item.backend, MediaBackend.emby); + expect(item.mediaVersions!.single.id, 'mediasource_7330'); + expect(item.mediaVersions!.single.parts.single.id, 'mediasource_7330'); + }); + test('divides the Tomatometer rather than range-sniffing it', () { // A CriticRating of 9 means 9%, not 9.0/10 — folding by magnitude would // silently promote a rotten score to fresh. @@ -556,6 +578,16 @@ void main() { } }); + test('Emby dialect stamps the library backend', () { + final library = JellyfinMappers.library( + {'Id': 'view-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, + serverId: ServerId(_serverId), + dialect: MediaBrowserDialect.emby, + )!; + + expect(library.backend, MediaBackend.emby); + }); + test('maps content-type-less collection folders to a movie and show root browse', () { for (final view in [ {'Id': 'view-missing-type', 'Name': 'Mixed', 'Type': 'CollectionFolder', 'IsFolder': true}, diff --git a/test/services/media_browser_paths_test.dart b/test/services/media_browser_paths_test.dart new file mode 100644 index 00000000..18182468 --- /dev/null +++ b/test/services/media_browser_paths_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; +import 'package:plezy/services/media_browser_paths.dart'; + +/// Route table for the endpoints where the two MediaBrowser dialects diverge. +/// +/// Every Emby expectation below was measured against Emby 4.9.5; the Jellyfin +/// spelling of the same route returns 404 there (500 for `/Users/Me`, which +/// binds `Me` as a user id). These are the exact strings the client sends, so a +/// regression here is a silent loss of watch state or Continue Watching. +void main() { + const jellyfin = MediaBrowserPaths(dialect: MediaBrowserDialect.jellyfin, userId: 'user-1'); + const emby = MediaBrowserPaths(dialect: MediaBrowserDialect.emby, userId: 'user-1'); + + group('Jellyfin uses the 10.9+ unprefixed routes', () { + test('current user', () => expect(jellyfin.currentUser, '/Users/Me')); + test('resume', () => expect(jellyfin.resumeItems, '/UserItems/Resume')); + test('played', () => expect(jellyfin.playedItem('item-9'), '/UserPlayedItems/item-9')); + test('favorite', () => expect(jellyfin.favoriteItem('item-9'), '/UserFavoriteItems/item-9')); + test('rating', () => expect(jellyfin.itemRating('item-9'), '/UserItems/item-9/Rating')); + test('trailers', () => expect(jellyfin.localTrailers('item-9'), '/Items/item-9/LocalTrailers')); + test('extras', () => expect(jellyfin.specialFeatures('item-9'), '/Items/item-9/SpecialFeatures')); + }); + + group('Emby uses the original user-scoped routes', () { + test('current user', () => expect(emby.currentUser, '/Users/user-1')); + test('resume', () => expect(emby.resumeItems, '/Users/user-1/Items/Resume')); + test('played', () => expect(emby.playedItem('item-9'), '/Users/user-1/PlayedItems/item-9')); + test('favorite', () => expect(emby.favoriteItem('item-9'), '/Users/user-1/FavoriteItems/item-9')); + test('rating', () => expect(emby.itemRating('item-9'), '/Users/user-1/Items/item-9/Rating')); + test('trailers', () => expect(emby.localTrailers('item-9'), '/Users/user-1/Items/item-9/LocalTrailers')); + test('extras', () => expect(emby.specialFeatures('item-9'), '/Users/user-1/Items/item-9/SpecialFeatures')); + }); + + group('path segment encoding', () { + test('item ids are percent-encoded so a hostile id cannot escape the path', () { + expect(emby.playedItem('a/b?c'), '/Users/user-1/PlayedItems/a%2Fb%3Fc'); + expect(jellyfin.playedItem('a/b?c'), '/UserPlayedItems/a%2Fb%3Fc'); + }); + + test('user ids are percent-encoded in the user-scoped forms', () { + const hostile = MediaBrowserPaths(dialect: MediaBrowserDialect.emby, userId: 'u/1'); + expect(hostile.currentUser, '/Users/u%2F1'); + expect(hostile.resumeItems, '/Users/u%2F1/Items/Resume'); + }); + + test('Emby item ids are opaque numeric strings and pass through unchanged', () { + // Emby ids look like "7330"; Jellyfin's are 32-char hex GUIDs. Both are + // treated as opaque. + expect(emby.playedItem('7330'), '/Users/user-1/PlayedItems/7330'); + }); + }); +} diff --git a/test/services/plex_api_cache_test.dart b/test/services/plex_api_cache_test.dart index 291495ba..063ae623 100644 --- a/test/services/plex_api_cache_test.dart +++ b/test/services/plex_api_cache_test.dart @@ -74,6 +74,8 @@ void main() { // Register the other backend last; cleanup must not depend on whichever // concrete singleton happened to initialize most recently. JellyfinApiCache.initialize(db); + final mediaBrowserCache = ApiCache.forBackend(MediaBackend.jellyfin); + expect(identical(ApiCache.forBackend(MediaBackend.emby), mediaBrowserCache), isTrue); await ApiCache.clearRegisteredVolatile(); expect(await cache.get(ServerId('srv'), '/volatile'), isNull); @@ -85,7 +87,10 @@ void main() { JellyfinApiCache.initialize(newDb); expect(() => ApiCache.forBackend(MediaBackend.plex), throwsStateError); - expect(identical(ApiCache.forBackend(MediaBackend.jellyfin).database, newDb), isTrue); + final replacement = JellyfinApiCache.instance; + expect(identical(ApiCache.forBackend(MediaBackend.jellyfin), replacement), isTrue); + expect(identical(ApiCache.forBackend(MediaBackend.emby), replacement), isTrue); + expect(identical(replacement.database, newDb), isTrue); await newDb.close(); }); diff --git a/test/test_helpers/backend_client_fixtures.dart b/test/test_helpers/backend_client_fixtures.dart index df1ea3cb..d987da87 100644 --- a/test/test_helpers/backend_client_fixtures.dart +++ b/test/test_helpers/backend_client_fixtures.dart @@ -2,11 +2,15 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/utils/active_client_scope.dart'; +/// A MediaBrowser-family connection fixture. Defaults to the Jellyfin dialect; +/// pass `dialect: MediaBrowserDialect.emby` (or use [testEmbyConnection]) to +/// exercise the Emby routes. JellyfinConnection testJellyfinConnection({ String machineId = 'srv-1', String userId = 'user-1', @@ -21,6 +25,7 @@ JellyfinConnection testJellyfinConnection({ ConnectionStatus status = ConnectionStatus.unknown, DateTime? createdAt, DateTime? lastAuthenticatedAt, + MediaBrowserDialect dialect = MediaBrowserDialect.jellyfin, }) { return JellyfinConnection( id: id ?? '$machineId/$userId', @@ -32,6 +37,7 @@ JellyfinConnection testJellyfinConnection({ userName: userName, accessToken: accessToken, deviceId: deviceId, + dialect: dialect, isAdministrator: isAdministrator, status: status, createdAt: createdAt ?? DateTime.utc(2024), @@ -39,6 +45,42 @@ JellyfinConnection testJellyfinConnection({ ); } +/// Emby-dialect twin of [testJellyfinConnection]. Same field defaults so a +/// suite can be parameterized over both dialects and assert only the route +/// differences. +JellyfinConnection testEmbyConnection({ + String machineId = 'srv-1', + String userId = 'user-1', + String? id, + String baseUrl = 'https://emby.example.com', + List? baseUrls, + String serverName = 'Home', + String userName = 'User', + String accessToken = 'token', + String deviceId = 'device-1', + bool isAdministrator = false, + ConnectionStatus status = ConnectionStatus.unknown, + DateTime? createdAt, + DateTime? lastAuthenticatedAt, +}) { + return testJellyfinConnection( + machineId: machineId, + userId: userId, + id: id, + baseUrl: baseUrl, + baseUrls: baseUrls, + serverName: serverName, + userName: userName, + accessToken: accessToken, + deviceId: deviceId, + isAdministrator: isAdministrator, + status: status, + createdAt: createdAt, + lastAuthenticatedAt: lastAuthenticatedAt, + dialect: MediaBrowserDialect.emby, + ); +} + PlexConfig testPlexConfig({ String baseUrl = 'https://plex.example.com', String? token = 'token', @@ -81,6 +123,22 @@ JellyfinClient testJellyfinClient({ ); } +/// Emby-dialect twin of [testJellyfinClient] — same `JellyfinClient` class, an +/// Emby connection underneath. +JellyfinClient testEmbyClient({ + JellyfinConnection? connection, + http.Client? httpClient, + Future Function(http.Request request)? handler, + void Function()? onAllEndpointsExhausted, +}) { + return testJellyfinClient( + connection: connection ?? testEmbyConnection(), + httpClient: httpClient, + handler: handler, + onAllEndpointsExhausted: onAllEndpointsExhausted, + ); +} + PlexClient testPlexClient({ PlexConfig? config, String baseUrl = 'https://plex.example.com', diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 7c2b5cbb..10bc4867 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -126,6 +126,36 @@ void main() { ); }); + test('Emby follows the server answer, not the admin bit', () { + expect( + isMediaDeletionAllowed( + itemBackend: MediaBackend.emby, + resolvedItemPermission: false, + isAdminActionAllowed: true, + ), + isFalse, + ); + expect( + isMediaDeletionAllowed( + itemBackend: MediaBackend.emby, + resolvedItemPermission: true, + isAdminActionAllowed: false, + ), + isTrue, + ); + }); + + test('Emby fails closed when the permission is unknown', () { + expect( + isMediaDeletionAllowed( + itemBackend: MediaBackend.emby, + resolvedItemPermission: null, + isAdminActionAllowed: true, + ), + isFalse, + ); + }); + test('Plex keeps its account-level gate', () { expect( isMediaDeletionAllowed(