diff --git a/assets/jellyfin_icon.svg b/assets/jellyfin_icon.svg new file mode 100644 index 00000000..a5689016 --- /dev/null +++ b/assets/jellyfin_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/plex_chevron.svg b/assets/plex_chevron.svg new file mode 100644 index 00000000..c100a24a --- /dev/null +++ b/assets/plex_chevron.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/connection/connection.dart b/lib/connection/connection.dart new file mode 100644 index 00000000..2fa709af --- /dev/null +++ b/lib/connection/connection.dart @@ -0,0 +1,328 @@ +import '../media/media_backend.dart'; +import '../models/plex/plex_home_user.dart'; +import '../services/plex_auth_service.dart'; + +/// Identifier of a backend kind a [Connection] points at. Lighter-weight than +/// [MediaBackend] for places that only care about persistence/auth shape +/// (e.g. database column values). +enum ConnectionKind { + plex, + jellyfin; + + String get id => switch (this) { + ConnectionKind.plex => 'plex', + ConnectionKind.jellyfin => 'jellyfin', + }; + + static ConnectionKind fromId(String id) => switch (id) { + 'plex' => ConnectionKind.plex, + 'jellyfin' => ConnectionKind.jellyfin, + _ => throw ArgumentError('Unknown ConnectionKind id: $id'), + }; + + MediaBackend get backend => switch (this) { + ConnectionKind.plex => MediaBackend.plex, + ConnectionKind.jellyfin => MediaBackend.jellyfin, + }; +} + +/// Health snapshot for a connection. Updated by the orchestrator each time a +/// session is established or refreshed. +enum ConnectionStatus { unknown, online, offline, authError, disabled } + +/// A media server connection — a unit of authentication the user added. +/// +/// A `PlexAccountConnection` carries one Plex account + its discovered servers + an +/// optional active Home profile. A `JellyfinConnection` is a single server + +/// user. Most users only ever add one connection. +sealed class Connection { + String get id; + ConnectionKind get kind; + String get displayName; + ConnectionStatus get status; + DateTime get createdAt; + DateTime? get lastAuthenticatedAt; + + /// Backend kind as a [MediaBackend] — for UI that branches on backend + /// (badges, etc.). Just a passthrough to [kind.backend]. + MediaBackend get backend => kind.backend; + + /// Primary label shown in connection-list UIs. Plex shows the active + /// profile/account name; Jellyfin shows the server name. + String get displayLabel; + + /// Secondary line shown beneath [displayLabel] in connection-list UIs. + /// Plex: server count; Jellyfin: `userName · baseUrl`. May be null when + /// no useful subtitle exists. + String? get displaySubtitle; + + /// Backend-specific config payload, persisted as JSON. Each subclass + /// defines the schema. + Map toConfigJson(); +} + +/// A Plex account connection. +/// +/// Fields here mirror what [PlexAuthService] gathers during PIN OAuth: an +/// account token (long-lived), the per-device client identifier (so plex.tv +/// doesn't see a "new device" each launch), and the optional Home user the +/// user has switched into. +class PlexAccountConnection extends Connection { + @override + final String id; + + @override + final ConnectionStatus status; + + @override + final DateTime createdAt; + + @override + final DateTime? lastAuthenticatedAt; + + /// plex.tv account access token. + final String accountToken; + + /// Per-device client identifier. Stable across launches. + final String clientIdentifier; + + /// Display name shown for this connection (typically the Plex account email + /// or username, fallback "Plex"). + final String accountLabel; + + /// Active Home user, or `null` for the main account. + final PlexHomeUser? activeProfile; + + /// Servers discovered for this account (cached). Populated by the auth + /// flow and refreshed periodically. + final List servers; + + PlexAccountConnection({ + required this.id, + required this.accountToken, + required this.clientIdentifier, + required this.accountLabel, + this.activeProfile, + this.servers = const [], + this.status = ConnectionStatus.unknown, + required this.createdAt, + this.lastAuthenticatedAt, + }); + + @override + ConnectionKind get kind => ConnectionKind.plex; + + @override + String get displayName => activeProfile != null && activeProfile!.title.isNotEmpty + ? '${activeProfile!.title} · $accountLabel' + : accountLabel; + + @override + String get displayLabel => displayName; + + @override + String? get displaySubtitle => servers.length == 1 ? '1 Plex server' : '${servers.length} Plex servers'; + + PlexAccountConnection copyWith({ + String? id, + String? accountToken, + String? clientIdentifier, + String? accountLabel, + PlexHomeUser? activeProfile, + bool clearActiveProfile = false, + List? servers, + ConnectionStatus? status, + DateTime? createdAt, + DateTime? lastAuthenticatedAt, + }) { + return PlexAccountConnection( + id: id ?? this.id, + accountToken: accountToken ?? this.accountToken, + clientIdentifier: clientIdentifier ?? this.clientIdentifier, + accountLabel: accountLabel ?? this.accountLabel, + activeProfile: clearActiveProfile ? null : (activeProfile ?? this.activeProfile), + servers: servers ?? this.servers, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt, + ); + } + + @override + Map toConfigJson() { + return { + 'accountToken': accountToken, + 'clientIdentifier': clientIdentifier, + 'accountLabel': accountLabel, + 'activeProfile': activeProfile?.toJson(), + 'servers': servers.map((s) => s.toJson()).toList(), + }; + } + + factory PlexAccountConnection.fromConfigJson({ + required String id, + required Map json, + required ConnectionStatus status, + required DateTime createdAt, + DateTime? lastAuthenticatedAt, + }) { + final profileJson = json['activeProfile']; + final activeProfile = profileJson is Map ? PlexHomeUser.fromJson(profileJson) : null; + final serversJson = json['servers']; + final servers = serversJson is List + ? serversJson.whereType>().map(PlexServer.fromJson).toList() + : []; + return PlexAccountConnection( + id: id, + accountToken: json['accountToken'] as String? ?? '', + clientIdentifier: json['clientIdentifier'] as String? ?? '', + accountLabel: json['accountLabel'] as String? ?? 'Plex', + activeProfile: activeProfile, + servers: servers, + status: status, + createdAt: createdAt, + lastAuthenticatedAt: lastAuthenticatedAt, + ); + } +} + +/// A single-server Jellyfin connection. +class JellyfinConnection extends Connection { + @override + final String id; + + @override + final ConnectionStatus status; + + @override + final DateTime createdAt; + + @override + final DateTime? lastAuthenticatedAt; + + /// Server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`. + final String baseUrl; + + /// Server's reported name (System/Info). + final String serverName; + + /// Server's machine identifier (System/Info `Id`). + final String serverMachineId; + + /// Authenticated Jellyfin user id (UUID). + final String userId; + + /// Authenticated user's display name. + final String userName; + + /// Long-lived access token from `/Users/AuthenticateByName`. + final String accessToken; + + /// Per-device client identifier (same value sent in the + /// `Authorization: MediaBrowser DeviceId="..."` header). + final String deviceId; + + /// Whether this user is a Jellyfin 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; + + JellyfinConnection({ + required this.id, + required this.baseUrl, + required this.serverName, + required this.serverMachineId, + required this.userId, + required this.userName, + required this.accessToken, + required this.deviceId, + this.isAdministrator = false, + this.status = ConnectionStatus.unknown, + required this.createdAt, + this.lastAuthenticatedAt, + }); + + @override + ConnectionKind get kind => ConnectionKind.jellyfin; + + @override + String get displayName => '$userName · $serverName'; + + @override + String get displayLabel => serverName; + + @override + String? get displaySubtitle => '$userName · ${_truncateUrl(baseUrl)}'; + + static String _truncateUrl(String url) { + if (url.length <= 40) return url; + return '${url.substring(0, 37)}…'; + } + + JellyfinConnection copyWith({ + String? id, + String? baseUrl, + String? serverName, + String? serverMachineId, + String? userId, + String? userName, + String? accessToken, + String? deviceId, + bool? isAdministrator, + ConnectionStatus? status, + DateTime? createdAt, + DateTime? lastAuthenticatedAt, + }) { + return JellyfinConnection( + id: id ?? this.id, + baseUrl: baseUrl ?? this.baseUrl, + serverName: serverName ?? this.serverName, + serverMachineId: serverMachineId ?? this.serverMachineId, + userId: userId ?? this.userId, + userName: userName ?? this.userName, + accessToken: accessToken ?? this.accessToken, + deviceId: deviceId ?? this.deviceId, + isAdministrator: isAdministrator ?? this.isAdministrator, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt, + ); + } + + @override + Map toConfigJson() { + return { + 'baseUrl': baseUrl, + 'serverName': serverName, + 'serverMachineId': serverMachineId, + 'userId': userId, + 'userName': userName, + 'accessToken': accessToken, + 'deviceId': deviceId, + 'isAdministrator': isAdministrator, + }; + } + + factory JellyfinConnection.fromConfigJson({ + required String id, + required Map json, + required ConnectionStatus status, + required DateTime createdAt, + DateTime? lastAuthenticatedAt, + }) { + return JellyfinConnection( + id: id, + baseUrl: json['baseUrl'] as String? ?? '', + serverName: json['serverName'] as String? ?? 'Jellyfin', + serverMachineId: json['serverMachineId'] as String? ?? '', + userId: json['userId'] as String? ?? '', + userName: json['userName'] as String? ?? '', + accessToken: json['accessToken'] as String? ?? '', + deviceId: json['deviceId'] as String? ?? '', + isAdministrator: json['isAdministrator'] as bool? ?? false, + status: status, + createdAt: createdAt, + lastAuthenticatedAt: lastAuthenticatedAt, + ); + } +} diff --git a/lib/connection/connection_auth_service.dart b/lib/connection/connection_auth_service.dart new file mode 100644 index 00000000..304b0a39 --- /dev/null +++ b/lib/connection/connection_auth_service.dart @@ -0,0 +1,20 @@ +import 'connection.dart'; + +/// Backend-neutral auth service interface. Each backend's implementation +/// (`PlexConnectionAuthService`, `JellyfinConnectionAuthService`) drives its +/// own UX (PIN flow vs. password) but produces the same opaque +/// [Connection] record at the end. +abstract class ConnectionAuthService { + /// Best-effort check that an existing token still works. Returns false on + /// 401/403; throws on transport failures the caller should retry. + Future validate(Connection connection); + + /// Refresh whatever side-channel state belongs to a connection — for Plex + /// that's the discovered server list and Home users; for Jellyfin it's a + /// no-op once auth has succeeded. Returns the updated connection. + Future refresh(Connection connection); + + /// Revoke the token server-side and forget local credentials. The caller + /// is responsible for removing the row from [ConnectionRegistry]. + Future signOut(Connection connection); +} diff --git a/lib/connection/connection_bootstrap.dart b/lib/connection/connection_bootstrap.dart new file mode 100644 index 00000000..0f16172f --- /dev/null +++ b/lib/connection/connection_bootstrap.dart @@ -0,0 +1,327 @@ +import 'dart:convert'; + +import 'package:uuid/uuid.dart'; + +import '../models/plex/plex_home.dart'; +import '../models/plex/plex_home_user.dart'; +import '../profiles/profile.dart'; +import '../profiles/profile_registry.dart'; +import '../services/plex_auth_service.dart'; +import '../services/server_registry.dart'; +import '../services/storage_service.dart'; +import '../utils/app_logger.dart'; +import 'connection.dart'; +import 'connection_registry.dart'; + +/// One-shot helpers that bridge between the legacy single-Plex-account +/// SharedPreferences state (`StorageService.plexToken` + +/// `currentUserUUID` + `homeUsersCache` + `ServerRegistry.getServers()`) +/// and the new [ConnectionRegistry] world. +/// +/// Plex Home users are NOT persisted here — the bootstrap copies the +/// legacy `homeUsersCache` into the per-connection +/// `plex_home_users_{connectionId}` SharedPreferences slot so +/// [PlexHomeService] picks it up on cold start. +class ConnectionBootstrap { + ConnectionBootstrap({ + required this.storage, + required this.connectionRegistry, + required this.serverRegistry, + required this.profileRegistry, + Future> Function(String accountToken)? plexHomeUserFetcher, + Future> Function(String accountToken)? plexUserInfoFetcher, + }) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers, + _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo; + + final StorageService storage; + final ConnectionRegistry connectionRegistry; + final ServerRegistry serverRegistry; + final ProfileRegistry profileRegistry; + final Future> Function(String accountToken) _plexHomeUserFetcher; + final Future> Function(String accountToken) _plexUserInfoFetcher; + + static const String _keyProfileMigrationV1Done = 'profile_migration_v1_done'; + static const String _ownerProfileIdPrefix = 'local-'; + + /// Run all idempotent boot-time migrations. Best-effort — errors are + /// logged but never thrown. + Future run() async { + await seedFromDevTokenDefine(); + final hadLegacyPlexToken = (storage.getPlexToken() ?? '').isNotEmpty; + final hadLegacyProfileState = _hasLegacyProfileState(); + final migratedAccount = await migrateLegacyPlexAccount(); + final account = await _firstPlexAccount(migratedAccount); + final alreadyMigrated = storage.prefs.getBool(_keyProfileMigrationV1Done) ?? false; + if (!alreadyMigrated) { + if (account == null && (hadLegacyPlexToken || hadLegacyProfileState)) { + appLogger.w('Migration: legacy profile state present but Plex account was not migrated; will retry later'); + return; + } + // Drop any plex_home rows left over from the pre-refactor data + // model — Plex Home users are now fetched live, never persisted. + await profileRegistry.dropAllPlexHomeRows(); + if (account != null) { + final prepared = await _preparePlexVirtualProfile(account); + if (!prepared) { + if (migratedAccount != null && hadLegacyPlexToken) { + await connectionRegistry.remove(migratedAccount.id); + } + appLogger.w('Migration: could not hydrate Plex Home profiles for ${account.id}; will retry later'); + return; + } + } + await _ensureOwnerProfile(); + if (hadLegacyPlexToken) { + await storage.clearLegacyPlexToken(); + } + await storage.clearServersList(); + await storage.prefs.setBool(_keyProfileMigrationV1Done, true); + } else { + await storage.clearServersList(); + await _recoverLegacyProfilePromotionIfNeeded(account); + await _migrateLegacyPlexHomeUsersCacheForExistingAccount(account); + } + } + + bool _hasLegacyProfileState() { + return (storage.getCurrentUserUUID() ?? '').isNotEmpty || + (storage.prefs.getString('home_users_cache') ?? '').isNotEmpty; + } + + /// Screenshot automation injects a Plex token via the `PLEX_TOKEN` + /// dart-define so the app boots already-signed-in. Inserts a + /// [PlexAccountConnection] directly when the env var is non-empty AND + /// the registry doesn't already have a Plex account, fetching the user + /// info + servers like the auth screen does. No-op in normal builds. + Future seedFromDevTokenDefine() async { + const devToken = String.fromEnvironment('PLEX_TOKEN'); + if (devToken.isEmpty) return; + final existing = await connectionRegistry.list(); + if (existing.whereType().isNotEmpty) return; + + try { + final auth = await PlexAuthService.create(); + try { + final info = await auth.getUserInfo(devToken); + final servers = await auth.fetchServers(devToken); + final clientId = await storage.getOrCreateClientIdentifier(); + final conn = PlexAccountConnection( + id: 'plex.$clientId', + accountToken: devToken, + clientIdentifier: clientId, + accountLabel: (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex', + servers: servers, + createdAt: DateTime.now(), + lastAuthenticatedAt: DateTime.now(), + ); + await connectionRegistry.upsert(conn); + appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${conn.id}'); + } finally { + auth.dispose(); + } + } catch (e, st) { + appLogger.w('PLEX_TOKEN seed failed', error: e, stackTrace: st); + } + } + + /// If a legacy Plex token sits in [StorageService] without a corresponding + /// row in [ConnectionRegistry], wrap it as a [PlexAccountConnection] and + /// insert. Best-effort: errors are logged but never thrown. + Future migrateLegacyPlexAccount() async { + final token = storage.getPlexToken(); + if (token == null || token.isEmpty) return null; + + final existing = await connectionRegistry.list(); + final alreadyMigrated = existing + .whereType() + .where((c) => c.accountToken == token) + .firstOrNull; + if (alreadyMigrated != null) { + return alreadyMigrated; + } + + try { + final clientId = await storage.getOrCreateClientIdentifier(); + final servers = await serverRegistry.getServers(); + + String accountLabel = 'Plex'; + String accountUuid = ''; + try { + final info = await _plexUserInfoFetcher(token); + accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex'; + accountUuid = (info['uuid'] as String?)?.trim() ?? ''; + } catch (e) { + appLogger.d('Plex migration: account label lookup failed (using fallback): $e'); + } + + final conn = PlexAccountConnection( + id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}', + accountToken: token, + clientIdentifier: clientId, + accountLabel: accountLabel, + servers: servers, + createdAt: DateTime.now(), + lastAuthenticatedAt: DateTime.now(), + ); + await connectionRegistry.upsert(conn); + appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${conn.id}'); + return conn; + } catch (e, st) { + appLogger.w('Plex account migration failed', error: e, stackTrace: st); + return null; + } + } + + /// Hydrate Plex Home users for [account] and select a virtual Plex Home + /// profile. Plex users are never persisted as local Plezy profiles. + Future _preparePlexVirtualProfile(PlexAccountConnection account) async { + final copied = await _migrateLegacyPlexHomeUsersCache(account.id); + var users = copied ? _readPlexHomeUsersCache(account.id) : null; + users ??= await _fetchAndCachePlexHomeUsers(account); + final hydratedUsers = users; + if (hydratedUsers.isEmpty) return false; + + final legacyActiveUuid = storage.getCurrentUserUUID(); + PlexHomeUser selected; + if (legacyActiveUuid != null && legacyActiveUuid.isNotEmpty) { + selected = hydratedUsers.firstWhere( + (u) => u.uuid == legacyActiveUuid, + orElse: () => _preferredPlexHomeUser(hydratedUsers), + ); + if (selected.uuid != legacyActiveUuid) { + appLogger.w('Migration: legacy Plex Home UUID $legacyActiveUuid not found; using ${selected.uuid} instead'); + } + } else { + selected = _preferredPlexHomeUser(hydratedUsers); + } + + final activeProfileId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: selected.uuid); + await storage.setActiveProfileId(activeProfileId); + await storage.clearCurrentUserUUID(); + await Future.wait([storage.prefs.remove('home_users_cache'), storage.prefs.remove('home_users_cache_expiry')]); + appLogger.i('Migration: selected Plex Home profile ${selected.displayName} → $activeProfileId'); + return true; + } + + PlexHomeUser _preferredPlexHomeUser(List users) { + return users.firstWhere((u) => u.admin, orElse: () => users.first); + } + + Future _migrateLegacyPlexHomeUsersCache(String connectionId) async { + final raw = storage.prefs.getString('home_users_cache'); + if (raw == null || raw.isEmpty) return false; + + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return false; + final home = PlexHome.fromJson(decoded); + if (home.users.isEmpty) return false; + await storage.savePlexHomeUsersCache(connectionId, home.users.map((u) => u.toJson()).toList()); + appLogger.i('Migration: copied ${home.users.length} Plex Home users into cache for $connectionId'); + return true; + } catch (e, st) { + appLogger.w('Plex Home cache migration failed', error: e, stackTrace: st); + return false; + } + } + + List? _readPlexHomeUsersCache(String connectionId) { + final raw = storage.getPlexHomeUsersCacheJson(connectionId); + if (raw == null || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return null; + return decoded.whereType>().map(PlexHomeUser.fromJson).toList(); + } catch (e, st) { + appLogger.w('Migration: failed to read Plex Home cache for $connectionId', error: e, stackTrace: st); + return null; + } + } + + Future> _fetchAndCachePlexHomeUsers(PlexAccountConnection account) async { + try { + final users = await _plexHomeUserFetcher(account.accountToken); + if (users.isNotEmpty) { + await storage.savePlexHomeUsersCache(account.id, users.map((u) => u.toJson()).toList()); + appLogger.i('Migration: fetched ${users.length} Plex Home users for ${account.id}'); + } + return users; + } catch (e, st) { + appLogger.w('Migration: Plex Home fetch failed for ${account.id}', error: e, stackTrace: st); + return const []; + } + } + + Future _migrateLegacyPlexHomeUsersCacheForExistingAccount(PlexAccountConnection? account) async { + if (storage.prefs.getString('home_users_cache') == null) return; + var target = account; + if (target == null) { + final connections = await connectionRegistry.list(); + for (final conn in connections) { + if (conn is PlexAccountConnection) { + target = conn; + break; + } + } + } + if (target == null) return; + final copied = await _migrateLegacyPlexHomeUsersCache(target.id); + if (copied) { + await Future.wait([storage.prefs.remove('home_users_cache'), storage.prefs.remove('home_users_cache_expiry')]); + } + } + + /// Make sure fresh non-Plex installs always have at least one local profile. + /// Migrated Plex accounts select a virtual Plex Home profile instead. + Future _ensureOwnerProfile() async { + final existing = await profileRegistry.list(); + if (existing.isNotEmpty) return; + if (storage.getActiveProfileId() != null) return; + + final owner = Profile( + id: '$_ownerProfileIdPrefix${const Uuid().v4()}', + kind: ProfileKind.local, + displayName: 'Default', + sortOrder: 0, + createdAt: DateTime.now(), + ); + await profileRegistry.upsert(owner); + await storage.setActiveProfileId(owner.id); + appLogger.i('Migration: created placeholder Default profile (no Plex account on first launch)'); + } + + Future _firstPlexAccount(PlexAccountConnection? preferred) async { + if (preferred != null) return preferred; + final connections = await connectionRegistry.list(); + for (final conn in connections) { + if (conn is PlexAccountConnection) return conn; + } + return null; + } + + Future _recoverLegacyProfilePromotionIfNeeded(PlexAccountConnection? account) async { + if (!_hasLegacyProfileState()) return; + final target = await _firstPlexAccount(account); + if (target == null) return; + await _preparePlexVirtualProfile(target); + } +} + +Future> _fetchPlexHomeUsers(String accountToken) async { + final auth = await PlexAuthService.create(); + try { + final home = await auth.getHomeUsers(accountToken); + return home.users; + } finally { + auth.dispose(); + } +} + +Future> _fetchPlexUserInfo(String accountToken) async { + final auth = await PlexAuthService.create(); + try { + return await auth.getUserInfo(accountToken); + } finally { + auth.dispose(); + } +} diff --git a/lib/connection/connection_registry.dart b/lib/connection/connection_registry.dart new file mode 100644 index 00000000..3e0c3cb0 --- /dev/null +++ b/lib/connection/connection_registry.dart @@ -0,0 +1,188 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; + +import '../database/app_database.dart'; +import '../services/credential_vault.dart'; +import '../utils/app_logger.dart'; +import 'connection.dart'; + +/// CRUD over the persisted [Connections] table. The registry is the source +/// of truth for which connections the user has added; the runtime +/// `MultiServerManager` populates per-server clients from these records. +/// +/// Single-connection users get a default automatically — power users with +/// multiple connections can override it via [setDefault]. +class ConnectionRegistry { + ConnectionRegistry(this._db); + + final AppDatabase _db; + + /// Emits the current set of connections after every mutation. Drift's + /// `watch()` provides this for free. + Stream> watchConnections() { + return (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).watch().asyncMap( + (rows) async => (await Future.wait(rows.map(_rowToConnection))).whereType().toList(), + ); + } + + /// One-shot fetch of all stored connections. + Future> list() async { + final rows = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get(); + return (await Future.wait(rows.map(_rowToConnection))).whereType().toList(); + } + + /// Lookup a connection by id. + Future get(String id) async { + final row = await (_db.select(_db.connections)..where((t) => t.id.equals(id))).getSingleOrNull(); + if (row == null) return null; + return _rowToConnection(row); + } + + /// Returns the user's preferred connection — either the row marked + /// [Connections.isDefault], or the only row if exactly one exists, or + /// null when no connections are stored. + Future getDefault() async { + final rows = await _db.select(_db.connections).get(); + if (rows.isEmpty) return null; + final flagged = rows.firstWhereOrNull((r) => r.isDefault); + final picked = flagged ?? (rows.length == 1 ? rows.single : null); + return picked == null ? null : _rowToConnection(picked); + } + + /// Insert or replace [connection]. If this is the first stored connection + /// it is automatically marked default; re-upserting an existing row keeps + /// the row's current `isDefault` (so token/metadata refreshes don't clear + /// the default flag). + Future upsert(Connection connection) async { + final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull(); + final bool isDefault; + if (existing != null) { + isDefault = existing.isDefault; + } else { + final any = + await (_db.selectOnly(_db.connections) + ..addColumns([_db.connections.id]) + ..limit(1)) + .getSingleOrNull(); + isDefault = any == null; + } + final protectedConfig = await CredentialVault.protectConnectionConfig( + connection.kind.id, + connection.toConfigJson(), + ); + final row = ConnectionsCompanion( + id: Value(connection.id), + kind: Value(connection.kind.id), + displayName: Value(connection.displayName), + configJson: Value(jsonEncode(protectedConfig)), + isDefault: Value(isDefault), + createdAt: Value(connection.createdAt.millisecondsSinceEpoch), + lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch), + ); + await _db.into(_db.connections).insertOnConflictUpdate(row); + appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}'); + } + + /// Remove a stored connection. If the removed row was the default, the + /// oldest remaining connection (if any) becomes default. + Future remove(String id) async { + await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go(); + final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get(); + if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) { + await (_db.update( + _db.connections, + )..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true))); + } + appLogger.d('ConnectionRegistry: removed $id'); + } + + /// Set [id] as the default connection. Clears the flag on all others. + Future setDefault(String id) async { + await _db.transaction(() async { + await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false))); + await (_db.update( + _db.connections, + )..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true))); + }); + } + + /// Update only the auth-related metadata on an existing row (token, + /// `lastAuthenticatedAt`). Used by the auth flow after a successful + /// silent refresh without touching the rest of the config. + Future recordAuthSuccess(String id, DateTime at) async { + await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write( + ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)), + ); + } + + Future clear() async { + await _db.delete(_db.connections).go(); + } + + /// All Plex accounts in insertion order. Convenience over + /// `(await list()).whereType()` — cuts ~3 lines from + /// every caller that needs to filter by backend. + Future> listPlexAccounts() async { + final all = await list(); + return all.whereType().toList(); + } + + /// All Jellyfin connections in insertion order. Symmetric helper to + /// [listPlexAccounts]. + Future> listJellyfin() async { + final all = await list(); + return all.whereType().toList(); + } + + /// Lookup a [PlexAccountConnection] by id. Returns `null` if no row + /// matches OR the row exists but isn't a Plex account. + Future getPlexAccount(String id) async { + final c = await get(id); + return c is PlexAccountConnection ? c : null; + } + + /// Lookup a [JellyfinConnection] by id. Returns `null` if no row matches + /// OR the row exists but isn't a Jellyfin connection. + Future getJellyfin(String id) async { + final c = await get(id); + return c is JellyfinConnection ? c : null; + } + + Future _rowToConnection(ConnectionRow row) async { + try { + final json = jsonDecode(row.configJson) as Map; + final kind = ConnectionKind.fromId(row.kind); + final revealed = await CredentialVault.revealConnectionConfig(kind.id, json); + final createdAt = DateTime.fromMillisecondsSinceEpoch(row.createdAt); + final lastAuth = row.lastAuthenticatedAt == null + ? null + : DateTime.fromMillisecondsSinceEpoch(row.lastAuthenticatedAt!); + final connection = switch (kind) { + ConnectionKind.plex => PlexAccountConnection.fromConfigJson( + id: row.id, + json: revealed.config, + status: ConnectionStatus.unknown, + createdAt: createdAt, + lastAuthenticatedAt: lastAuth, + ), + ConnectionKind.jellyfin => JellyfinConnection.fromConfigJson( + id: row.id, + json: revealed.config, + status: ConnectionStatus.unknown, + createdAt: createdAt, + lastAuthenticatedAt: lastAuth, + ), + }; + if (revealed.migrated) { + await upsert(connection); + } + return connection; + } catch (e, st) { + appLogger.e('ConnectionRegistry: failed to decode connection ${row.id}', error: e, stackTrace: st); + return null; + } + } +} diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index e1da9ded..6623369d 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -12,11 +12,46 @@ import '../utils/global_key_utils.dart'; part 'app_database.g.dart'; -/// String values stored in [OfflineWatchProgress.actionType] (use `.name`). -enum OfflineActionType { progress, watched, unwatched } +/// Action queued in the offline watch-progress sync table. The serialized +/// form ([id]) is what gets persisted in [OfflineWatchProgress.actionType]; +/// keep these strings stable across renames so existing rows resolve. +enum OfflineActionType { + progress, + watched, + unwatched; + + /// Stable string id used for persistence. Survives an enum-name rename + /// (e.g. `progress` → `inProgress`) — `.name` would corrupt every row. + String get id => switch (this) { + OfflineActionType.progress => 'progress', + OfflineActionType.watched => 'watched', + OfflineActionType.unwatched => 'unwatched', + }; + + /// Inverse of [id]. Throws on unknown so a typo in production doesn't + /// silently fall back to the wrong action. + static OfflineActionType fromId(String id) => switch (id) { + 'progress' => OfflineActionType.progress, + 'watched' => OfflineActionType.watched, + 'unwatched' => OfflineActionType.unwatched, + _ => throw ArgumentError('Unknown OfflineActionType id: $id'), + }; +} // Simplified database with API cache for offline support -@DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress, SyncRules]) +@DriftDatabase( + tables: [ + DownloadedMedia, + DownloadOwners, + DownloadQueue, + ApiCache, + OfflineWatchProgress, + SyncRules, + Connections, + Profiles, + ProfileConnections, + ], +) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @@ -26,11 +61,20 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.e); @override - int get schemaVersion => 13; + int get schemaVersion => 14; @override MigrationStrategy get migration { return MigrationStrategy( + // Enforce ProfileConnections → Profiles/Connections cascades. + // Drift turns FKs *off* during migrations, so the per-connection + // pragma we set in `_openConnection` is wiped on first open. This + // hook runs after migrations and re-enables it for subsequent + // queries — also applies to in-memory test databases that don't go + // through `_openConnection`. + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON'); + }, onCreate: (Migrator m) async { await m.createAll(); }, @@ -41,19 +85,17 @@ class AppDatabase extends _$AppDatabase { } if (from < 8) { appLogger.i('Adding bgTaskId column to DownloadedMedia (v8 migration)'); - try { - await m.addColumn(downloadedMedia, downloadedMedia.bgTaskId); - } catch (e) { - appLogger.w('bgTaskId column may already exist: $e'); - } + await _ignoreAlreadyExists( + 'DownloadedMedia.bgTaskId column', + () => m.addColumn(downloadedMedia, downloadedMedia.bgTaskId), + ); } if (from < 9) { appLogger.i('Adding mediaIndex column to DownloadedMedia (v9 migration)'); - try { - await m.addColumn(downloadedMedia, downloadedMedia.mediaIndex); - } catch (e) { - appLogger.w('mediaIndex column may already exist: $e'); - } + await _ignoreAlreadyExists( + 'DownloadedMedia.mediaIndex column', + () => m.addColumn(downloadedMedia, downloadedMedia.mediaIndex), + ); } if (from < 10) { appLogger.i('Adding SyncRules table (v10 migration)'); @@ -61,19 +103,14 @@ class AppDatabase extends _$AppDatabase { } if (from < 11) { appLogger.i('Adding enabled column to SyncRules (v11 migration)'); - try { - await m.addColumn(syncRules, syncRules.enabled); - } catch (e) { - appLogger.w('enabled column may already exist: $e'); - } + await _ignoreAlreadyExists('SyncRules.enabled column', () => m.addColumn(syncRules, syncRules.enabled)); } if (from < 12) { appLogger.i('Adding downloadFilter column to SyncRules (v12 migration)'); - try { - await m.addColumn(syncRules, syncRules.downloadFilter); - } catch (e) { - appLogger.w('downloadFilter column may already exist: $e'); - } + await _ignoreAlreadyExists( + 'SyncRules.downloadFilter column', + () => m.addColumn(syncRules, syncRules.downloadFilter), + ); } if (from < 13) { appLogger.i('Adding indexes on DownloadedMedia hot-queried columns (v13 migration)'); @@ -84,38 +121,158 @@ class AppDatabase extends _$AppDatabase { 'idx_downloaded_media_grandparent': idxDownloadedMediaGrandparent, }; for (final entry in indexes.entries) { - try { - await m.create(entry.value); - } catch (e) { - appLogger.w('Index ${entry.key} may already exist: $e'); - } + await _ignoreAlreadyExists('Index ${entry.key}', () => m.create(entry.value)); } } + if (from < 14) { + appLogger.i( + 'Adding Connections, Profiles, ProfileConnections, DownloadOwners + scope/profile columns (v14 migration)', + ); + + await m.createTable(connections); + await m.create(idxConnectionsKind); + + await m.createTable(profiles); + await m.create(idxProfilesKind); + + await m.createTable(profileConnections); + await m.create(idxProfileConnectionsConnectionId); + await m.create(idxProfileConnectionsProfileId); + + await _ignoreAlreadyExists('DownloadOwners table', () => m.createTable(downloadOwners)); + await _ignoreAlreadyExists('Index idx_download_owners_profile', () => m.create(idxDownloadOwnersProfile)); + await _ignoreAlreadyExists( + 'Index idx_download_owners_global_key', + () => m.create(idxDownloadOwnersGlobalKey), + ); + + await _ignoreAlreadyExists( + 'DownloadedMedia.clientScopeId column', + () => m.addColumn(downloadedMedia, downloadedMedia.clientScopeId), + ); + await _ignoreAlreadyExists( + 'OfflineWatchProgress.clientScopeId column', + () => m.addColumn(offlineWatchProgress, offlineWatchProgress.clientScopeId), + ); + await _ignoreAlreadyExists('SyncRules.profileId column', () => m.addColumn(syncRules, syncRules.profileId)); + await _ignoreAlreadyExists( + 'OfflineWatchProgress.profileId column', + () => m.addColumn(offlineWatchProgress, offlineWatchProgress.profileId), + ); + + await customStatement(''' + UPDATE downloaded_media + SET client_scope_id = ( + SELECT id FROM connections + WHERE kind = 'jellyfin' + AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/' + LIMIT 1 + ) + WHERE client_scope_id IS NULL + AND EXISTS ( + SELECT 1 FROM connections + WHERE kind = 'jellyfin' + AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/' + ) + '''); + await customStatement(''' + UPDATE offline_watch_progress + SET client_scope_id = ( + SELECT id FROM connections + WHERE kind = 'jellyfin' + AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/' + LIMIT 1 + ) + WHERE client_scope_id IS NULL + AND EXISTS ( + SELECT 1 FROM connections + WHERE kind = 'jellyfin' + AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/' + ) + '''); + + await m.create(idxOfflineWatchProgressServer); + await _ignoreAlreadyExists('Index idx_sync_rules_profile', () => m.create(idxSyncRulesProfile)); + await _ignoreAlreadyExists( + 'Index idx_offline_watch_progress_profile', + () => m.create(idxOfflineWatchProgressProfile), + ); + } }, ); } + Future _ignoreAlreadyExists(String label, Future Function() operation) async { + try { + await operation(); + } catch (e) { + final message = e.toString().toLowerCase(); + if (message.contains('already exists') || message.contains('duplicate column name')) { + appLogger.w('$label already exists during migration: $e'); + return; + } + rethrow; + } + } + // ============================================================ // Offline Watch Progress Operations // ============================================================ + Expression _clientScopePredicate(GeneratedColumn column, String? clientScopeId) { + return clientScopeId == null ? column.isNull() : column.equals(clientScopeId); + } + + Expression _nullableTextPredicate(GeneratedColumn column, String? value) { + return value == null ? column.isNull() : column.equals(value); + } + /// Get all pending offline watch actions for sync - Future> getPendingWatchActions() { - return (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get(); + Future> getPendingWatchActions({String? profileId}) { + final query = select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)]); + if (profileId != null) { + query.where((t) => t.profileId.equals(profileId)); + } + return query.get(); + } + + /// Claim pre-v18 offline watch actions for [profileId]. Those rows predate + /// profile ownership and have `NULL profile_id`; the first active profile + /// inherits them so already-watched offline progress is not stranded. + Future adoptLegacyOfflineWatchActionsForProfile(String profileId) async { + if (profileId.isEmpty) return; + await (update( + offlineWatchProgress, + )..where((t) => t.profileId.isNull())).write(OfflineWatchProgressCompanion(profileId: Value(profileId))); } /// Get pending watch actions for a specific server - Future> getPendingWatchActionsForServer(String serverId) { + Future> getPendingWatchActionsForServer(String serverId, {String? profileId}) { return (select(offlineWatchProgress) - ..where((t) => t.serverId.equals(serverId)) + ..where( + (t) => + t.serverId.equals(serverId) & + (profileId == null ? const Constant(true) : t.profileId.equals(profileId)), + ) ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) .get(); } /// Get the latest action for a specific item - Future getLatestWatchAction(String globalKey) { + Future getLatestWatchAction( + String globalKey, { + String? profileId, + bool filterProfile = false, + String? clientScopeId, + bool filterClientScope = false, + }) { return (select(offlineWatchProgress) - ..where((t) => t.globalKey.equals(globalKey)) + ..where( + (t) => + t.globalKey.equals(globalKey) & + (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) & + (filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)), + ) ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]) ..limit(1)) .getSingleOrNull(); @@ -125,19 +282,32 @@ class AppDatabase extends _$AppDatabase { /// /// Returns a map of globalKey -> latest action for each key. /// Keys with no actions will not be present in the returned map. - Future> getLatestWatchActionsForKeys(Set globalKeys) async { + Future> getLatestWatchActionsForKeys( + Set globalKeys, { + String? profileId, + bool filterProfile = false, + Map? clientScopeIdsByGlobalKey, + }) async { if (globalKeys.isEmpty) return {}; // Query all actions for the given keys final allActions = await (select(offlineWatchProgress) - ..where((t) => t.globalKey.isIn(globalKeys)) + ..where( + (t) => + t.globalKey.isIn(globalKeys) & + (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)), + ) ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) .get(); // Group by globalKey and take the latest (first due to ordering) final result = {}; for (final action in allActions) { + if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) { + final expectedScope = clientScopeIdsByGlobalKey[action.globalKey]; + if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue; + } // Only keep the first (latest) action for each key result.putIfAbsent(action.globalKey, () => action); } @@ -145,9 +315,17 @@ class AppDatabase extends _$AppDatabase { return result; } - /// Insert or update a progress action (merges with existing) + bool _clientScopeValuesMatch(String? actual, String? expected) { + final normalizedActual = actual == null || actual.isEmpty ? null : actual; + final normalizedExpected = expected == null || expected.isEmpty ? null : expected; + return normalizedActual == normalizedExpected; + } + + /// Insert or update a progress action (merges with existing). Future upsertProgressAction({ + String? profileId, required String serverId, + String? clientScopeId, required String ratingKey, required int viewOffset, required int duration, @@ -159,7 +337,13 @@ class AppDatabase extends _$AppDatabase { // Check for existing progress entry final existing = await (select(offlineWatchProgress) - ..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals(OfflineActionType.progress.name)) + ..where( + (t) => + t.globalKey.equals(globalKey) & + _nullableTextPredicate(t.profileId, profileId) & + _clientScopePredicate(t.clientScopeId, clientScopeId) & + t.actionType.equals(OfflineActionType.progress.id), + ) ..limit(1)) .getSingleOrNull(); @@ -170,6 +354,8 @@ class AppDatabase extends _$AppDatabase { viewOffset: Value(viewOffset), duration: Value(duration), shouldMarkWatched: Value(shouldMarkWatched), + profileId: Value(profileId), + clientScopeId: Value(clientScopeId), updatedAt: Value(now), ), ); @@ -178,9 +364,11 @@ class AppDatabase extends _$AppDatabase { await into(offlineWatchProgress).insert( OfflineWatchProgressCompanion.insert( serverId: serverId, + profileId: Value(profileId), + clientScopeId: Value(clientScopeId), ratingKey: ratingKey, globalKey: globalKey, - actionType: OfflineActionType.progress.name, + actionType: OfflineActionType.progress.id, viewOffset: Value(viewOffset), duration: Value(duration), shouldMarkWatched: Value(shouldMarkWatched), @@ -191,10 +379,12 @@ class AppDatabase extends _$AppDatabase { } } - /// Insert a manual watch action (watched or unwatched) - /// Removes conflicting actions for the same item + /// Insert a manual watch action (watched or unwatched). + /// Removes conflicting actions for the same item. Future insertWatchAction({ + String? profileId, required String serverId, + String? clientScopeId, required String ratingKey, required String actionType, // 'watched' or 'unwatched' }) async { @@ -202,12 +392,20 @@ class AppDatabase extends _$AppDatabase { final now = DateTime.now().millisecondsSinceEpoch; // Remove conflicting actions (opposite action type and progress) - await (delete(offlineWatchProgress)..where((t) => t.globalKey.equals(globalKey))).go(); + await (delete(offlineWatchProgress)..where( + (t) => + t.globalKey.equals(globalKey) & + _nullableTextPredicate(t.profileId, profileId) & + _clientScopePredicate(t.clientScopeId, clientScopeId), + )) + .go(); // Insert the new action await into(offlineWatchProgress).insert( OfflineWatchProgressCompanion.insert( serverId: serverId, + profileId: Value(profileId), + clientScopeId: Value(clientScopeId), ratingKey: ratingKey, globalKey: globalKey, actionType: actionType, @@ -234,10 +432,12 @@ class AppDatabase extends _$AppDatabase { } /// Get count of pending sync items - Future getPendingSyncCount() async { - final count = await (selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()])) - .map((row) => row.read(offlineWatchProgress.id.count())) - .getSingle(); + Future getPendingSyncCount({String? profileId}) async { + final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]); + if (profileId != null) { + query.where(offlineWatchProgress.profileId.equals(profileId)); + } + final count = await query.map((row) => row.read(offlineWatchProgress.id.count())).getSingle(); return count ?? 0; } @@ -250,8 +450,12 @@ class AppDatabase extends _$AppDatabase { // Sync Rules Operations // ============================================================ - Future> getSyncRules() { - return select(syncRules).get(); + Future> getSyncRules({String? profileId}) { + final query = select(syncRules); + if (profileId != null) { + query.where((t) => t.profileId.equals(profileId)); + } + return query.get(); } Future getSyncRule(String globalKey) { @@ -259,6 +463,7 @@ class AppDatabase extends _$AppDatabase { } Future insertSyncRule({ + String profileId = '', required String serverId, required String ratingKey, required String globalKey, @@ -267,9 +472,15 @@ class AppDatabase extends _$AppDatabase { int mediaIndex = 0, String downloadFilter = 'unwatched', }) async { - await into(syncRules).insertOnConflictUpdate( + // [insertOnConflictUpdate] defaults the conflict target to the primary + // key (`id`), which is auto-incremented — the conflict never triggers + // and the row's UNIQUE [globalKey] constraint blows up instead. Drive + // the upsert off the public [globalKey] so re-creating a rule for the same + // shared target updates the existing row. + await into(syncRules).insert( SyncRulesCompanion.insert( serverId: serverId, + profileId: Value(profileId), ratingKey: ratingKey, globalKey: globalKey, targetType: targetType, @@ -278,9 +489,39 @@ class AppDatabase extends _$AppDatabase { mediaIndex: Value(mediaIndex), downloadFilter: Value(downloadFilter), ), + onConflict: DoUpdate( + (_) => SyncRulesCompanion( + serverId: Value(serverId), + profileId: Value(profileId), + ratingKey: Value(ratingKey), + targetType: Value(targetType), + episodeCount: Value(episodeCount), + mediaIndex: Value(mediaIndex), + downloadFilter: Value(downloadFilter), + ), + target: [syncRules.globalKey], + ), ); } + /// Claim pre-v16 public sync rules for [profileId]. Rules created before + /// profile ownership have an empty profile id and a public global key. + Future adoptLegacySyncRulesForProfile(String profileId) async { + if (profileId.isEmpty) return; + final legacyRules = await (select(syncRules)..where((t) => t.profileId.equals(''))).get(); + for (final rule in legacyRules) { + final scopedKey = buildProfileScopedGlobalKey(profileId, rule.serverId, rule.ratingKey); + final duplicate = await getSyncRule(scopedKey); + if (duplicate != null) { + await (delete(syncRules)..where((t) => t.id.equals(rule.id))).go(); + continue; + } + await (update(syncRules)..where((t) => t.id.equals(rule.id))).write( + SyncRulesCompanion(profileId: Value(profileId), globalKey: Value(scopedKey)), + ); + } + } + Future updateSyncRuleCount(String globalKey, int episodeCount) async { await (update( syncRules, @@ -346,6 +587,9 @@ LazyDatabase _openConnection() { setup: (db) { db.execute('PRAGMA journal_mode=WAL'); db.execute('PRAGMA synchronous=NORMAL'); + // Enforce ProfileConnections → Profiles/Connections cascades. + // SQLite requires this on every connection — it's not persisted. + db.execute('PRAGMA foreign_keys = ON'); }, ); }); diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 555aca00..587bddc0 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -3,7 +3,8 @@ part of 'app_database.dart'; // ignore_for_file: type=lint -class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMediaTable, DownloadedMediaItem> { +class $DownloadedMediaTable extends DownloadedMedia + with TableInfo<$DownloadedMediaTable, DownloadedMediaItem> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -17,9 +18,13 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe hasAutoIncrement: true, type: DriftSqlType.int, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _serverIdMeta = const VerificationMeta( + 'serverId', ); - static const VerificationMeta _serverIdMeta = const VerificationMeta('serverId'); @override late final GeneratedColumn serverId = GeneratedColumn( 'server_id', @@ -28,7 +33,20 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _ratingKeyMeta = const VerificationMeta('ratingKey'); + static const VerificationMeta _clientScopeIdMeta = const VerificationMeta( + 'clientScopeId', + ); + @override + late final GeneratedColumn clientScopeId = GeneratedColumn( + 'client_scope_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _ratingKeyMeta = const VerificationMeta( + 'ratingKey', + ); @override late final GeneratedColumn ratingKey = GeneratedColumn( 'rating_key', @@ -37,7 +55,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _globalKeyMeta = const VerificationMeta('globalKey'); + static const VerificationMeta _globalKeyMeta = const VerificationMeta( + 'globalKey', + ); @override late final GeneratedColumn globalKey = GeneratedColumn( 'global_key', @@ -56,7 +76,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _parentRatingKeyMeta = const VerificationMeta('parentRatingKey'); + static const VerificationMeta _parentRatingKeyMeta = const VerificationMeta( + 'parentRatingKey', + ); @override late final GeneratedColumn parentRatingKey = GeneratedColumn( 'parent_rating_key', @@ -65,15 +87,17 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _grandparentRatingKeyMeta = const VerificationMeta('grandparentRatingKey'); + static const VerificationMeta _grandparentRatingKeyMeta = + const VerificationMeta('grandparentRatingKey'); @override - late final GeneratedColumn grandparentRatingKey = GeneratedColumn( - 'grandparent_rating_key', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); + late final GeneratedColumn grandparentRatingKey = + GeneratedColumn( + 'grandparent_rating_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _statusMeta = const VerificationMeta('status'); @override late final GeneratedColumn status = GeneratedColumn( @@ -83,7 +107,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _progressMeta = const VerificationMeta('progress'); + static const VerificationMeta _progressMeta = const VerificationMeta( + 'progress', + ); @override late final GeneratedColumn progress = GeneratedColumn( 'progress', @@ -93,7 +119,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe requiredDuringInsert: false, defaultValue: const Constant(0), ); - static const VerificationMeta _totalBytesMeta = const VerificationMeta('totalBytes'); + static const VerificationMeta _totalBytesMeta = const VerificationMeta( + 'totalBytes', + ); @override late final GeneratedColumn totalBytes = GeneratedColumn( 'total_bytes', @@ -102,7 +130,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.int, requiredDuringInsert: false, ); - static const VerificationMeta _downloadedBytesMeta = const VerificationMeta('downloadedBytes'); + static const VerificationMeta _downloadedBytesMeta = const VerificationMeta( + 'downloadedBytes', + ); @override late final GeneratedColumn downloadedBytes = GeneratedColumn( 'downloaded_bytes', @@ -112,7 +142,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe requiredDuringInsert: false, defaultValue: const Constant(0), ); - static const VerificationMeta _videoFilePathMeta = const VerificationMeta('videoFilePath'); + static const VerificationMeta _videoFilePathMeta = const VerificationMeta( + 'videoFilePath', + ); @override late final GeneratedColumn videoFilePath = GeneratedColumn( 'video_file_path', @@ -121,7 +153,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _thumbPathMeta = const VerificationMeta('thumbPath'); + static const VerificationMeta _thumbPathMeta = const VerificationMeta( + 'thumbPath', + ); @override late final GeneratedColumn thumbPath = GeneratedColumn( 'thumb_path', @@ -130,7 +164,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _downloadedAtMeta = const VerificationMeta('downloadedAt'); + static const VerificationMeta _downloadedAtMeta = const VerificationMeta( + 'downloadedAt', + ); @override late final GeneratedColumn downloadedAt = GeneratedColumn( 'downloaded_at', @@ -139,7 +175,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.int, requiredDuringInsert: false, ); - static const VerificationMeta _errorMessageMeta = const VerificationMeta('errorMessage'); + static const VerificationMeta _errorMessageMeta = const VerificationMeta( + 'errorMessage', + ); @override late final GeneratedColumn errorMessage = GeneratedColumn( 'error_message', @@ -148,7 +186,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _retryCountMeta = const VerificationMeta('retryCount'); + static const VerificationMeta _retryCountMeta = const VerificationMeta( + 'retryCount', + ); @override late final GeneratedColumn retryCount = GeneratedColumn( 'retry_count', @@ -158,7 +198,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe requiredDuringInsert: false, defaultValue: const Constant(0), ); - static const VerificationMeta _bgTaskIdMeta = const VerificationMeta('bgTaskId'); + static const VerificationMeta _bgTaskIdMeta = const VerificationMeta( + 'bgTaskId', + ); @override late final GeneratedColumn bgTaskId = GeneratedColumn( 'bg_task_id', @@ -167,7 +209,9 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _mediaIndexMeta = const VerificationMeta('mediaIndex'); + static const VerificationMeta _mediaIndexMeta = const VerificationMeta( + 'mediaIndex', + ); @override late final GeneratedColumn mediaIndex = GeneratedColumn( 'media_index', @@ -181,6 +225,7 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe List get $columns => [ id, serverId, + clientScopeId, ratingKey, globalKey, type, @@ -204,84 +249,153 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe String get actualTableName => $name; static const String $name = 'downloaded_media'; @override - VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } if (data.containsKey('server_id')) { - context.handle(_serverIdMeta, serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta)); + context.handle( + _serverIdMeta, + serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta), + ); } else if (isInserting) { context.missing(_serverIdMeta); } + if (data.containsKey('client_scope_id')) { + context.handle( + _clientScopeIdMeta, + clientScopeId.isAcceptableOrUnknown( + data['client_scope_id']!, + _clientScopeIdMeta, + ), + ); + } if (data.containsKey('rating_key')) { - context.handle(_ratingKeyMeta, ratingKey.isAcceptableOrUnknown(data['rating_key']!, _ratingKeyMeta)); + context.handle( + _ratingKeyMeta, + ratingKey.isAcceptableOrUnknown(data['rating_key']!, _ratingKeyMeta), + ); } else if (isInserting) { context.missing(_ratingKeyMeta); } if (data.containsKey('global_key')) { - context.handle(_globalKeyMeta, globalKey.isAcceptableOrUnknown(data['global_key']!, _globalKeyMeta)); + context.handle( + _globalKeyMeta, + globalKey.isAcceptableOrUnknown(data['global_key']!, _globalKeyMeta), + ); } else if (isInserting) { context.missing(_globalKeyMeta); } if (data.containsKey('type')) { - context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + context.handle( + _typeMeta, + type.isAcceptableOrUnknown(data['type']!, _typeMeta), + ); } else if (isInserting) { context.missing(_typeMeta); } if (data.containsKey('parent_rating_key')) { context.handle( _parentRatingKeyMeta, - parentRatingKey.isAcceptableOrUnknown(data['parent_rating_key']!, _parentRatingKeyMeta), + parentRatingKey.isAcceptableOrUnknown( + data['parent_rating_key']!, + _parentRatingKeyMeta, + ), ); } if (data.containsKey('grandparent_rating_key')) { context.handle( _grandparentRatingKeyMeta, - grandparentRatingKey.isAcceptableOrUnknown(data['grandparent_rating_key']!, _grandparentRatingKeyMeta), + grandparentRatingKey.isAcceptableOrUnknown( + data['grandparent_rating_key']!, + _grandparentRatingKeyMeta, + ), ); } if (data.containsKey('status')) { - context.handle(_statusMeta, status.isAcceptableOrUnknown(data['status']!, _statusMeta)); + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); } else if (isInserting) { context.missing(_statusMeta); } if (data.containsKey('progress')) { - context.handle(_progressMeta, progress.isAcceptableOrUnknown(data['progress']!, _progressMeta)); + context.handle( + _progressMeta, + progress.isAcceptableOrUnknown(data['progress']!, _progressMeta), + ); } if (data.containsKey('total_bytes')) { - context.handle(_totalBytesMeta, totalBytes.isAcceptableOrUnknown(data['total_bytes']!, _totalBytesMeta)); + context.handle( + _totalBytesMeta, + totalBytes.isAcceptableOrUnknown(data['total_bytes']!, _totalBytesMeta), + ); } if (data.containsKey('downloaded_bytes')) { context.handle( _downloadedBytesMeta, - downloadedBytes.isAcceptableOrUnknown(data['downloaded_bytes']!, _downloadedBytesMeta), + downloadedBytes.isAcceptableOrUnknown( + data['downloaded_bytes']!, + _downloadedBytesMeta, + ), ); } if (data.containsKey('video_file_path')) { context.handle( _videoFilePathMeta, - videoFilePath.isAcceptableOrUnknown(data['video_file_path']!, _videoFilePathMeta), + videoFilePath.isAcceptableOrUnknown( + data['video_file_path']!, + _videoFilePathMeta, + ), ); } if (data.containsKey('thumb_path')) { - context.handle(_thumbPathMeta, thumbPath.isAcceptableOrUnknown(data['thumb_path']!, _thumbPathMeta)); + context.handle( + _thumbPathMeta, + thumbPath.isAcceptableOrUnknown(data['thumb_path']!, _thumbPathMeta), + ); } if (data.containsKey('downloaded_at')) { - context.handle(_downloadedAtMeta, downloadedAt.isAcceptableOrUnknown(data['downloaded_at']!, _downloadedAtMeta)); + context.handle( + _downloadedAtMeta, + downloadedAt.isAcceptableOrUnknown( + data['downloaded_at']!, + _downloadedAtMeta, + ), + ); } if (data.containsKey('error_message')) { - context.handle(_errorMessageMeta, errorMessage.isAcceptableOrUnknown(data['error_message']!, _errorMessageMeta)); + context.handle( + _errorMessageMeta, + errorMessage.isAcceptableOrUnknown( + data['error_message']!, + _errorMessageMeta, + ), + ); } if (data.containsKey('retry_count')) { - context.handle(_retryCountMeta, retryCount.isAcceptableOrUnknown(data['retry_count']!, _retryCountMeta)); + context.handle( + _retryCountMeta, + retryCount.isAcceptableOrUnknown(data['retry_count']!, _retryCountMeta), + ); } if (data.containsKey('bg_task_id')) { - context.handle(_bgTaskIdMeta, bgTaskId.isAcceptableOrUnknown(data['bg_task_id']!, _bgTaskIdMeta)); + context.handle( + _bgTaskIdMeta, + bgTaskId.isAcceptableOrUnknown(data['bg_task_id']!, _bgTaskIdMeta), + ); } if (data.containsKey('media_index')) { - context.handle(_mediaIndexMeta, mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta)); + context.handle( + _mediaIndexMeta, + mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta), + ); } return context; } @@ -292,11 +406,30 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe DownloadedMediaItem map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return DownloadedMediaItem( - id: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}id'])!, - serverId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}server_id'])!, - ratingKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}rating_key'])!, - globalKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}global_key'])!, - type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + serverId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_id'], + )!, + clientScopeId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}client_scope_id'], + ), + ratingKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}rating_key'], + )!, + globalKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}global_key'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}type'], + )!, parentRatingKey: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}parent_rating_key'], @@ -305,17 +438,50 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe DriftSqlType.string, data['${effectivePrefix}grandparent_rating_key'], ), - status: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}status'])!, - progress: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}progress'])!, - totalBytes: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}total_bytes']), - downloadedBytes: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}downloaded_bytes'])!, - videoFilePath: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}video_file_path']), - thumbPath: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}thumb_path']), - downloadedAt: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}downloaded_at']), - errorMessage: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}error_message']), - retryCount: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}retry_count'])!, - bgTaskId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}bg_task_id']), - mediaIndex: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}media_index'])!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}status'], + )!, + progress: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}progress'], + )!, + totalBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}total_bytes'], + ), + downloadedBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}downloaded_bytes'], + )!, + videoFilePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}video_file_path'], + ), + thumbPath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumb_path'], + ), + downloadedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}downloaded_at'], + ), + errorMessage: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}error_message'], + ), + retryCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}retry_count'], + )!, + bgTaskId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}bg_task_id'], + ), + mediaIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}media_index'], + )!, ); } @@ -325,9 +491,11 @@ class $DownloadedMediaTable extends DownloadedMedia with TableInfo<$DownloadedMe } } -class DownloadedMediaItem extends DataClass implements Insertable { +class DownloadedMediaItem extends DataClass + implements Insertable { final int id; final String serverId; + final String? clientScopeId; final String ratingKey; final String globalKey; final String type; @@ -347,6 +515,7 @@ class DownloadedMediaItem extends DataClass implements Insertable{}; map['id'] = Variable(id); map['server_id'] = Variable(serverId); + if (!nullToAbsent || clientScopeId != null) { + map['client_scope_id'] = Variable(clientScopeId); + } map['rating_key'] = Variable(ratingKey); map['global_key'] = Variable(globalKey); map['type'] = Variable(type); @@ -408,37 +580,60 @@ class DownloadedMediaItem extends DataClass implements Insertable json, {ValueSerializer? serializer}) { + factory DownloadedMediaItem.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return DownloadedMediaItem( id: serializer.fromJson(json['id']), serverId: serializer.fromJson(json['serverId']), + clientScopeId: serializer.fromJson(json['clientScopeId']), ratingKey: serializer.fromJson(json['ratingKey']), globalKey: serializer.fromJson(json['globalKey']), type: serializer.fromJson(json['type']), parentRatingKey: serializer.fromJson(json['parentRatingKey']), - grandparentRatingKey: serializer.fromJson(json['grandparentRatingKey']), + grandparentRatingKey: serializer.fromJson( + json['grandparentRatingKey'], + ), status: serializer.fromJson(json['status']), progress: serializer.fromJson(json['progress']), totalBytes: serializer.fromJson(json['totalBytes']), @@ -458,6 +653,7 @@ class DownloadedMediaItem extends DataClass implements Insertable{ 'id': serializer.toJson(id), 'serverId': serializer.toJson(serverId), + 'clientScopeId': serializer.toJson(clientScopeId), 'ratingKey': serializer.toJson(ratingKey), 'globalKey': serializer.toJson(globalKey), 'type': serializer.toJson(type), @@ -480,6 +676,7 @@ class DownloadedMediaItem extends DataClass implements Insertable clientScopeId = const Value.absent(), String? ratingKey, String? globalKey, String? type, @@ -499,16 +696,25 @@ class DownloadedMediaItem extends DataClass implements Insertable DownloadedMediaItem( id: id ?? this.id, serverId: serverId ?? this.serverId, + clientScopeId: clientScopeId.present + ? clientScopeId.value + : this.clientScopeId, ratingKey: ratingKey ?? this.ratingKey, globalKey: globalKey ?? this.globalKey, type: type ?? this.type, - parentRatingKey: parentRatingKey.present ? parentRatingKey.value : this.parentRatingKey, - grandparentRatingKey: grandparentRatingKey.present ? grandparentRatingKey.value : this.grandparentRatingKey, + parentRatingKey: parentRatingKey.present + ? parentRatingKey.value + : this.parentRatingKey, + grandparentRatingKey: grandparentRatingKey.present + ? grandparentRatingKey.value + : this.grandparentRatingKey, status: status ?? this.status, progress: progress ?? this.progress, totalBytes: totalBytes.present ? totalBytes.value : this.totalBytes, downloadedBytes: downloadedBytes ?? this.downloadedBytes, - videoFilePath: videoFilePath.present ? videoFilePath.value : this.videoFilePath, + videoFilePath: videoFilePath.present + ? videoFilePath.value + : this.videoFilePath, thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath, downloadedAt: downloadedAt.present ? downloadedAt.value : this.downloadedAt, errorMessage: errorMessage.present ? errorMessage.value : this.errorMessage, @@ -520,24 +726,43 @@ class DownloadedMediaItem extends DataClass implements Insertable Object.hash( id, serverId, + clientScopeId, ratingKey, globalKey, type, @@ -593,6 +820,7 @@ class DownloadedMediaItem extends DataClass implements Insertable { final Value id; final Value serverId; + final Value clientScopeId; final Value ratingKey; final Value globalKey; final Value type; @@ -633,6 +862,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { const DownloadedMediaCompanion({ this.id = const Value.absent(), this.serverId = const Value.absent(), + this.clientScopeId = const Value.absent(), this.ratingKey = const Value.absent(), this.globalKey = const Value.absent(), this.type = const Value.absent(), @@ -653,6 +883,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { DownloadedMediaCompanion.insert({ this.id = const Value.absent(), required String serverId, + this.clientScopeId = const Value.absent(), required String ratingKey, required String globalKey, required String type, @@ -677,6 +908,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { static Insertable custom({ Expression? id, Expression? serverId, + Expression? clientScopeId, Expression? ratingKey, Expression? globalKey, Expression? type, @@ -697,11 +929,13 @@ class DownloadedMediaCompanion extends UpdateCompanion { return RawValuesInsertable({ if (id != null) 'id': id, if (serverId != null) 'server_id': serverId, + if (clientScopeId != null) 'client_scope_id': clientScopeId, if (ratingKey != null) 'rating_key': ratingKey, if (globalKey != null) 'global_key': globalKey, if (type != null) 'type': type, if (parentRatingKey != null) 'parent_rating_key': parentRatingKey, - if (grandparentRatingKey != null) 'grandparent_rating_key': grandparentRatingKey, + if (grandparentRatingKey != null) + 'grandparent_rating_key': grandparentRatingKey, if (status != null) 'status': status, if (progress != null) 'progress': progress, if (totalBytes != null) 'total_bytes': totalBytes, @@ -719,6 +953,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { DownloadedMediaCompanion copyWith({ Value? id, Value? serverId, + Value? clientScopeId, Value? ratingKey, Value? globalKey, Value? type, @@ -739,6 +974,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { return DownloadedMediaCompanion( id: id ?? this.id, serverId: serverId ?? this.serverId, + clientScopeId: clientScopeId ?? this.clientScopeId, ratingKey: ratingKey ?? this.ratingKey, globalKey: globalKey ?? this.globalKey, type: type ?? this.type, @@ -767,6 +1003,9 @@ class DownloadedMediaCompanion extends UpdateCompanion { if (serverId.present) { map['server_id'] = Variable(serverId.value); } + if (clientScopeId.present) { + map['client_scope_id'] = Variable(clientScopeId.value); + } if (ratingKey.present) { map['rating_key'] = Variable(ratingKey.value); } @@ -780,7 +1019,9 @@ class DownloadedMediaCompanion extends UpdateCompanion { map['parent_rating_key'] = Variable(parentRatingKey.value); } if (grandparentRatingKey.present) { - map['grandparent_rating_key'] = Variable(grandparentRatingKey.value); + map['grandparent_rating_key'] = Variable( + grandparentRatingKey.value, + ); } if (status.present) { map['status'] = Variable(status.value); @@ -823,6 +1064,7 @@ class DownloadedMediaCompanion extends UpdateCompanion { return (StringBuffer('DownloadedMediaCompanion(') ..write('id: $id, ') ..write('serverId: $serverId, ') + ..write('clientScopeId: $clientScopeId, ') ..write('ratingKey: $ratingKey, ') ..write('globalKey: $globalKey, ') ..write('type: $type, ') @@ -844,7 +1086,278 @@ class DownloadedMediaCompanion extends UpdateCompanion { } } -class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTable, DownloadQueueItem> { +class $DownloadOwnersTable extends DownloadOwners + with TableInfo<$DownloadOwnersTable, DownloadOwnerItem> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $DownloadOwnersTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _profileIdMeta = const VerificationMeta( + 'profileId', + ); + @override + late final GeneratedColumn profileId = GeneratedColumn( + 'profile_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _globalKeyMeta = const VerificationMeta( + 'globalKey', + ); + @override + late final GeneratedColumn globalKey = GeneratedColumn( + 'global_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [profileId, globalKey, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'download_owners'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('profile_id')) { + context.handle( + _profileIdMeta, + profileId.isAcceptableOrUnknown(data['profile_id']!, _profileIdMeta), + ); + } else if (isInserting) { + context.missing(_profileIdMeta); + } + if (data.containsKey('global_key')) { + context.handle( + _globalKeyMeta, + globalKey.isAcceptableOrUnknown(data['global_key']!, _globalKeyMeta), + ); + } else if (isInserting) { + context.missing(_globalKeyMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {profileId, globalKey}; + @override + DownloadOwnerItem map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return DownloadOwnerItem( + profileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}profile_id'], + )!, + globalKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}global_key'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $DownloadOwnersTable createAlias(String alias) { + return $DownloadOwnersTable(attachedDatabase, alias); + } +} + +class DownloadOwnerItem extends DataClass + implements Insertable { + final String profileId; + final String globalKey; + final int createdAt; + const DownloadOwnerItem({ + required this.profileId, + required this.globalKey, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['profile_id'] = Variable(profileId); + map['global_key'] = Variable(globalKey); + map['created_at'] = Variable(createdAt); + return map; + } + + DownloadOwnersCompanion toCompanion(bool nullToAbsent) { + return DownloadOwnersCompanion( + profileId: Value(profileId), + globalKey: Value(globalKey), + createdAt: Value(createdAt), + ); + } + + factory DownloadOwnerItem.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return DownloadOwnerItem( + profileId: serializer.fromJson(json['profileId']), + globalKey: serializer.fromJson(json['globalKey']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'profileId': serializer.toJson(profileId), + 'globalKey': serializer.toJson(globalKey), + 'createdAt': serializer.toJson(createdAt), + }; + } + + DownloadOwnerItem copyWith({ + String? profileId, + String? globalKey, + int? createdAt, + }) => DownloadOwnerItem( + profileId: profileId ?? this.profileId, + globalKey: globalKey ?? this.globalKey, + createdAt: createdAt ?? this.createdAt, + ); + DownloadOwnerItem copyWithCompanion(DownloadOwnersCompanion data) { + return DownloadOwnerItem( + profileId: data.profileId.present ? data.profileId.value : this.profileId, + globalKey: data.globalKey.present ? data.globalKey.value : this.globalKey, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('DownloadOwnerItem(') + ..write('profileId: $profileId, ') + ..write('globalKey: $globalKey, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(profileId, globalKey, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DownloadOwnerItem && + other.profileId == this.profileId && + other.globalKey == this.globalKey && + other.createdAt == this.createdAt); +} + +class DownloadOwnersCompanion extends UpdateCompanion { + final Value profileId; + final Value globalKey; + final Value createdAt; + final Value rowid; + const DownloadOwnersCompanion({ + this.profileId = const Value.absent(), + this.globalKey = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + DownloadOwnersCompanion.insert({ + required String profileId, + required String globalKey, + required int createdAt, + this.rowid = const Value.absent(), + }) : profileId = Value(profileId), + globalKey = Value(globalKey), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? profileId, + Expression? globalKey, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (profileId != null) 'profile_id': profileId, + if (globalKey != null) 'global_key': globalKey, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + DownloadOwnersCompanion copyWith({ + Value? profileId, + Value? globalKey, + Value? createdAt, + Value? rowid, + }) { + return DownloadOwnersCompanion( + profileId: profileId ?? this.profileId, + globalKey: globalKey ?? this.globalKey, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (profileId.present) { + map['profile_id'] = Variable(profileId.value); + } + if (globalKey.present) { + map['global_key'] = Variable(globalKey.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('DownloadOwnersCompanion(') + ..write('profileId: $profileId, ') + ..write('globalKey: $globalKey, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $DownloadQueueTable extends DownloadQueue + with TableInfo<$DownloadQueueTable, DownloadQueueItem> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -858,9 +1371,13 @@ class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTab hasAutoIncrement: true, type: DriftSqlType.int, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _mediaGlobalKeyMeta = const VerificationMeta( + 'mediaGlobalKey', ); - static const VerificationMeta _mediaGlobalKeyMeta = const VerificationMeta('mediaGlobalKey'); @override late final GeneratedColumn mediaGlobalKey = GeneratedColumn( 'media_global_key', @@ -870,7 +1387,9 @@ class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTab requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), ); - static const VerificationMeta _priorityMeta = const VerificationMeta('priority'); + static const VerificationMeta _priorityMeta = const VerificationMeta( + 'priority', + ); @override late final GeneratedColumn priority = GeneratedColumn( 'priority', @@ -880,7 +1399,9 @@ class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTab requiredDuringInsert: false, defaultValue: const Constant(0), ); - static const VerificationMeta _addedAtMeta = const VerificationMeta('addedAt'); + static const VerificationMeta _addedAtMeta = const VerificationMeta( + 'addedAt', + ); @override late final GeneratedColumn addedAt = GeneratedColumn( 'added_at', @@ -889,7 +1410,9 @@ class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTab type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _downloadSubtitlesMeta = const VerificationMeta('downloadSubtitles'); + static const VerificationMeta _downloadSubtitlesMeta = const VerificationMeta( + 'downloadSubtitles', + ); @override late final GeneratedColumn downloadSubtitles = GeneratedColumn( 'download_subtitles', @@ -897,10 +1420,14 @@ class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTab false, type: DriftSqlType.bool, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("download_subtitles" IN (0, 1))'), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("download_subtitles" IN (0, 1))', + ), defaultValue: const Constant(true), ); - static const VerificationMeta _downloadArtworkMeta = const VerificationMeta('downloadArtwork'); + static const VerificationMeta _downloadArtworkMeta = const VerificationMeta( + 'downloadArtwork', + ); @override late final GeneratedColumn downloadArtwork = GeneratedColumn( 'download_artwork', @@ -908,18 +1435,30 @@ class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTab false, type: DriftSqlType.bool, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("download_artwork" IN (0, 1))'), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("download_artwork" IN (0, 1))', + ), defaultValue: const Constant(true), ); @override - List get $columns => [id, mediaGlobalKey, priority, addedAt, downloadSubtitles, downloadArtwork]; + List get $columns => [ + id, + mediaGlobalKey, + priority, + addedAt, + downloadSubtitles, + downloadArtwork, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'download_queue'; @override - VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { @@ -928,29 +1467,44 @@ class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTab if (data.containsKey('media_global_key')) { context.handle( _mediaGlobalKeyMeta, - mediaGlobalKey.isAcceptableOrUnknown(data['media_global_key']!, _mediaGlobalKeyMeta), + mediaGlobalKey.isAcceptableOrUnknown( + data['media_global_key']!, + _mediaGlobalKeyMeta, + ), ); } else if (isInserting) { context.missing(_mediaGlobalKeyMeta); } if (data.containsKey('priority')) { - context.handle(_priorityMeta, priority.isAcceptableOrUnknown(data['priority']!, _priorityMeta)); + context.handle( + _priorityMeta, + priority.isAcceptableOrUnknown(data['priority']!, _priorityMeta), + ); } if (data.containsKey('added_at')) { - context.handle(_addedAtMeta, addedAt.isAcceptableOrUnknown(data['added_at']!, _addedAtMeta)); + context.handle( + _addedAtMeta, + addedAt.isAcceptableOrUnknown(data['added_at']!, _addedAtMeta), + ); } else if (isInserting) { context.missing(_addedAtMeta); } if (data.containsKey('download_subtitles')) { context.handle( _downloadSubtitlesMeta, - downloadSubtitles.isAcceptableOrUnknown(data['download_subtitles']!, _downloadSubtitlesMeta), + downloadSubtitles.isAcceptableOrUnknown( + data['download_subtitles']!, + _downloadSubtitlesMeta, + ), ); } if (data.containsKey('download_artwork')) { context.handle( _downloadArtworkMeta, - downloadArtwork.isAcceptableOrUnknown(data['download_artwork']!, _downloadArtworkMeta), + downloadArtwork.isAcceptableOrUnknown( + data['download_artwork']!, + _downloadArtworkMeta, + ), ); } return context; @@ -962,13 +1516,22 @@ class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTab DownloadQueueItem map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return DownloadQueueItem( - id: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}id'])!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, mediaGlobalKey: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}media_global_key'], )!, - priority: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}priority'])!, - addedAt: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}added_at'])!, + priority: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}priority'], + )!, + addedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}added_at'], + )!, downloadSubtitles: attachedDatabase.typeMapping.read( DriftSqlType.bool, data['${effectivePrefix}download_subtitles'], @@ -986,7 +1549,8 @@ class $DownloadQueueTable extends DownloadQueue with TableInfo<$DownloadQueueTab } } -class DownloadQueueItem extends DataClass implements Insertable { +class DownloadQueueItem extends DataClass + implements Insertable { final int id; final String mediaGlobalKey; final int priority; @@ -1024,7 +1588,10 @@ class DownloadQueueItem extends DataClass implements Insertable json, {ValueSerializer? serializer}) { + factory DownloadQueueItem.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return DownloadQueueItem( id: serializer.fromJson(json['id']), @@ -1066,11 +1633,17 @@ class DownloadQueueItem extends DataClass implements Insertable Object.hash(id, mediaGlobalKey, priority, addedAt, downloadSubtitles, downloadArtwork); + int get hashCode => Object.hash( + id, + mediaGlobalKey, + priority, + addedAt, + downloadSubtitles, + downloadArtwork, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -1199,12 +1779,15 @@ class DownloadQueueCompanion extends UpdateCompanion { } } -class $ApiCacheTable extends ApiCache with TableInfo<$ApiCacheTable, ApiCacheData> { +class $ApiCacheTable extends ApiCache + with TableInfo<$ApiCacheTable, ApiCacheData> { @override final GeneratedDatabase attachedDatabase; final String? _alias; $ApiCacheTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _cacheKeyMeta = const VerificationMeta('cacheKey'); + static const VerificationMeta _cacheKeyMeta = const VerificationMeta( + 'cacheKey', + ); @override late final GeneratedColumn cacheKey = GeneratedColumn( 'cache_key', @@ -1230,10 +1813,14 @@ class $ApiCacheTable extends ApiCache with TableInfo<$ApiCacheTable, ApiCacheDat false, type: DriftSqlType.bool, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("pinned" IN (0, 1))'), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("pinned" IN (0, 1))', + ), defaultValue: const Constant(false), ); - static const VerificationMeta _cachedAtMeta = const VerificationMeta('cachedAt'); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); @override late final GeneratedColumn cachedAt = GeneratedColumn( 'cached_at', @@ -1251,24 +1838,39 @@ class $ApiCacheTable extends ApiCache with TableInfo<$ApiCacheTable, ApiCacheDat String get actualTableName => $name; static const String $name = 'api_cache'; @override - VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('cache_key')) { - context.handle(_cacheKeyMeta, cacheKey.isAcceptableOrUnknown(data['cache_key']!, _cacheKeyMeta)); + context.handle( + _cacheKeyMeta, + cacheKey.isAcceptableOrUnknown(data['cache_key']!, _cacheKeyMeta), + ); } else if (isInserting) { context.missing(_cacheKeyMeta); } if (data.containsKey('data')) { - context.handle(_dataMeta, this.data.isAcceptableOrUnknown(data['data']!, _dataMeta)); + context.handle( + _dataMeta, + this.data.isAcceptableOrUnknown(data['data']!, _dataMeta), + ); } else if (isInserting) { context.missing(_dataMeta); } if (data.containsKey('pinned')) { - context.handle(_pinnedMeta, pinned.isAcceptableOrUnknown(data['pinned']!, _pinnedMeta)); + context.handle( + _pinnedMeta, + pinned.isAcceptableOrUnknown(data['pinned']!, _pinnedMeta), + ); } if (data.containsKey('cached_at')) { - context.handle(_cachedAtMeta, cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta)); + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); } return context; } @@ -1279,10 +1881,22 @@ class $ApiCacheTable extends ApiCache with TableInfo<$ApiCacheTable, ApiCacheDat ApiCacheData map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return ApiCacheData( - cacheKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}cache_key'])!, - data: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}data'])!, - pinned: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}pinned'])!, - cachedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}cached_at'])!, + cacheKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cache_key'], + )!, + data: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}data'], + )!, + pinned: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}pinned'], + )!, + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, ); } @@ -1293,7 +1907,8 @@ class $ApiCacheTable extends ApiCache with TableInfo<$ApiCacheTable, ApiCacheDat } class ApiCacheData extends DataClass implements Insertable { - /// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345") + /// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345" + /// for Plex, "abc123:/Users/.../Items/..." for Jellyfin) final String cacheKey; /// JSON response data @@ -1304,7 +1919,12 @@ class ApiCacheData extends DataClass implements Insertable { /// Timestamp for cache invalidation (optional future use) final DateTime cachedAt; - const ApiCacheData({required this.cacheKey, required this.data, required this.pinned, required this.cachedAt}); + const ApiCacheData({ + required this.cacheKey, + required this.data, + required this.pinned, + required this.cachedAt, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -1324,7 +1944,10 @@ class ApiCacheData extends DataClass implements Insertable { ); } - factory ApiCacheData.fromJson(Map json, {ValueSerializer? serializer}) { + factory ApiCacheData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return ApiCacheData( cacheKey: serializer.fromJson(json['cacheKey']), @@ -1344,7 +1967,12 @@ class ApiCacheData extends DataClass implements Insertable { }; } - ApiCacheData copyWith({String? cacheKey, String? data, bool? pinned, DateTime? cachedAt}) => ApiCacheData( + ApiCacheData copyWith({ + String? cacheKey, + String? data, + bool? pinned, + DateTime? cachedAt, + }) => ApiCacheData( cacheKey: cacheKey ?? this.cacheKey, data: data ?? this.data, pinned: pinned ?? this.pinned, @@ -1484,9 +2112,24 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress hasAutoIncrement: true, type: DriftSqlType.int, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _profileIdMeta = const VerificationMeta( + 'profileId', + ); + @override + late final GeneratedColumn profileId = GeneratedColumn( + 'profile_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _serverIdMeta = const VerificationMeta( + 'serverId', ); - static const VerificationMeta _serverIdMeta = const VerificationMeta('serverId'); @override late final GeneratedColumn serverId = GeneratedColumn( 'server_id', @@ -1495,7 +2138,20 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _ratingKeyMeta = const VerificationMeta('ratingKey'); + static const VerificationMeta _clientScopeIdMeta = const VerificationMeta( + 'clientScopeId', + ); + @override + late final GeneratedColumn clientScopeId = GeneratedColumn( + 'client_scope_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _ratingKeyMeta = const VerificationMeta( + 'ratingKey', + ); @override late final GeneratedColumn ratingKey = GeneratedColumn( 'rating_key', @@ -1504,7 +2160,9 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _globalKeyMeta = const VerificationMeta('globalKey'); + static const VerificationMeta _globalKeyMeta = const VerificationMeta( + 'globalKey', + ); @override late final GeneratedColumn globalKey = GeneratedColumn( 'global_key', @@ -1513,7 +2171,9 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _actionTypeMeta = const VerificationMeta('actionType'); + static const VerificationMeta _actionTypeMeta = const VerificationMeta( + 'actionType', + ); @override late final GeneratedColumn actionType = GeneratedColumn( 'action_type', @@ -1522,7 +2182,9 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _viewOffsetMeta = const VerificationMeta('viewOffset'); + static const VerificationMeta _viewOffsetMeta = const VerificationMeta( + 'viewOffset', + ); @override late final GeneratedColumn viewOffset = GeneratedColumn( 'view_offset', @@ -1531,7 +2193,9 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress type: DriftSqlType.int, requiredDuringInsert: false, ); - static const VerificationMeta _durationMeta = const VerificationMeta('duration'); + static const VerificationMeta _durationMeta = const VerificationMeta( + 'duration', + ); @override late final GeneratedColumn duration = GeneratedColumn( 'duration', @@ -1540,7 +2204,9 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress type: DriftSqlType.int, requiredDuringInsert: false, ); - static const VerificationMeta _shouldMarkWatchedMeta = const VerificationMeta('shouldMarkWatched'); + static const VerificationMeta _shouldMarkWatchedMeta = const VerificationMeta( + 'shouldMarkWatched', + ); @override late final GeneratedColumn shouldMarkWatched = GeneratedColumn( 'should_mark_watched', @@ -1548,10 +2214,14 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress false, type: DriftSqlType.bool, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("should_mark_watched" IN (0, 1))'), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("should_mark_watched" IN (0, 1))', + ), defaultValue: const Constant(false), ); - static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); @override late final GeneratedColumn createdAt = GeneratedColumn( 'created_at', @@ -1560,7 +2230,9 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); @override late final GeneratedColumn updatedAt = GeneratedColumn( 'updated_at', @@ -1569,7 +2241,9 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _syncAttemptsMeta = const VerificationMeta('syncAttempts'); + static const VerificationMeta _syncAttemptsMeta = const VerificationMeta( + 'syncAttempts', + ); @override late final GeneratedColumn syncAttempts = GeneratedColumn( 'sync_attempts', @@ -1579,7 +2253,9 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress requiredDuringInsert: false, defaultValue: const Constant(0), ); - static const VerificationMeta _lastErrorMeta = const VerificationMeta('lastError'); + static const VerificationMeta _lastErrorMeta = const VerificationMeta( + 'lastError', + ); @override late final GeneratedColumn lastError = GeneratedColumn( 'last_error', @@ -1591,7 +2267,9 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress @override List get $columns => [ id, + profileId, serverId, + clientScopeId, ratingKey, globalKey, actionType, @@ -1609,59 +2287,113 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress String get actualTableName => $name; static const String $name = 'offline_watch_progress'; @override - VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } + if (data.containsKey('profile_id')) { + context.handle( + _profileIdMeta, + profileId.isAcceptableOrUnknown(data['profile_id']!, _profileIdMeta), + ); + } if (data.containsKey('server_id')) { - context.handle(_serverIdMeta, serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta)); + context.handle( + _serverIdMeta, + serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta), + ); } else if (isInserting) { context.missing(_serverIdMeta); } + if (data.containsKey('client_scope_id')) { + context.handle( + _clientScopeIdMeta, + clientScopeId.isAcceptableOrUnknown( + data['client_scope_id']!, + _clientScopeIdMeta, + ), + ); + } if (data.containsKey('rating_key')) { - context.handle(_ratingKeyMeta, ratingKey.isAcceptableOrUnknown(data['rating_key']!, _ratingKeyMeta)); + context.handle( + _ratingKeyMeta, + ratingKey.isAcceptableOrUnknown(data['rating_key']!, _ratingKeyMeta), + ); } else if (isInserting) { context.missing(_ratingKeyMeta); } if (data.containsKey('global_key')) { - context.handle(_globalKeyMeta, globalKey.isAcceptableOrUnknown(data['global_key']!, _globalKeyMeta)); + context.handle( + _globalKeyMeta, + globalKey.isAcceptableOrUnknown(data['global_key']!, _globalKeyMeta), + ); } else if (isInserting) { context.missing(_globalKeyMeta); } if (data.containsKey('action_type')) { - context.handle(_actionTypeMeta, actionType.isAcceptableOrUnknown(data['action_type']!, _actionTypeMeta)); + context.handle( + _actionTypeMeta, + actionType.isAcceptableOrUnknown(data['action_type']!, _actionTypeMeta), + ); } else if (isInserting) { context.missing(_actionTypeMeta); } if (data.containsKey('view_offset')) { - context.handle(_viewOffsetMeta, viewOffset.isAcceptableOrUnknown(data['view_offset']!, _viewOffsetMeta)); + context.handle( + _viewOffsetMeta, + viewOffset.isAcceptableOrUnknown(data['view_offset']!, _viewOffsetMeta), + ); } if (data.containsKey('duration')) { - context.handle(_durationMeta, duration.isAcceptableOrUnknown(data['duration']!, _durationMeta)); + context.handle( + _durationMeta, + duration.isAcceptableOrUnknown(data['duration']!, _durationMeta), + ); } if (data.containsKey('should_mark_watched')) { context.handle( _shouldMarkWatchedMeta, - shouldMarkWatched.isAcceptableOrUnknown(data['should_mark_watched']!, _shouldMarkWatchedMeta), + shouldMarkWatched.isAcceptableOrUnknown( + data['should_mark_watched']!, + _shouldMarkWatchedMeta, + ), ); } if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); } else if (isInserting) { context.missing(_createdAtMeta); } if (data.containsKey('updated_at')) { - context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); } else if (isInserting) { context.missing(_updatedAtMeta); } if (data.containsKey('sync_attempts')) { - context.handle(_syncAttemptsMeta, syncAttempts.isAcceptableOrUnknown(data['sync_attempts']!, _syncAttemptsMeta)); + context.handle( + _syncAttemptsMeta, + syncAttempts.isAcceptableOrUnknown( + data['sync_attempts']!, + _syncAttemptsMeta, + ), + ); } if (data.containsKey('last_error')) { - context.handle(_lastErrorMeta, lastError.isAcceptableOrUnknown(data['last_error']!, _lastErrorMeta)); + context.handle( + _lastErrorMeta, + lastError.isAcceptableOrUnknown(data['last_error']!, _lastErrorMeta), + ); } return context; } @@ -1669,24 +2401,68 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress @override Set get $primaryKey => {id}; @override - OfflineWatchProgressItem map(Map data, {String? tablePrefix}) { + OfflineWatchProgressItem map( + Map data, { + String? tablePrefix, + }) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return OfflineWatchProgressItem( - id: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}id'])!, - serverId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}server_id'])!, - ratingKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}rating_key'])!, - globalKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}global_key'])!, - actionType: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}action_type'])!, - viewOffset: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}view_offset']), - duration: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}duration']), + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + profileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}profile_id'], + ), + serverId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_id'], + )!, + clientScopeId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}client_scope_id'], + ), + ratingKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}rating_key'], + )!, + globalKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}global_key'], + )!, + actionType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}action_type'], + )!, + viewOffset: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}view_offset'], + ), + duration: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration'], + ), shouldMarkWatched: attachedDatabase.typeMapping.read( DriftSqlType.bool, data['${effectivePrefix}should_mark_watched'], )!, - createdAt: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}created_at'])!, - updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}updated_at'])!, - syncAttempts: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}sync_attempts'])!, - lastError: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}last_error']), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at'], + )!, + syncAttempts: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sync_attempts'], + )!, + lastError: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_error'], + ), ); } @@ -1696,13 +2472,21 @@ class $OfflineWatchProgressTable extends OfflineWatchProgress } } -class OfflineWatchProgressItem extends DataClass implements Insertable { +class OfflineWatchProgressItem extends DataClass + implements Insertable { /// Auto-incrementing primary key final int id; + /// Active Plezy profile that owns this queued action. + final String? profileId; + /// Server ID this media belongs to final String serverId; + /// Optional user-scoped client/cache id for backends where [serverId] is + /// shared by multiple users on the same server. + final String? clientScopeId; + /// Rating key of the media item final String ratingKey; @@ -1735,7 +2519,9 @@ class OfflineWatchProgressItem extends DataClass implements Insertable toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); + if (!nullToAbsent || profileId != null) { + map['profile_id'] = Variable(profileId); + } map['server_id'] = Variable(serverId); + if (!nullToAbsent || clientScopeId != null) { + map['client_scope_id'] = Variable(clientScopeId); + } map['rating_key'] = Variable(ratingKey); map['global_key'] = Variable(globalKey); map['action_type'] = Variable(actionType); @@ -1774,25 +2566,42 @@ class OfflineWatchProgressItem extends DataClass implements Insertable json, {ValueSerializer? serializer}) { + factory OfflineWatchProgressItem.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return OfflineWatchProgressItem( id: serializer.fromJson(json['id']), + profileId: serializer.fromJson(json['profileId']), serverId: serializer.fromJson(json['serverId']), + clientScopeId: serializer.fromJson(json['clientScopeId']), ratingKey: serializer.fromJson(json['ratingKey']), globalKey: serializer.fromJson(json['globalKey']), actionType: serializer.fromJson(json['actionType']), @@ -1810,7 +2619,9 @@ class OfflineWatchProgressItem extends DataClass implements Insertable{ 'id': serializer.toJson(id), + 'profileId': serializer.toJson(profileId), 'serverId': serializer.toJson(serverId), + 'clientScopeId': serializer.toJson(clientScopeId), 'ratingKey': serializer.toJson(ratingKey), 'globalKey': serializer.toJson(globalKey), 'actionType': serializer.toJson(actionType), @@ -1826,7 +2637,9 @@ class OfflineWatchProgressItem extends DataClass implements Insertable profileId = const Value.absent(), String? serverId, + Value clientScopeId = const Value.absent(), String? ratingKey, String? globalKey, String? actionType, @@ -1839,7 +2652,11 @@ class OfflineWatchProgressItem extends DataClass implements Insertable lastError = const Value.absent(), }) => OfflineWatchProgressItem( id: id ?? this.id, + profileId: profileId.present ? profileId.value : this.profileId, serverId: serverId ?? this.serverId, + clientScopeId: clientScopeId.present + ? clientScopeId.value + : this.clientScopeId, ratingKey: ratingKey ?? this.ratingKey, globalKey: globalKey ?? this.globalKey, actionType: actionType ?? this.actionType, @@ -1851,19 +2668,33 @@ class OfflineWatchProgressItem extends DataClass implements Insertable Object.hash( id, + profileId, serverId, + clientScopeId, ratingKey, globalKey, actionType, @@ -1907,7 +2742,9 @@ class OfflineWatchProgressItem extends DataClass implements Insertable { +class OfflineWatchProgressCompanion + extends UpdateCompanion { final Value id; + final Value profileId; final Value serverId; + final Value clientScopeId; final Value ratingKey; final Value globalKey; final Value actionType; @@ -1935,7 +2775,9 @@ class OfflineWatchProgressCompanion extends UpdateCompanion lastError; const OfflineWatchProgressCompanion({ this.id = const Value.absent(), + this.profileId = const Value.absent(), this.serverId = const Value.absent(), + this.clientScopeId = const Value.absent(), this.ratingKey = const Value.absent(), this.globalKey = const Value.absent(), this.actionType = const Value.absent(), @@ -1949,7 +2791,9 @@ class OfflineWatchProgressCompanion extends UpdateCompanion custom({ Expression? id, + Expression? profileId, Expression? serverId, + Expression? clientScopeId, Expression? ratingKey, Expression? globalKey, Expression? actionType, @@ -1982,7 +2828,9 @@ class OfflineWatchProgressCompanion extends UpdateCompanion? id, + Value? profileId, Value? serverId, + Value? clientScopeId, Value? ratingKey, Value? globalKey, Value? actionType, @@ -2012,7 +2862,9 @@ class OfflineWatchProgressCompanion extends UpdateCompanion(id.value); } + if (profileId.present) { + map['profile_id'] = Variable(profileId.value); + } if (serverId.present) { map['server_id'] = Variable(serverId.value); } + if (clientScopeId.present) { + map['client_scope_id'] = Variable(clientScopeId.value); + } if (ratingKey.present) { map['rating_key'] = Variable(ratingKey.value); } @@ -2072,7 +2930,9 @@ class OfflineWatchProgressCompanion extends UpdateCompanion { +class $SyncRulesTable extends SyncRules + with TableInfo<$SyncRulesTable, SyncRuleItem> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -2102,9 +2963,25 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule hasAutoIncrement: true, type: DriftSqlType.int, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _profileIdMeta = const VerificationMeta( + 'profileId', + ); + @override + late final GeneratedColumn profileId = GeneratedColumn( + 'profile_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _serverIdMeta = const VerificationMeta( + 'serverId', ); - static const VerificationMeta _serverIdMeta = const VerificationMeta('serverId'); @override late final GeneratedColumn serverId = GeneratedColumn( 'server_id', @@ -2113,7 +2990,9 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _ratingKeyMeta = const VerificationMeta('ratingKey'); + static const VerificationMeta _ratingKeyMeta = const VerificationMeta( + 'ratingKey', + ); @override late final GeneratedColumn ratingKey = GeneratedColumn( 'rating_key', @@ -2122,7 +3001,9 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _globalKeyMeta = const VerificationMeta('globalKey'); + static const VerificationMeta _globalKeyMeta = const VerificationMeta( + 'globalKey', + ); @override late final GeneratedColumn globalKey = GeneratedColumn( 'global_key', @@ -2132,7 +3013,9 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), ); - static const VerificationMeta _targetTypeMeta = const VerificationMeta('targetType'); + static const VerificationMeta _targetTypeMeta = const VerificationMeta( + 'targetType', + ); @override late final GeneratedColumn targetType = GeneratedColumn( 'target_type', @@ -2141,7 +3024,9 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _episodeCountMeta = const VerificationMeta('episodeCount'); + static const VerificationMeta _episodeCountMeta = const VerificationMeta( + 'episodeCount', + ); @override late final GeneratedColumn episodeCount = GeneratedColumn( 'episode_count', @@ -2150,7 +3035,9 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _enabledMeta = const VerificationMeta('enabled'); + static const VerificationMeta _enabledMeta = const VerificationMeta( + 'enabled', + ); @override late final GeneratedColumn enabled = GeneratedColumn( 'enabled', @@ -2158,10 +3045,14 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule false, type: DriftSqlType.bool, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("enabled" IN (0, 1))'), + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("enabled" IN (0, 1))', + ), defaultValue: const Constant(true), ); - static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); @override late final GeneratedColumn createdAt = GeneratedColumn( 'created_at', @@ -2170,7 +3061,9 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _lastExecutedAtMeta = const VerificationMeta('lastExecutedAt'); + static const VerificationMeta _lastExecutedAtMeta = const VerificationMeta( + 'lastExecutedAt', + ); @override late final GeneratedColumn lastExecutedAt = GeneratedColumn( 'last_executed_at', @@ -2179,7 +3072,9 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule type: DriftSqlType.int, requiredDuringInsert: false, ); - static const VerificationMeta _mediaIndexMeta = const VerificationMeta('mediaIndex'); + static const VerificationMeta _mediaIndexMeta = const VerificationMeta( + 'mediaIndex', + ); @override late final GeneratedColumn mediaIndex = GeneratedColumn( 'media_index', @@ -2189,7 +3084,9 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule requiredDuringInsert: false, defaultValue: const Constant(0), ); - static const VerificationMeta _downloadFilterMeta = const VerificationMeta('downloadFilter'); + static const VerificationMeta _downloadFilterMeta = const VerificationMeta( + 'downloadFilter', + ); @override late final GeneratedColumn downloadFilter = GeneratedColumn( 'download_filter', @@ -2202,6 +3099,7 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule @override List get $columns => [ id, + profileId, serverId, ratingKey, globalKey, @@ -2219,58 +3117,100 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule String get actualTableName => $name; static const String $name = 'sync_rules'; @override - VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } + if (data.containsKey('profile_id')) { + context.handle( + _profileIdMeta, + profileId.isAcceptableOrUnknown(data['profile_id']!, _profileIdMeta), + ); + } if (data.containsKey('server_id')) { - context.handle(_serverIdMeta, serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta)); + context.handle( + _serverIdMeta, + serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta), + ); } else if (isInserting) { context.missing(_serverIdMeta); } if (data.containsKey('rating_key')) { - context.handle(_ratingKeyMeta, ratingKey.isAcceptableOrUnknown(data['rating_key']!, _ratingKeyMeta)); + context.handle( + _ratingKeyMeta, + ratingKey.isAcceptableOrUnknown(data['rating_key']!, _ratingKeyMeta), + ); } else if (isInserting) { context.missing(_ratingKeyMeta); } if (data.containsKey('global_key')) { - context.handle(_globalKeyMeta, globalKey.isAcceptableOrUnknown(data['global_key']!, _globalKeyMeta)); + context.handle( + _globalKeyMeta, + globalKey.isAcceptableOrUnknown(data['global_key']!, _globalKeyMeta), + ); } else if (isInserting) { context.missing(_globalKeyMeta); } if (data.containsKey('target_type')) { - context.handle(_targetTypeMeta, targetType.isAcceptableOrUnknown(data['target_type']!, _targetTypeMeta)); + context.handle( + _targetTypeMeta, + targetType.isAcceptableOrUnknown(data['target_type']!, _targetTypeMeta), + ); } else if (isInserting) { context.missing(_targetTypeMeta); } if (data.containsKey('episode_count')) { - context.handle(_episodeCountMeta, episodeCount.isAcceptableOrUnknown(data['episode_count']!, _episodeCountMeta)); + context.handle( + _episodeCountMeta, + episodeCount.isAcceptableOrUnknown( + data['episode_count']!, + _episodeCountMeta, + ), + ); } else if (isInserting) { context.missing(_episodeCountMeta); } if (data.containsKey('enabled')) { - context.handle(_enabledMeta, enabled.isAcceptableOrUnknown(data['enabled']!, _enabledMeta)); + context.handle( + _enabledMeta, + enabled.isAcceptableOrUnknown(data['enabled']!, _enabledMeta), + ); } if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); } else if (isInserting) { context.missing(_createdAtMeta); } if (data.containsKey('last_executed_at')) { context.handle( _lastExecutedAtMeta, - lastExecutedAt.isAcceptableOrUnknown(data['last_executed_at']!, _lastExecutedAtMeta), + lastExecutedAt.isAcceptableOrUnknown( + data['last_executed_at']!, + _lastExecutedAtMeta, + ), ); } if (data.containsKey('media_index')) { - context.handle(_mediaIndexMeta, mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta)); + context.handle( + _mediaIndexMeta, + mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta), + ); } if (data.containsKey('download_filter')) { context.handle( _downloadFilterMeta, - downloadFilter.isAcceptableOrUnknown(data['download_filter']!, _downloadFilterMeta), + downloadFilter.isAcceptableOrUnknown( + data['download_filter']!, + _downloadFilterMeta, + ), ); } return context; @@ -2282,16 +3222,50 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule SyncRuleItem map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return SyncRuleItem( - id: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}id'])!, - serverId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}server_id'])!, - ratingKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}rating_key'])!, - globalKey: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}global_key'])!, - targetType: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}target_type'])!, - episodeCount: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}episode_count'])!, - enabled: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}enabled'])!, - createdAt: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}created_at'])!, - lastExecutedAt: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}last_executed_at']), - mediaIndex: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}media_index'])!, + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + profileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}profile_id'], + )!, + serverId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_id'], + )!, + ratingKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}rating_key'], + )!, + globalKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}global_key'], + )!, + targetType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}target_type'], + )!, + episodeCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}episode_count'], + )!, + enabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}enabled'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + lastExecutedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_executed_at'], + ), + mediaIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}media_index'], + )!, downloadFilter: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}download_filter'], @@ -2307,6 +3281,7 @@ class $SyncRulesTable extends SyncRules with TableInfo<$SyncRulesTable, SyncRule class SyncRuleItem extends DataClass implements Insertable { final int id; + final String profileId; final String serverId; final String ratingKey; final String globalKey; @@ -2319,6 +3294,7 @@ class SyncRuleItem extends DataClass implements Insertable { final String downloadFilter; const SyncRuleItem({ required this.id, + required this.profileId, required this.serverId, required this.ratingKey, required this.globalKey, @@ -2334,6 +3310,7 @@ class SyncRuleItem extends DataClass implements Insertable { Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); + map['profile_id'] = Variable(profileId); map['server_id'] = Variable(serverId); map['rating_key'] = Variable(ratingKey); map['global_key'] = Variable(globalKey); @@ -2352,6 +3329,7 @@ class SyncRuleItem extends DataClass implements Insertable { SyncRulesCompanion toCompanion(bool nullToAbsent) { return SyncRulesCompanion( id: Value(id), + profileId: Value(profileId), serverId: Value(serverId), ratingKey: Value(ratingKey), globalKey: Value(globalKey), @@ -2359,16 +3337,22 @@ class SyncRuleItem extends DataClass implements Insertable { episodeCount: Value(episodeCount), enabled: Value(enabled), createdAt: Value(createdAt), - lastExecutedAt: lastExecutedAt == null && nullToAbsent ? const Value.absent() : Value(lastExecutedAt), + lastExecutedAt: lastExecutedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastExecutedAt), mediaIndex: Value(mediaIndex), downloadFilter: Value(downloadFilter), ); } - factory SyncRuleItem.fromJson(Map json, {ValueSerializer? serializer}) { + factory SyncRuleItem.fromJson( + Map json, { + ValueSerializer? serializer, + }) { serializer ??= driftRuntimeOptions.defaultSerializer; return SyncRuleItem( id: serializer.fromJson(json['id']), + profileId: serializer.fromJson(json['profileId']), serverId: serializer.fromJson(json['serverId']), ratingKey: serializer.fromJson(json['ratingKey']), globalKey: serializer.fromJson(json['globalKey']), @@ -2386,6 +3370,7 @@ class SyncRuleItem extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), + 'profileId': serializer.toJson(profileId), 'serverId': serializer.toJson(serverId), 'ratingKey': serializer.toJson(ratingKey), 'globalKey': serializer.toJson(globalKey), @@ -2401,6 +3386,7 @@ class SyncRuleItem extends DataClass implements Insertable { SyncRuleItem copyWith({ int? id, + String? profileId, String? serverId, String? ratingKey, String? globalKey, @@ -2413,6 +3399,7 @@ class SyncRuleItem extends DataClass implements Insertable { String? downloadFilter, }) => SyncRuleItem( id: id ?? this.id, + profileId: profileId ?? this.profileId, serverId: serverId ?? this.serverId, ratingKey: ratingKey ?? this.ratingKey, globalKey: globalKey ?? this.globalKey, @@ -2420,23 +3407,36 @@ class SyncRuleItem extends DataClass implements Insertable { episodeCount: episodeCount ?? this.episodeCount, enabled: enabled ?? this.enabled, createdAt: createdAt ?? this.createdAt, - lastExecutedAt: lastExecutedAt.present ? lastExecutedAt.value : this.lastExecutedAt, + lastExecutedAt: lastExecutedAt.present + ? lastExecutedAt.value + : this.lastExecutedAt, mediaIndex: mediaIndex ?? this.mediaIndex, downloadFilter: downloadFilter ?? this.downloadFilter, ); SyncRuleItem copyWithCompanion(SyncRulesCompanion data) { return SyncRuleItem( id: data.id.present ? data.id.value : this.id, + profileId: data.profileId.present ? data.profileId.value : this.profileId, serverId: data.serverId.present ? data.serverId.value : this.serverId, ratingKey: data.ratingKey.present ? data.ratingKey.value : this.ratingKey, globalKey: data.globalKey.present ? data.globalKey.value : this.globalKey, - targetType: data.targetType.present ? data.targetType.value : this.targetType, - episodeCount: data.episodeCount.present ? data.episodeCount.value : this.episodeCount, + targetType: data.targetType.present + ? data.targetType.value + : this.targetType, + episodeCount: data.episodeCount.present + ? data.episodeCount.value + : this.episodeCount, enabled: data.enabled.present ? data.enabled.value : this.enabled, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - lastExecutedAt: data.lastExecutedAt.present ? data.lastExecutedAt.value : this.lastExecutedAt, - mediaIndex: data.mediaIndex.present ? data.mediaIndex.value : this.mediaIndex, - downloadFilter: data.downloadFilter.present ? data.downloadFilter.value : this.downloadFilter, + lastExecutedAt: data.lastExecutedAt.present + ? data.lastExecutedAt.value + : this.lastExecutedAt, + mediaIndex: data.mediaIndex.present + ? data.mediaIndex.value + : this.mediaIndex, + downloadFilter: data.downloadFilter.present + ? data.downloadFilter.value + : this.downloadFilter, ); } @@ -2444,6 +3444,7 @@ class SyncRuleItem extends DataClass implements Insertable { String toString() { return (StringBuffer('SyncRuleItem(') ..write('id: $id, ') + ..write('profileId: $profileId, ') ..write('serverId: $serverId, ') ..write('ratingKey: $ratingKey, ') ..write('globalKey: $globalKey, ') @@ -2461,6 +3462,7 @@ class SyncRuleItem extends DataClass implements Insertable { @override int get hashCode => Object.hash( id, + profileId, serverId, ratingKey, globalKey, @@ -2477,6 +3479,7 @@ class SyncRuleItem extends DataClass implements Insertable { identical(this, other) || (other is SyncRuleItem && other.id == this.id && + other.profileId == this.profileId && other.serverId == this.serverId && other.ratingKey == this.ratingKey && other.globalKey == this.globalKey && @@ -2491,6 +3494,7 @@ class SyncRuleItem extends DataClass implements Insertable { class SyncRulesCompanion extends UpdateCompanion { final Value id; + final Value profileId; final Value serverId; final Value ratingKey; final Value globalKey; @@ -2503,6 +3507,7 @@ class SyncRulesCompanion extends UpdateCompanion { final Value downloadFilter; const SyncRulesCompanion({ this.id = const Value.absent(), + this.profileId = const Value.absent(), this.serverId = const Value.absent(), this.ratingKey = const Value.absent(), this.globalKey = const Value.absent(), @@ -2516,6 +3521,7 @@ class SyncRulesCompanion extends UpdateCompanion { }); SyncRulesCompanion.insert({ this.id = const Value.absent(), + this.profileId = const Value.absent(), required String serverId, required String ratingKey, required String globalKey, @@ -2534,6 +3540,7 @@ class SyncRulesCompanion extends UpdateCompanion { createdAt = Value(createdAt); static Insertable custom({ Expression? id, + Expression? profileId, Expression? serverId, Expression? ratingKey, Expression? globalKey, @@ -2547,6 +3554,7 @@ class SyncRulesCompanion extends UpdateCompanion { }) { return RawValuesInsertable({ if (id != null) 'id': id, + if (profileId != null) 'profile_id': profileId, if (serverId != null) 'server_id': serverId, if (ratingKey != null) 'rating_key': ratingKey, if (globalKey != null) 'global_key': globalKey, @@ -2562,6 +3570,7 @@ class SyncRulesCompanion extends UpdateCompanion { SyncRulesCompanion copyWith({ Value? id, + Value? profileId, Value? serverId, Value? ratingKey, Value? globalKey, @@ -2575,6 +3584,7 @@ class SyncRulesCompanion extends UpdateCompanion { }) { return SyncRulesCompanion( id: id ?? this.id, + profileId: profileId ?? this.profileId, serverId: serverId ?? this.serverId, ratingKey: ratingKey ?? this.ratingKey, globalKey: globalKey ?? this.globalKey, @@ -2594,6 +3604,9 @@ class SyncRulesCompanion extends UpdateCompanion { if (id.present) { map['id'] = Variable(id.value); } + if (profileId.present) { + map['profile_id'] = Variable(profileId.value); + } if (serverId.present) { map['server_id'] = Variable(serverId.value); } @@ -2631,6 +3644,7 @@ class SyncRulesCompanion extends UpdateCompanion { String toString() { return (StringBuffer('SyncRulesCompanion(') ..write('id: $id, ') + ..write('profileId: $profileId, ') ..write('serverId: $serverId, ') ..write('ratingKey: $ratingKey, ') ..write('globalKey: $globalKey, ') @@ -2646,14 +3660,1554 @@ class SyncRulesCompanion extends UpdateCompanion { } } +class $ConnectionsTable extends Connections + with TableInfo<$ConnectionsTable, ConnectionRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ConnectionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _kindMeta = const VerificationMeta('kind'); + @override + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _displayNameMeta = const VerificationMeta( + 'displayName', + ); + @override + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _configJsonMeta = const VerificationMeta( + 'configJson', + ); + @override + late final GeneratedColumn configJson = GeneratedColumn( + 'config_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _isDefaultMeta = const VerificationMeta( + 'isDefault', + ); + @override + late final GeneratedColumn isDefault = GeneratedColumn( + 'is_default', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_default" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastAuthenticatedAtMeta = + const VerificationMeta('lastAuthenticatedAt'); + @override + late final GeneratedColumn lastAuthenticatedAt = GeneratedColumn( + 'last_authenticated_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + kind, + displayName, + configJson, + isDefault, + createdAt, + lastAuthenticatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'connections'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('kind')) { + context.handle( + _kindMeta, + kind.isAcceptableOrUnknown(data['kind']!, _kindMeta), + ); + } else if (isInserting) { + context.missing(_kindMeta); + } + if (data.containsKey('display_name')) { + context.handle( + _displayNameMeta, + displayName.isAcceptableOrUnknown( + data['display_name']!, + _displayNameMeta, + ), + ); + } else if (isInserting) { + context.missing(_displayNameMeta); + } + if (data.containsKey('config_json')) { + context.handle( + _configJsonMeta, + configJson.isAcceptableOrUnknown(data['config_json']!, _configJsonMeta), + ); + } else if (isInserting) { + context.missing(_configJsonMeta); + } + if (data.containsKey('is_default')) { + context.handle( + _isDefaultMeta, + isDefault.isAcceptableOrUnknown(data['is_default']!, _isDefaultMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('last_authenticated_at')) { + context.handle( + _lastAuthenticatedAtMeta, + lastAuthenticatedAt.isAcceptableOrUnknown( + data['last_authenticated_at']!, + _lastAuthenticatedAtMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ConnectionRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ConnectionRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + kind: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}kind'], + )!, + displayName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}display_name'], + )!, + configJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}config_json'], + )!, + isDefault: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_default'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + lastAuthenticatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_authenticated_at'], + ), + ); + } + + @override + $ConnectionsTable createAlias(String alias) { + return $ConnectionsTable(attachedDatabase, alias); + } +} + +class ConnectionRow extends DataClass implements Insertable { + /// Stable identifier for the connection. For Plex it's a generated UUID + /// (one per account); for Jellyfin it's the server's machineId. + final String id; + + /// Backend kind: `'plex'` or `'jellyfin'`. + final String kind; + + /// User-visible label (account email, server name). + final String displayName; + + /// Backend-specific config payload (token, baseUrl, profile id, …). + final String configJson; + + /// Whether this is the default connection used at app launch when only + /// one connection is present. + final bool isDefault; + + /// Timestamp this connection was added (milliseconds since epoch). + final int createdAt; + + /// Timestamp of the most-recent successful auth refresh (milliseconds + /// since epoch). Null until the first successful auth. + final int? lastAuthenticatedAt; + const ConnectionRow({ + required this.id, + required this.kind, + required this.displayName, + required this.configJson, + required this.isDefault, + required this.createdAt, + this.lastAuthenticatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['kind'] = Variable(kind); + map['display_name'] = Variable(displayName); + map['config_json'] = Variable(configJson); + map['is_default'] = Variable(isDefault); + map['created_at'] = Variable(createdAt); + if (!nullToAbsent || lastAuthenticatedAt != null) { + map['last_authenticated_at'] = Variable(lastAuthenticatedAt); + } + return map; + } + + ConnectionsCompanion toCompanion(bool nullToAbsent) { + return ConnectionsCompanion( + id: Value(id), + kind: Value(kind), + displayName: Value(displayName), + configJson: Value(configJson), + isDefault: Value(isDefault), + createdAt: Value(createdAt), + lastAuthenticatedAt: lastAuthenticatedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastAuthenticatedAt), + ); + } + + factory ConnectionRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ConnectionRow( + id: serializer.fromJson(json['id']), + kind: serializer.fromJson(json['kind']), + displayName: serializer.fromJson(json['displayName']), + configJson: serializer.fromJson(json['configJson']), + isDefault: serializer.fromJson(json['isDefault']), + createdAt: serializer.fromJson(json['createdAt']), + lastAuthenticatedAt: serializer.fromJson( + json['lastAuthenticatedAt'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'kind': serializer.toJson(kind), + 'displayName': serializer.toJson(displayName), + 'configJson': serializer.toJson(configJson), + 'isDefault': serializer.toJson(isDefault), + 'createdAt': serializer.toJson(createdAt), + 'lastAuthenticatedAt': serializer.toJson(lastAuthenticatedAt), + }; + } + + ConnectionRow copyWith({ + String? id, + String? kind, + String? displayName, + String? configJson, + bool? isDefault, + int? createdAt, + Value lastAuthenticatedAt = const Value.absent(), + }) => ConnectionRow( + id: id ?? this.id, + kind: kind ?? this.kind, + displayName: displayName ?? this.displayName, + configJson: configJson ?? this.configJson, + isDefault: isDefault ?? this.isDefault, + createdAt: createdAt ?? this.createdAt, + lastAuthenticatedAt: lastAuthenticatedAt.present + ? lastAuthenticatedAt.value + : this.lastAuthenticatedAt, + ); + ConnectionRow copyWithCompanion(ConnectionsCompanion data) { + return ConnectionRow( + id: data.id.present ? data.id.value : this.id, + kind: data.kind.present ? data.kind.value : this.kind, + displayName: data.displayName.present + ? data.displayName.value + : this.displayName, + configJson: data.configJson.present + ? data.configJson.value + : this.configJson, + isDefault: data.isDefault.present ? data.isDefault.value : this.isDefault, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + lastAuthenticatedAt: data.lastAuthenticatedAt.present + ? data.lastAuthenticatedAt.value + : this.lastAuthenticatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ConnectionRow(') + ..write('id: $id, ') + ..write('kind: $kind, ') + ..write('displayName: $displayName, ') + ..write('configJson: $configJson, ') + ..write('isDefault: $isDefault, ') + ..write('createdAt: $createdAt, ') + ..write('lastAuthenticatedAt: $lastAuthenticatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + kind, + displayName, + configJson, + isDefault, + createdAt, + lastAuthenticatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ConnectionRow && + other.id == this.id && + other.kind == this.kind && + other.displayName == this.displayName && + other.configJson == this.configJson && + other.isDefault == this.isDefault && + other.createdAt == this.createdAt && + other.lastAuthenticatedAt == this.lastAuthenticatedAt); +} + +class ConnectionsCompanion extends UpdateCompanion { + final Value id; + final Value kind; + final Value displayName; + final Value configJson; + final Value isDefault; + final Value createdAt; + final Value lastAuthenticatedAt; + final Value rowid; + const ConnectionsCompanion({ + this.id = const Value.absent(), + this.kind = const Value.absent(), + this.displayName = const Value.absent(), + this.configJson = const Value.absent(), + this.isDefault = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastAuthenticatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ConnectionsCompanion.insert({ + required String id, + required String kind, + required String displayName, + required String configJson, + this.isDefault = const Value.absent(), + required int createdAt, + this.lastAuthenticatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + kind = Value(kind), + displayName = Value(displayName), + configJson = Value(configJson), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? kind, + Expression? displayName, + Expression? configJson, + Expression? isDefault, + Expression? createdAt, + Expression? lastAuthenticatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (kind != null) 'kind': kind, + if (displayName != null) 'display_name': displayName, + if (configJson != null) 'config_json': configJson, + if (isDefault != null) 'is_default': isDefault, + if (createdAt != null) 'created_at': createdAt, + if (lastAuthenticatedAt != null) + 'last_authenticated_at': lastAuthenticatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ConnectionsCompanion copyWith({ + Value? id, + Value? kind, + Value? displayName, + Value? configJson, + Value? isDefault, + Value? createdAt, + Value? lastAuthenticatedAt, + Value? rowid, + }) { + return ConnectionsCompanion( + id: id ?? this.id, + kind: kind ?? this.kind, + displayName: displayName ?? this.displayName, + configJson: configJson ?? this.configJson, + isDefault: isDefault ?? this.isDefault, + createdAt: createdAt ?? this.createdAt, + lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (kind.present) { + map['kind'] = Variable(kind.value); + } + if (displayName.present) { + map['display_name'] = Variable(displayName.value); + } + if (configJson.present) { + map['config_json'] = Variable(configJson.value); + } + if (isDefault.present) { + map['is_default'] = Variable(isDefault.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (lastAuthenticatedAt.present) { + map['last_authenticated_at'] = Variable(lastAuthenticatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ConnectionsCompanion(') + ..write('id: $id, ') + ..write('kind: $kind, ') + ..write('displayName: $displayName, ') + ..write('configJson: $configJson, ') + ..write('isDefault: $isDefault, ') + ..write('createdAt: $createdAt, ') + ..write('lastAuthenticatedAt: $lastAuthenticatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ProfilesTable extends Profiles + with TableInfo<$ProfilesTable, ProfileRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ProfilesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _kindMeta = const VerificationMeta('kind'); + @override + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _displayNameMeta = const VerificationMeta( + 'displayName', + ); + @override + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _avatarThumbUrlMeta = const VerificationMeta( + 'avatarThumbUrl', + ); + @override + late final GeneratedColumn avatarThumbUrl = GeneratedColumn( + 'avatar_thumb_url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _configJsonMeta = const VerificationMeta( + 'configJson', + ); + @override + late final GeneratedColumn configJson = GeneratedColumn( + 'config_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sortOrderMeta = const VerificationMeta( + 'sortOrder', + ); + @override + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastUsedAtMeta = const VerificationMeta( + 'lastUsedAt', + ); + @override + late final GeneratedColumn lastUsedAt = GeneratedColumn( + 'last_used_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + kind, + displayName, + avatarThumbUrl, + configJson, + sortOrder, + createdAt, + lastUsedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'profiles'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('kind')) { + context.handle( + _kindMeta, + kind.isAcceptableOrUnknown(data['kind']!, _kindMeta), + ); + } else if (isInserting) { + context.missing(_kindMeta); + } + if (data.containsKey('display_name')) { + context.handle( + _displayNameMeta, + displayName.isAcceptableOrUnknown( + data['display_name']!, + _displayNameMeta, + ), + ); + } else if (isInserting) { + context.missing(_displayNameMeta); + } + if (data.containsKey('avatar_thumb_url')) { + context.handle( + _avatarThumbUrlMeta, + avatarThumbUrl.isAcceptableOrUnknown( + data['avatar_thumb_url']!, + _avatarThumbUrlMeta, + ), + ); + } + if (data.containsKey('config_json')) { + context.handle( + _configJsonMeta, + configJson.isAcceptableOrUnknown(data['config_json']!, _configJsonMeta), + ); + } else if (isInserting) { + context.missing(_configJsonMeta); + } + if (data.containsKey('sort_order')) { + context.handle( + _sortOrderMeta, + sortOrder.isAcceptableOrUnknown(data['sort_order']!, _sortOrderMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('last_used_at')) { + context.handle( + _lastUsedAtMeta, + lastUsedAt.isAcceptableOrUnknown( + data['last_used_at']!, + _lastUsedAtMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ProfileRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ProfileRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + kind: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}kind'], + )!, + displayName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}display_name'], + )!, + avatarThumbUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}avatar_thumb_url'], + ), + configJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}config_json'], + )!, + sortOrder: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sort_order'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + lastUsedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_used_at'], + ), + ); + } + + @override + $ProfilesTable createAlias(String alias) { + return $ProfilesTable(attachedDatabase, alias); + } +} + +class ProfileRow extends DataClass implements Insertable { + /// Stable identifier. For Plex Home profiles: `plex-home-{accountId}-{homeUserUuid}` + /// (deterministic so re-discovery is idempotent). For locals: `local-{uuid}`. + final String id; + + /// `'local'` | `'plex_home'`. + final String kind; + final String displayName; + + /// Plex Home users have a thumb URL; locals fall back to initials/colour. + final String? avatarThumbUrl; + + /// Per-kind config: + /// - `local`: `{ "pinHash": "..." }` + /// - `plex_home`: `{ "restricted": bool, "admin": bool, "hasPassword": bool, "parentConnectionId": "..." }` + final String configJson; + final int sortOrder; + final int createdAt; + final int? lastUsedAt; + const ProfileRow({ + required this.id, + required this.kind, + required this.displayName, + this.avatarThumbUrl, + required this.configJson, + required this.sortOrder, + required this.createdAt, + this.lastUsedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['kind'] = Variable(kind); + map['display_name'] = Variable(displayName); + if (!nullToAbsent || avatarThumbUrl != null) { + map['avatar_thumb_url'] = Variable(avatarThumbUrl); + } + map['config_json'] = Variable(configJson); + map['sort_order'] = Variable(sortOrder); + map['created_at'] = Variable(createdAt); + if (!nullToAbsent || lastUsedAt != null) { + map['last_used_at'] = Variable(lastUsedAt); + } + return map; + } + + ProfilesCompanion toCompanion(bool nullToAbsent) { + return ProfilesCompanion( + id: Value(id), + kind: Value(kind), + displayName: Value(displayName), + avatarThumbUrl: avatarThumbUrl == null && nullToAbsent + ? const Value.absent() + : Value(avatarThumbUrl), + configJson: Value(configJson), + sortOrder: Value(sortOrder), + createdAt: Value(createdAt), + lastUsedAt: lastUsedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastUsedAt), + ); + } + + factory ProfileRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ProfileRow( + id: serializer.fromJson(json['id']), + kind: serializer.fromJson(json['kind']), + displayName: serializer.fromJson(json['displayName']), + avatarThumbUrl: serializer.fromJson(json['avatarThumbUrl']), + configJson: serializer.fromJson(json['configJson']), + sortOrder: serializer.fromJson(json['sortOrder']), + createdAt: serializer.fromJson(json['createdAt']), + lastUsedAt: serializer.fromJson(json['lastUsedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'kind': serializer.toJson(kind), + 'displayName': serializer.toJson(displayName), + 'avatarThumbUrl': serializer.toJson(avatarThumbUrl), + 'configJson': serializer.toJson(configJson), + 'sortOrder': serializer.toJson(sortOrder), + 'createdAt': serializer.toJson(createdAt), + 'lastUsedAt': serializer.toJson(lastUsedAt), + }; + } + + ProfileRow copyWith({ + String? id, + String? kind, + String? displayName, + Value avatarThumbUrl = const Value.absent(), + String? configJson, + int? sortOrder, + int? createdAt, + Value lastUsedAt = const Value.absent(), + }) => ProfileRow( + id: id ?? this.id, + kind: kind ?? this.kind, + displayName: displayName ?? this.displayName, + avatarThumbUrl: avatarThumbUrl.present + ? avatarThumbUrl.value + : this.avatarThumbUrl, + configJson: configJson ?? this.configJson, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + lastUsedAt: lastUsedAt.present ? lastUsedAt.value : this.lastUsedAt, + ); + ProfileRow copyWithCompanion(ProfilesCompanion data) { + return ProfileRow( + id: data.id.present ? data.id.value : this.id, + kind: data.kind.present ? data.kind.value : this.kind, + displayName: data.displayName.present + ? data.displayName.value + : this.displayName, + avatarThumbUrl: data.avatarThumbUrl.present + ? data.avatarThumbUrl.value + : this.avatarThumbUrl, + configJson: data.configJson.present + ? data.configJson.value + : this.configJson, + sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + lastUsedAt: data.lastUsedAt.present + ? data.lastUsedAt.value + : this.lastUsedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ProfileRow(') + ..write('id: $id, ') + ..write('kind: $kind, ') + ..write('displayName: $displayName, ') + ..write('avatarThumbUrl: $avatarThumbUrl, ') + ..write('configJson: $configJson, ') + ..write('sortOrder: $sortOrder, ') + ..write('createdAt: $createdAt, ') + ..write('lastUsedAt: $lastUsedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + kind, + displayName, + avatarThumbUrl, + configJson, + sortOrder, + createdAt, + lastUsedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ProfileRow && + other.id == this.id && + other.kind == this.kind && + other.displayName == this.displayName && + other.avatarThumbUrl == this.avatarThumbUrl && + other.configJson == this.configJson && + other.sortOrder == this.sortOrder && + other.createdAt == this.createdAt && + other.lastUsedAt == this.lastUsedAt); +} + +class ProfilesCompanion extends UpdateCompanion { + final Value id; + final Value kind; + final Value displayName; + final Value avatarThumbUrl; + final Value configJson; + final Value sortOrder; + final Value createdAt; + final Value lastUsedAt; + final Value rowid; + const ProfilesCompanion({ + this.id = const Value.absent(), + this.kind = const Value.absent(), + this.displayName = const Value.absent(), + this.avatarThumbUrl = const Value.absent(), + this.configJson = const Value.absent(), + this.sortOrder = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastUsedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ProfilesCompanion.insert({ + required String id, + required String kind, + required String displayName, + this.avatarThumbUrl = const Value.absent(), + required String configJson, + this.sortOrder = const Value.absent(), + required int createdAt, + this.lastUsedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + kind = Value(kind), + displayName = Value(displayName), + configJson = Value(configJson), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? kind, + Expression? displayName, + Expression? avatarThumbUrl, + Expression? configJson, + Expression? sortOrder, + Expression? createdAt, + Expression? lastUsedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (kind != null) 'kind': kind, + if (displayName != null) 'display_name': displayName, + if (avatarThumbUrl != null) 'avatar_thumb_url': avatarThumbUrl, + if (configJson != null) 'config_json': configJson, + if (sortOrder != null) 'sort_order': sortOrder, + if (createdAt != null) 'created_at': createdAt, + if (lastUsedAt != null) 'last_used_at': lastUsedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ProfilesCompanion copyWith({ + Value? id, + Value? kind, + Value? displayName, + Value? avatarThumbUrl, + Value? configJson, + Value? sortOrder, + Value? createdAt, + Value? lastUsedAt, + Value? rowid, + }) { + return ProfilesCompanion( + id: id ?? this.id, + kind: kind ?? this.kind, + displayName: displayName ?? this.displayName, + avatarThumbUrl: avatarThumbUrl ?? this.avatarThumbUrl, + configJson: configJson ?? this.configJson, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (kind.present) { + map['kind'] = Variable(kind.value); + } + if (displayName.present) { + map['display_name'] = Variable(displayName.value); + } + if (avatarThumbUrl.present) { + map['avatar_thumb_url'] = Variable(avatarThumbUrl.value); + } + if (configJson.present) { + map['config_json'] = Variable(configJson.value); + } + if (sortOrder.present) { + map['sort_order'] = Variable(sortOrder.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (lastUsedAt.present) { + map['last_used_at'] = Variable(lastUsedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ProfilesCompanion(') + ..write('id: $id, ') + ..write('kind: $kind, ') + ..write('displayName: $displayName, ') + ..write('avatarThumbUrl: $avatarThumbUrl, ') + ..write('configJson: $configJson, ') + ..write('sortOrder: $sortOrder, ') + ..write('createdAt: $createdAt, ') + ..write('lastUsedAt: $lastUsedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ProfileConnectionsTable extends ProfileConnections + with TableInfo<$ProfileConnectionsTable, ProfileConnectionRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ProfileConnectionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _profileIdMeta = const VerificationMeta( + 'profileId', + ); + @override + late final GeneratedColumn profileId = GeneratedColumn( + 'profile_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _connectionIdMeta = const VerificationMeta( + 'connectionId', + ); + @override + late final GeneratedColumn connectionId = GeneratedColumn( + 'connection_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES connections (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _userTokenMeta = const VerificationMeta( + 'userToken', + ); + @override + late final GeneratedColumn userToken = GeneratedColumn( + 'user_token', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _userIdentifierMeta = const VerificationMeta( + 'userIdentifier', + ); + @override + late final GeneratedColumn userIdentifier = GeneratedColumn( + 'user_identifier', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _isDefaultMeta = const VerificationMeta( + 'isDefault', + ); + @override + late final GeneratedColumn isDefault = GeneratedColumn( + 'is_default', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_default" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _tokenAcquiredAtMeta = const VerificationMeta( + 'tokenAcquiredAt', + ); + @override + late final GeneratedColumn tokenAcquiredAt = GeneratedColumn( + 'token_acquired_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastUsedAtMeta = const VerificationMeta( + 'lastUsedAt', + ); + @override + late final GeneratedColumn lastUsedAt = GeneratedColumn( + 'last_used_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + profileId, + connectionId, + userToken, + userIdentifier, + isDefault, + tokenAcquiredAt, + lastUsedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'profile_connections'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('profile_id')) { + context.handle( + _profileIdMeta, + profileId.isAcceptableOrUnknown(data['profile_id']!, _profileIdMeta), + ); + } else if (isInserting) { + context.missing(_profileIdMeta); + } + if (data.containsKey('connection_id')) { + context.handle( + _connectionIdMeta, + connectionId.isAcceptableOrUnknown( + data['connection_id']!, + _connectionIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_connectionIdMeta); + } + if (data.containsKey('user_token')) { + context.handle( + _userTokenMeta, + userToken.isAcceptableOrUnknown(data['user_token']!, _userTokenMeta), + ); + } + if (data.containsKey('user_identifier')) { + context.handle( + _userIdentifierMeta, + userIdentifier.isAcceptableOrUnknown( + data['user_identifier']!, + _userIdentifierMeta, + ), + ); + } else if (isInserting) { + context.missing(_userIdentifierMeta); + } + if (data.containsKey('is_default')) { + context.handle( + _isDefaultMeta, + isDefault.isAcceptableOrUnknown(data['is_default']!, _isDefaultMeta), + ); + } + if (data.containsKey('token_acquired_at')) { + context.handle( + _tokenAcquiredAtMeta, + tokenAcquiredAt.isAcceptableOrUnknown( + data['token_acquired_at']!, + _tokenAcquiredAtMeta, + ), + ); + } + if (data.containsKey('last_used_at')) { + context.handle( + _lastUsedAtMeta, + lastUsedAt.isAcceptableOrUnknown( + data['last_used_at']!, + _lastUsedAtMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {profileId, connectionId}; + @override + ProfileConnectionRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ProfileConnectionRow( + profileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}profile_id'], + )!, + connectionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}connection_id'], + )!, + userToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_token'], + )!, + userIdentifier: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_identifier'], + )!, + isDefault: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_default'], + )!, + tokenAcquiredAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}token_acquired_at'], + ), + lastUsedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_used_at'], + ), + ); + } + + @override + $ProfileConnectionsTable createAlias(String alias) { + return $ProfileConnectionsTable(attachedDatabase, alias); + } +} + +class ProfileConnectionRow extends DataClass + implements Insertable { + final String profileId; + final String connectionId; + final String userToken; + final String userIdentifier; + final bool isDefault; + final int? tokenAcquiredAt; + final int? lastUsedAt; + const ProfileConnectionRow({ + required this.profileId, + required this.connectionId, + required this.userToken, + required this.userIdentifier, + required this.isDefault, + this.tokenAcquiredAt, + this.lastUsedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['profile_id'] = Variable(profileId); + map['connection_id'] = Variable(connectionId); + map['user_token'] = Variable(userToken); + map['user_identifier'] = Variable(userIdentifier); + map['is_default'] = Variable(isDefault); + if (!nullToAbsent || tokenAcquiredAt != null) { + map['token_acquired_at'] = Variable(tokenAcquiredAt); + } + if (!nullToAbsent || lastUsedAt != null) { + map['last_used_at'] = Variable(lastUsedAt); + } + return map; + } + + ProfileConnectionsCompanion toCompanion(bool nullToAbsent) { + return ProfileConnectionsCompanion( + profileId: Value(profileId), + connectionId: Value(connectionId), + userToken: Value(userToken), + userIdentifier: Value(userIdentifier), + isDefault: Value(isDefault), + tokenAcquiredAt: tokenAcquiredAt == null && nullToAbsent + ? const Value.absent() + : Value(tokenAcquiredAt), + lastUsedAt: lastUsedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastUsedAt), + ); + } + + factory ProfileConnectionRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ProfileConnectionRow( + profileId: serializer.fromJson(json['profileId']), + connectionId: serializer.fromJson(json['connectionId']), + userToken: serializer.fromJson(json['userToken']), + userIdentifier: serializer.fromJson(json['userIdentifier']), + isDefault: serializer.fromJson(json['isDefault']), + tokenAcquiredAt: serializer.fromJson(json['tokenAcquiredAt']), + lastUsedAt: serializer.fromJson(json['lastUsedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'profileId': serializer.toJson(profileId), + 'connectionId': serializer.toJson(connectionId), + 'userToken': serializer.toJson(userToken), + 'userIdentifier': serializer.toJson(userIdentifier), + 'isDefault': serializer.toJson(isDefault), + 'tokenAcquiredAt': serializer.toJson(tokenAcquiredAt), + 'lastUsedAt': serializer.toJson(lastUsedAt), + }; + } + + ProfileConnectionRow copyWith({ + String? profileId, + String? connectionId, + String? userToken, + String? userIdentifier, + bool? isDefault, + Value tokenAcquiredAt = const Value.absent(), + Value lastUsedAt = const Value.absent(), + }) => ProfileConnectionRow( + profileId: profileId ?? this.profileId, + connectionId: connectionId ?? this.connectionId, + userToken: userToken ?? this.userToken, + userIdentifier: userIdentifier ?? this.userIdentifier, + isDefault: isDefault ?? this.isDefault, + tokenAcquiredAt: tokenAcquiredAt.present + ? tokenAcquiredAt.value + : this.tokenAcquiredAt, + lastUsedAt: lastUsedAt.present ? lastUsedAt.value : this.lastUsedAt, + ); + ProfileConnectionRow copyWithCompanion(ProfileConnectionsCompanion data) { + return ProfileConnectionRow( + profileId: data.profileId.present ? data.profileId.value : this.profileId, + connectionId: data.connectionId.present + ? data.connectionId.value + : this.connectionId, + userToken: data.userToken.present ? data.userToken.value : this.userToken, + userIdentifier: data.userIdentifier.present + ? data.userIdentifier.value + : this.userIdentifier, + isDefault: data.isDefault.present ? data.isDefault.value : this.isDefault, + tokenAcquiredAt: data.tokenAcquiredAt.present + ? data.tokenAcquiredAt.value + : this.tokenAcquiredAt, + lastUsedAt: data.lastUsedAt.present + ? data.lastUsedAt.value + : this.lastUsedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ProfileConnectionRow(') + ..write('profileId: $profileId, ') + ..write('connectionId: $connectionId, ') + ..write('userToken: $userToken, ') + ..write('userIdentifier: $userIdentifier, ') + ..write('isDefault: $isDefault, ') + ..write('tokenAcquiredAt: $tokenAcquiredAt, ') + ..write('lastUsedAt: $lastUsedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + profileId, + connectionId, + userToken, + userIdentifier, + isDefault, + tokenAcquiredAt, + lastUsedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ProfileConnectionRow && + other.profileId == this.profileId && + other.connectionId == this.connectionId && + other.userToken == this.userToken && + other.userIdentifier == this.userIdentifier && + other.isDefault == this.isDefault && + other.tokenAcquiredAt == this.tokenAcquiredAt && + other.lastUsedAt == this.lastUsedAt); +} + +class ProfileConnectionsCompanion + extends UpdateCompanion { + final Value profileId; + final Value connectionId; + final Value userToken; + final Value userIdentifier; + final Value isDefault; + final Value tokenAcquiredAt; + final Value lastUsedAt; + final Value rowid; + const ProfileConnectionsCompanion({ + this.profileId = const Value.absent(), + this.connectionId = const Value.absent(), + this.userToken = const Value.absent(), + this.userIdentifier = const Value.absent(), + this.isDefault = const Value.absent(), + this.tokenAcquiredAt = const Value.absent(), + this.lastUsedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ProfileConnectionsCompanion.insert({ + required String profileId, + required String connectionId, + this.userToken = const Value.absent(), + required String userIdentifier, + this.isDefault = const Value.absent(), + this.tokenAcquiredAt = const Value.absent(), + this.lastUsedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : profileId = Value(profileId), + connectionId = Value(connectionId), + userIdentifier = Value(userIdentifier); + static Insertable custom({ + Expression? profileId, + Expression? connectionId, + Expression? userToken, + Expression? userIdentifier, + Expression? isDefault, + Expression? tokenAcquiredAt, + Expression? lastUsedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (profileId != null) 'profile_id': profileId, + if (connectionId != null) 'connection_id': connectionId, + if (userToken != null) 'user_token': userToken, + if (userIdentifier != null) 'user_identifier': userIdentifier, + if (isDefault != null) 'is_default': isDefault, + if (tokenAcquiredAt != null) 'token_acquired_at': tokenAcquiredAt, + if (lastUsedAt != null) 'last_used_at': lastUsedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ProfileConnectionsCompanion copyWith({ + Value? profileId, + Value? connectionId, + Value? userToken, + Value? userIdentifier, + Value? isDefault, + Value? tokenAcquiredAt, + Value? lastUsedAt, + Value? rowid, + }) { + return ProfileConnectionsCompanion( + profileId: profileId ?? this.profileId, + connectionId: connectionId ?? this.connectionId, + userToken: userToken ?? this.userToken, + userIdentifier: userIdentifier ?? this.userIdentifier, + isDefault: isDefault ?? this.isDefault, + tokenAcquiredAt: tokenAcquiredAt ?? this.tokenAcquiredAt, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (profileId.present) { + map['profile_id'] = Variable(profileId.value); + } + if (connectionId.present) { + map['connection_id'] = Variable(connectionId.value); + } + if (userToken.present) { + map['user_token'] = Variable(userToken.value); + } + if (userIdentifier.present) { + map['user_identifier'] = Variable(userIdentifier.value); + } + if (isDefault.present) { + map['is_default'] = Variable(isDefault.value); + } + if (tokenAcquiredAt.present) { + map['token_acquired_at'] = Variable(tokenAcquiredAt.value); + } + if (lastUsedAt.present) { + map['last_used_at'] = Variable(lastUsedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ProfileConnectionsCompanion(') + ..write('profileId: $profileId, ') + ..write('connectionId: $connectionId, ') + ..write('userToken: $userToken, ') + ..write('userIdentifier: $userIdentifier, ') + ..write('isDefault: $isDefault, ') + ..write('tokenAcquiredAt: $tokenAcquiredAt, ') + ..write('lastUsedAt: $lastUsedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); - late final $DownloadedMediaTable downloadedMedia = $DownloadedMediaTable(this); + late final $DownloadedMediaTable downloadedMedia = $DownloadedMediaTable( + this, + ); + late final $DownloadOwnersTable downloadOwners = $DownloadOwnersTable(this); late final $DownloadQueueTable downloadQueue = $DownloadQueueTable(this); late final $ApiCacheTable apiCache = $ApiCacheTable(this); - late final $OfflineWatchProgressTable offlineWatchProgress = $OfflineWatchProgressTable(this); + late final $OfflineWatchProgressTable offlineWatchProgress = + $OfflineWatchProgressTable(this); late final $SyncRulesTable syncRules = $SyncRulesTable(this); + late final $ConnectionsTable connections = $ConnectionsTable(this); + late final $ProfilesTable profiles = $ProfilesTable(this); + late final $ProfileConnectionsTable profileConnections = + $ProfileConnectionsTable(this); late final Index idxDownloadedMediaStatus = Index( 'idx_downloaded_media_status', 'CREATE INDEX idx_downloaded_media_status ON downloaded_media (status)', @@ -2670,26 +5224,87 @@ abstract class _$AppDatabase extends GeneratedDatabase { 'idx_downloaded_media_grandparent', 'CREATE INDEX idx_downloaded_media_grandparent ON downloaded_media (grandparent_rating_key)', ); + late final Index idxDownloadOwnersProfile = Index( + 'idx_download_owners_profile', + 'CREATE INDEX idx_download_owners_profile ON download_owners (profile_id)', + ); + late final Index idxDownloadOwnersGlobalKey = Index( + 'idx_download_owners_global_key', + 'CREATE INDEX idx_download_owners_global_key ON download_owners (global_key)', + ); + late final Index idxOfflineWatchProgressServer = Index( + 'idx_offline_watch_progress_server', + 'CREATE INDEX idx_offline_watch_progress_server ON offline_watch_progress (server_id)', + ); + late final Index idxOfflineWatchProgressProfile = Index( + 'idx_offline_watch_progress_profile', + 'CREATE INDEX idx_offline_watch_progress_profile ON offline_watch_progress (profile_id)', + ); + late final Index idxSyncRulesProfile = Index( + 'idx_sync_rules_profile', + 'CREATE INDEX idx_sync_rules_profile ON sync_rules (profile_id)', + ); + late final Index idxConnectionsKind = Index( + 'idx_connections_kind', + 'CREATE INDEX idx_connections_kind ON connections (kind)', + ); + late final Index idxProfilesKind = Index( + 'idx_profiles_kind', + 'CREATE INDEX idx_profiles_kind ON profiles (kind)', + ); + late final Index idxProfileConnectionsConnectionId = Index( + 'idx_profile_connections_connection_id', + 'CREATE INDEX idx_profile_connections_connection_id ON profile_connections (connection_id)', + ); + late final Index idxProfileConnectionsProfileId = Index( + 'idx_profile_connections_profile_id', + 'CREATE INDEX idx_profile_connections_profile_id ON profile_connections (profile_id)', + ); @override - Iterable> get allTables => allSchemaEntities.whereType>(); + Iterable> get allTables => + allSchemaEntities.whereType>(); @override List get allSchemaEntities => [ downloadedMedia, + downloadOwners, downloadQueue, apiCache, offlineWatchProgress, syncRules, + connections, + profiles, + profileConnections, idxDownloadedMediaStatus, idxDownloadedMediaServer, idxDownloadedMediaParent, idxDownloadedMediaGrandparent, + idxDownloadOwnersProfile, + idxDownloadOwnersGlobalKey, + idxOfflineWatchProgressServer, + idxOfflineWatchProgressProfile, + idxSyncRulesProfile, + idxConnectionsKind, + idxProfilesKind, + idxProfileConnectionsConnectionId, + idxProfileConnectionsProfileId, ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'connections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('profile_connections', kind: UpdateKind.delete)], + ), + ]); } typedef $$DownloadedMediaTableCreateCompanionBuilder = DownloadedMediaCompanion Function({ Value id, required String serverId, + Value clientScopeId, required String ratingKey, required String globalKey, required String type, @@ -2711,6 +5326,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder = DownloadedMediaCompanion Function({ Value id, Value serverId, + Value clientScopeId, Value ratingKey, Value globalKey, Value type, @@ -2729,7 +5345,8 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder = Value mediaIndex, }); -class $$DownloadedMediaTableFilterComposer extends Composer<_$AppDatabase, $DownloadedMediaTable> { +class $$DownloadedMediaTableFilterComposer + extends Composer<_$AppDatabase, $DownloadedMediaTable> { $$DownloadedMediaTableFilterComposer({ required super.$db, required super.$table, @@ -2737,60 +5354,104 @@ class $$DownloadedMediaTableFilterComposer extends Composer<_$AppDatabase, $Down super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get serverId => - $composableBuilder(column: $table.serverId, builder: (column) => ColumnFilters(column)); + ColumnFilters get serverId => $composableBuilder( + column: $table.serverId, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get ratingKey => - $composableBuilder(column: $table.ratingKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get clientScopeId => $composableBuilder( + column: $table.clientScopeId, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get globalKey => - $composableBuilder(column: $table.globalKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get ratingKey => $composableBuilder( + column: $table.ratingKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); + ColumnFilters get globalKey => $composableBuilder( + column: $table.globalKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get parentRatingKey => - $composableBuilder(column: $table.parentRatingKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get grandparentRatingKey => - $composableBuilder(column: $table.grandparentRatingKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get parentRatingKey => $composableBuilder( + column: $table.parentRatingKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get status => - $composableBuilder(column: $table.status, builder: (column) => ColumnFilters(column)); + ColumnFilters get grandparentRatingKey => $composableBuilder( + column: $table.grandparentRatingKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get progress => - $composableBuilder(column: $table.progress, builder: (column) => ColumnFilters(column)); + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get totalBytes => - $composableBuilder(column: $table.totalBytes, builder: (column) => ColumnFilters(column)); + ColumnFilters get progress => $composableBuilder( + column: $table.progress, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get downloadedBytes => - $composableBuilder(column: $table.downloadedBytes, builder: (column) => ColumnFilters(column)); + ColumnFilters get totalBytes => $composableBuilder( + column: $table.totalBytes, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get videoFilePath => - $composableBuilder(column: $table.videoFilePath, builder: (column) => ColumnFilters(column)); + ColumnFilters get downloadedBytes => $composableBuilder( + column: $table.downloadedBytes, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get thumbPath => - $composableBuilder(column: $table.thumbPath, builder: (column) => ColumnFilters(column)); + ColumnFilters get videoFilePath => $composableBuilder( + column: $table.videoFilePath, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get downloadedAt => - $composableBuilder(column: $table.downloadedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get thumbPath => $composableBuilder( + column: $table.thumbPath, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get errorMessage => - $composableBuilder(column: $table.errorMessage, builder: (column) => ColumnFilters(column)); + ColumnFilters get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get retryCount => - $composableBuilder(column: $table.retryCount, builder: (column) => ColumnFilters(column)); + ColumnFilters get errorMessage => $composableBuilder( + column: $table.errorMessage, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get bgTaskId => - $composableBuilder(column: $table.bgTaskId, builder: (column) => ColumnFilters(column)); + ColumnFilters get retryCount => $composableBuilder( + column: $table.retryCount, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get mediaIndex => - $composableBuilder(column: $table.mediaIndex, builder: (column) => ColumnFilters(column)); + ColumnFilters get bgTaskId => $composableBuilder( + column: $table.bgTaskId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get mediaIndex => $composableBuilder( + column: $table.mediaIndex, + builder: (column) => ColumnFilters(column), + ); } -class $$DownloadedMediaTableOrderingComposer extends Composer<_$AppDatabase, $DownloadedMediaTable> { +class $$DownloadedMediaTableOrderingComposer + extends Composer<_$AppDatabase, $DownloadedMediaTable> { $$DownloadedMediaTableOrderingComposer({ required super.$db, required super.$table, @@ -2798,61 +5459,104 @@ class $$DownloadedMediaTableOrderingComposer extends Composer<_$AppDatabase, $Do super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get serverId => - $composableBuilder(column: $table.serverId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get serverId => $composableBuilder( + column: $table.serverId, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get ratingKey => - $composableBuilder(column: $table.ratingKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get clientScopeId => $composableBuilder( + column: $table.clientScopeId, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get globalKey => - $composableBuilder(column: $table.globalKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get ratingKey => $composableBuilder( + column: $table.ratingKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get type => - $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get globalKey => $composableBuilder( + column: $table.globalKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get parentRatingKey => - $composableBuilder(column: $table.parentRatingKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get type => $composableBuilder( + column: $table.type, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get grandparentRatingKey => - $composableBuilder(column: $table.grandparentRatingKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get parentRatingKey => $composableBuilder( + column: $table.parentRatingKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get status => - $composableBuilder(column: $table.status, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get grandparentRatingKey => $composableBuilder( + column: $table.grandparentRatingKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get progress => - $composableBuilder(column: $table.progress, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get totalBytes => - $composableBuilder(column: $table.totalBytes, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get progress => $composableBuilder( + column: $table.progress, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get downloadedBytes => - $composableBuilder(column: $table.downloadedBytes, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get totalBytes => $composableBuilder( + column: $table.totalBytes, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get videoFilePath => - $composableBuilder(column: $table.videoFilePath, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get downloadedBytes => $composableBuilder( + column: $table.downloadedBytes, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get thumbPath => - $composableBuilder(column: $table.thumbPath, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get videoFilePath => $composableBuilder( + column: $table.videoFilePath, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get downloadedAt => - $composableBuilder(column: $table.downloadedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get thumbPath => $composableBuilder( + column: $table.thumbPath, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get errorMessage => - $composableBuilder(column: $table.errorMessage, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get retryCount => - $composableBuilder(column: $table.retryCount, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get errorMessage => $composableBuilder( + column: $table.errorMessage, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get bgTaskId => - $composableBuilder(column: $table.bgTaskId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get retryCount => $composableBuilder( + column: $table.retryCount, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get mediaIndex => - $composableBuilder(column: $table.mediaIndex, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get bgTaskId => $composableBuilder( + column: $table.bgTaskId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get mediaIndex => $composableBuilder( + column: $table.mediaIndex, + builder: (column) => ColumnOrderings(column), + ); } -class $$DownloadedMediaTableAnnotationComposer extends Composer<_$AppDatabase, $DownloadedMediaTable> { +class $$DownloadedMediaTableAnnotationComposer + extends Composer<_$AppDatabase, $DownloadedMediaTable> { $$DownloadedMediaTableAnnotationComposer({ required super.$db, required super.$table, @@ -2860,46 +5564,82 @@ class $$DownloadedMediaTableAnnotationComposer extends Composer<_$AppDatabase, $ super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get serverId => $composableBuilder(column: $table.serverId, builder: (column) => column); + GeneratedColumn get serverId => + $composableBuilder(column: $table.serverId, builder: (column) => column); - GeneratedColumn get ratingKey => $composableBuilder(column: $table.ratingKey, builder: (column) => column); + GeneratedColumn get clientScopeId => $composableBuilder( + column: $table.clientScopeId, + builder: (column) => column, + ); - GeneratedColumn get globalKey => $composableBuilder(column: $table.globalKey, builder: (column) => column); + GeneratedColumn get ratingKey => + $composableBuilder(column: $table.ratingKey, builder: (column) => column); - GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); + GeneratedColumn get globalKey => + $composableBuilder(column: $table.globalKey, builder: (column) => column); - GeneratedColumn get parentRatingKey => - $composableBuilder(column: $table.parentRatingKey, builder: (column) => column); + GeneratedColumn get type => + $composableBuilder(column: $table.type, builder: (column) => column); - GeneratedColumn get grandparentRatingKey => - $composableBuilder(column: $table.grandparentRatingKey, builder: (column) => column); + GeneratedColumn get parentRatingKey => $composableBuilder( + column: $table.parentRatingKey, + builder: (column) => column, + ); - GeneratedColumn get status => $composableBuilder(column: $table.status, builder: (column) => column); + GeneratedColumn get grandparentRatingKey => $composableBuilder( + column: $table.grandparentRatingKey, + builder: (column) => column, + ); - GeneratedColumn get progress => $composableBuilder(column: $table.progress, builder: (column) => column); + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); - GeneratedColumn get totalBytes => $composableBuilder(column: $table.totalBytes, builder: (column) => column); + GeneratedColumn get progress => + $composableBuilder(column: $table.progress, builder: (column) => column); - GeneratedColumn get downloadedBytes => - $composableBuilder(column: $table.downloadedBytes, builder: (column) => column); + GeneratedColumn get totalBytes => $composableBuilder( + column: $table.totalBytes, + builder: (column) => column, + ); - GeneratedColumn get videoFilePath => - $composableBuilder(column: $table.videoFilePath, builder: (column) => column); + GeneratedColumn get downloadedBytes => $composableBuilder( + column: $table.downloadedBytes, + builder: (column) => column, + ); - GeneratedColumn get thumbPath => $composableBuilder(column: $table.thumbPath, builder: (column) => column); + GeneratedColumn get videoFilePath => $composableBuilder( + column: $table.videoFilePath, + builder: (column) => column, + ); - GeneratedColumn get downloadedAt => $composableBuilder(column: $table.downloadedAt, builder: (column) => column); + GeneratedColumn get thumbPath => + $composableBuilder(column: $table.thumbPath, builder: (column) => column); - GeneratedColumn get errorMessage => - $composableBuilder(column: $table.errorMessage, builder: (column) => column); + GeneratedColumn get downloadedAt => $composableBuilder( + column: $table.downloadedAt, + builder: (column) => column, + ); - GeneratedColumn get retryCount => $composableBuilder(column: $table.retryCount, builder: (column) => column); + GeneratedColumn get errorMessage => $composableBuilder( + column: $table.errorMessage, + builder: (column) => column, + ); - GeneratedColumn get bgTaskId => $composableBuilder(column: $table.bgTaskId, builder: (column) => column); + GeneratedColumn get retryCount => $composableBuilder( + column: $table.retryCount, + builder: (column) => column, + ); - GeneratedColumn get mediaIndex => $composableBuilder(column: $table.mediaIndex, builder: (column) => column); + GeneratedColumn get bgTaskId => + $composableBuilder(column: $table.bgTaskId, builder: (column) => column); + + GeneratedColumn get mediaIndex => $composableBuilder( + column: $table.mediaIndex, + builder: (column) => column, + ); } class $$DownloadedMediaTableTableManager @@ -2913,22 +5653,35 @@ class $$DownloadedMediaTableTableManager $$DownloadedMediaTableAnnotationComposer, $$DownloadedMediaTableCreateCompanionBuilder, $$DownloadedMediaTableUpdateCompanionBuilder, - (DownloadedMediaItem, BaseReferences<_$AppDatabase, $DownloadedMediaTable, DownloadedMediaItem>), + ( + DownloadedMediaItem, + BaseReferences< + _$AppDatabase, + $DownloadedMediaTable, + DownloadedMediaItem + >, + ), DownloadedMediaItem, PrefetchHooks Function() > { - $$DownloadedMediaTableTableManager(_$AppDatabase db, $DownloadedMediaTable table) - : super( + $$DownloadedMediaTableTableManager( + _$AppDatabase db, + $DownloadedMediaTable table, + ) : super( TableManagerState( db: db, table: table, - createFilteringComposer: () => $$DownloadedMediaTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => $$DownloadedMediaTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => $$DownloadedMediaTableAnnotationComposer($db: db, $table: table), + createFilteringComposer: () => + $$DownloadedMediaTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$DownloadedMediaTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$DownloadedMediaTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value serverId = const Value.absent(), + Value clientScopeId = const Value.absent(), Value ratingKey = const Value.absent(), Value globalKey = const Value.absent(), Value type = const Value.absent(), @@ -2948,6 +5701,7 @@ class $$DownloadedMediaTableTableManager }) => DownloadedMediaCompanion( id: id, serverId: serverId, + clientScopeId: clientScopeId, ratingKey: ratingKey, globalKey: globalKey, type: type, @@ -2969,6 +5723,7 @@ class $$DownloadedMediaTableTableManager ({ Value id = const Value.absent(), required String serverId, + Value clientScopeId = const Value.absent(), required String ratingKey, required String globalKey, required String type, @@ -2988,6 +5743,7 @@ class $$DownloadedMediaTableTableManager }) => DownloadedMediaCompanion.insert( id: id, serverId: serverId, + clientScopeId: clientScopeId, ratingKey: ratingKey, globalKey: globalKey, type: type, @@ -3005,7 +5761,9 @@ class $$DownloadedMediaTableTableManager bgTaskId: bgTaskId, mediaIndex: mediaIndex, ), - withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); @@ -3021,10 +5779,185 @@ typedef $$DownloadedMediaTableProcessedTableManager = $$DownloadedMediaTableAnnotationComposer, $$DownloadedMediaTableCreateCompanionBuilder, $$DownloadedMediaTableUpdateCompanionBuilder, - (DownloadedMediaItem, BaseReferences<_$AppDatabase, $DownloadedMediaTable, DownloadedMediaItem>), + ( + DownloadedMediaItem, + BaseReferences< + _$AppDatabase, + $DownloadedMediaTable, + DownloadedMediaItem + >, + ), DownloadedMediaItem, PrefetchHooks Function() >; +typedef $$DownloadOwnersTableCreateCompanionBuilder = + DownloadOwnersCompanion Function({ + required String profileId, + required String globalKey, + required int createdAt, + Value rowid, + }); +typedef $$DownloadOwnersTableUpdateCompanionBuilder = + DownloadOwnersCompanion Function({ + Value profileId, + Value globalKey, + Value createdAt, + Value rowid, + }); + +class $$DownloadOwnersTableFilterComposer + extends Composer<_$AppDatabase, $DownloadOwnersTable> { + $$DownloadOwnersTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get globalKey => $composableBuilder( + column: $table.globalKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$DownloadOwnersTableOrderingComposer + extends Composer<_$AppDatabase, $DownloadOwnersTable> { + $$DownloadOwnersTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get globalKey => $composableBuilder( + column: $table.globalKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$DownloadOwnersTableAnnotationComposer + extends Composer<_$AppDatabase, $DownloadOwnersTable> { + $$DownloadOwnersTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get profileId => + $composableBuilder(column: $table.profileId, builder: (column) => column); + + GeneratedColumn get globalKey => + $composableBuilder(column: $table.globalKey, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$DownloadOwnersTableTableManager + extends + RootTableManager< + _$AppDatabase, + $DownloadOwnersTable, + DownloadOwnerItem, + $$DownloadOwnersTableFilterComposer, + $$DownloadOwnersTableOrderingComposer, + $$DownloadOwnersTableAnnotationComposer, + $$DownloadOwnersTableCreateCompanionBuilder, + $$DownloadOwnersTableUpdateCompanionBuilder, + ( + DownloadOwnerItem, + BaseReferences< + _$AppDatabase, + $DownloadOwnersTable, + DownloadOwnerItem + >, + ), + DownloadOwnerItem, + PrefetchHooks Function() + > { + $$DownloadOwnersTableTableManager( + _$AppDatabase db, + $DownloadOwnersTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$DownloadOwnersTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$DownloadOwnersTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$DownloadOwnersTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value profileId = const Value.absent(), + Value globalKey = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => DownloadOwnersCompanion( + profileId: profileId, + globalKey: globalKey, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String profileId, + required String globalKey, + required int createdAt, + Value rowid = const Value.absent(), + }) => DownloadOwnersCompanion.insert( + profileId: profileId, + globalKey: globalKey, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$DownloadOwnersTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $DownloadOwnersTable, + DownloadOwnerItem, + $$DownloadOwnersTableFilterComposer, + $$DownloadOwnersTableOrderingComposer, + $$DownloadOwnersTableAnnotationComposer, + $$DownloadOwnersTableCreateCompanionBuilder, + $$DownloadOwnersTableUpdateCompanionBuilder, + ( + DownloadOwnerItem, + BaseReferences<_$AppDatabase, $DownloadOwnersTable, DownloadOwnerItem>, + ), + DownloadOwnerItem, + PrefetchHooks Function() + >; typedef $$DownloadQueueTableCreateCompanionBuilder = DownloadQueueCompanion Function({ Value id, @@ -3044,7 +5977,8 @@ typedef $$DownloadQueueTableUpdateCompanionBuilder = Value downloadArtwork, }); -class $$DownloadQueueTableFilterComposer extends Composer<_$AppDatabase, $DownloadQueueTable> { +class $$DownloadQueueTableFilterComposer + extends Composer<_$AppDatabase, $DownloadQueueTable> { $$DownloadQueueTableFilterComposer({ required super.$db, required super.$table, @@ -3052,25 +5986,39 @@ class $$DownloadQueueTableFilterComposer extends Composer<_$AppDatabase, $Downlo super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get mediaGlobalKey => - $composableBuilder(column: $table.mediaGlobalKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get mediaGlobalKey => $composableBuilder( + column: $table.mediaGlobalKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get priority => - $composableBuilder(column: $table.priority, builder: (column) => ColumnFilters(column)); + ColumnFilters get priority => $composableBuilder( + column: $table.priority, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get addedAt => - $composableBuilder(column: $table.addedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get addedAt => $composableBuilder( + column: $table.addedAt, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get downloadSubtitles => - $composableBuilder(column: $table.downloadSubtitles, builder: (column) => ColumnFilters(column)); + ColumnFilters get downloadSubtitles => $composableBuilder( + column: $table.downloadSubtitles, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get downloadArtwork => - $composableBuilder(column: $table.downloadArtwork, builder: (column) => ColumnFilters(column)); + ColumnFilters get downloadArtwork => $composableBuilder( + column: $table.downloadArtwork, + builder: (column) => ColumnFilters(column), + ); } -class $$DownloadQueueTableOrderingComposer extends Composer<_$AppDatabase, $DownloadQueueTable> { +class $$DownloadQueueTableOrderingComposer + extends Composer<_$AppDatabase, $DownloadQueueTable> { $$DownloadQueueTableOrderingComposer({ required super.$db, required super.$table, @@ -3078,25 +6026,39 @@ class $$DownloadQueueTableOrderingComposer extends Composer<_$AppDatabase, $Down super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get mediaGlobalKey => - $composableBuilder(column: $table.mediaGlobalKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get mediaGlobalKey => $composableBuilder( + column: $table.mediaGlobalKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get priority => - $composableBuilder(column: $table.priority, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get priority => $composableBuilder( + column: $table.priority, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get addedAt => - $composableBuilder(column: $table.addedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get addedAt => $composableBuilder( + column: $table.addedAt, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get downloadSubtitles => - $composableBuilder(column: $table.downloadSubtitles, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get downloadSubtitles => $composableBuilder( + column: $table.downloadSubtitles, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get downloadArtwork => - $composableBuilder(column: $table.downloadArtwork, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get downloadArtwork => $composableBuilder( + column: $table.downloadArtwork, + builder: (column) => ColumnOrderings(column), + ); } -class $$DownloadQueueTableAnnotationComposer extends Composer<_$AppDatabase, $DownloadQueueTable> { +class $$DownloadQueueTableAnnotationComposer + extends Composer<_$AppDatabase, $DownloadQueueTable> { $$DownloadQueueTableAnnotationComposer({ required super.$db, required super.$table, @@ -3104,20 +6066,29 @@ class $$DownloadQueueTableAnnotationComposer extends Composer<_$AppDatabase, $Do super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get mediaGlobalKey => - $composableBuilder(column: $table.mediaGlobalKey, builder: (column) => column); + GeneratedColumn get mediaGlobalKey => $composableBuilder( + column: $table.mediaGlobalKey, + builder: (column) => column, + ); - GeneratedColumn get priority => $composableBuilder(column: $table.priority, builder: (column) => column); + GeneratedColumn get priority => + $composableBuilder(column: $table.priority, builder: (column) => column); - GeneratedColumn get addedAt => $composableBuilder(column: $table.addedAt, builder: (column) => column); + GeneratedColumn get addedAt => + $composableBuilder(column: $table.addedAt, builder: (column) => column); - GeneratedColumn get downloadSubtitles => - $composableBuilder(column: $table.downloadSubtitles, builder: (column) => column); + GeneratedColumn get downloadSubtitles => $composableBuilder( + column: $table.downloadSubtitles, + builder: (column) => column, + ); - GeneratedColumn get downloadArtwork => - $composableBuilder(column: $table.downloadArtwork, builder: (column) => column); + GeneratedColumn get downloadArtwork => $composableBuilder( + column: $table.downloadArtwork, + builder: (column) => column, + ); } class $$DownloadQueueTableTableManager @@ -3131,7 +6102,14 @@ class $$DownloadQueueTableTableManager $$DownloadQueueTableAnnotationComposer, $$DownloadQueueTableCreateCompanionBuilder, $$DownloadQueueTableUpdateCompanionBuilder, - (DownloadQueueItem, BaseReferences<_$AppDatabase, $DownloadQueueTable, DownloadQueueItem>), + ( + DownloadQueueItem, + BaseReferences< + _$AppDatabase, + $DownloadQueueTable, + DownloadQueueItem + >, + ), DownloadQueueItem, PrefetchHooks Function() > { @@ -3140,9 +6118,12 @@ class $$DownloadQueueTableTableManager TableManagerState( db: db, table: table, - createFilteringComposer: () => $$DownloadQueueTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => $$DownloadQueueTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => $$DownloadQueueTableAnnotationComposer($db: db, $table: table), + createFilteringComposer: () => + $$DownloadQueueTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$DownloadQueueTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$DownloadQueueTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), @@ -3175,7 +6156,9 @@ class $$DownloadQueueTableTableManager downloadSubtitles: downloadSubtitles, downloadArtwork: downloadArtwork, ), - withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); @@ -3191,7 +6174,10 @@ typedef $$DownloadQueueTableProcessedTableManager = $$DownloadQueueTableAnnotationComposer, $$DownloadQueueTableCreateCompanionBuilder, $$DownloadQueueTableUpdateCompanionBuilder, - (DownloadQueueItem, BaseReferences<_$AppDatabase, $DownloadQueueTable, DownloadQueueItem>), + ( + DownloadQueueItem, + BaseReferences<_$AppDatabase, $DownloadQueueTable, DownloadQueueItem>, + ), DownloadQueueItem, PrefetchHooks Function() >; @@ -3212,7 +6198,8 @@ typedef $$ApiCacheTableUpdateCompanionBuilder = Value rowid, }); -class $$ApiCacheTableFilterComposer extends Composer<_$AppDatabase, $ApiCacheTable> { +class $$ApiCacheTableFilterComposer + extends Composer<_$AppDatabase, $ApiCacheTable> { $$ApiCacheTableFilterComposer({ required super.$db, required super.$table, @@ -3220,19 +6207,29 @@ class $$ApiCacheTableFilterComposer extends Composer<_$AppDatabase, $ApiCacheTab super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get cacheKey => - $composableBuilder(column: $table.cacheKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get cacheKey => $composableBuilder( + column: $table.cacheKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get data => $composableBuilder(column: $table.data, builder: (column) => ColumnFilters(column)); + ColumnFilters get data => $composableBuilder( + column: $table.data, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get pinned => - $composableBuilder(column: $table.pinned, builder: (column) => ColumnFilters(column)); + ColumnFilters get pinned => $composableBuilder( + column: $table.pinned, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get cachedAt => - $composableBuilder(column: $table.cachedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); } -class $$ApiCacheTableOrderingComposer extends Composer<_$AppDatabase, $ApiCacheTable> { +class $$ApiCacheTableOrderingComposer + extends Composer<_$AppDatabase, $ApiCacheTable> { $$ApiCacheTableOrderingComposer({ required super.$db, required super.$table, @@ -3240,20 +6237,29 @@ class $$ApiCacheTableOrderingComposer extends Composer<_$AppDatabase, $ApiCacheT super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get cacheKey => - $composableBuilder(column: $table.cacheKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get cacheKey => $composableBuilder( + column: $table.cacheKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get data => - $composableBuilder(column: $table.data, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get data => $composableBuilder( + column: $table.data, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get pinned => - $composableBuilder(column: $table.pinned, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinned => $composableBuilder( + column: $table.pinned, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get cachedAt => - $composableBuilder(column: $table.cachedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); } -class $$ApiCacheTableAnnotationComposer extends Composer<_$AppDatabase, $ApiCacheTable> { +class $$ApiCacheTableAnnotationComposer + extends Composer<_$AppDatabase, $ApiCacheTable> { $$ApiCacheTableAnnotationComposer({ required super.$db, required super.$table, @@ -3261,13 +6267,17 @@ class $$ApiCacheTableAnnotationComposer extends Composer<_$AppDatabase, $ApiCach super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get cacheKey => $composableBuilder(column: $table.cacheKey, builder: (column) => column); + GeneratedColumn get cacheKey => + $composableBuilder(column: $table.cacheKey, builder: (column) => column); - GeneratedColumn get data => $composableBuilder(column: $table.data, builder: (column) => column); + GeneratedColumn get data => + $composableBuilder(column: $table.data, builder: (column) => column); - GeneratedColumn get pinned => $composableBuilder(column: $table.pinned, builder: (column) => column); + GeneratedColumn get pinned => + $composableBuilder(column: $table.pinned, builder: (column) => column); - GeneratedColumn get cachedAt => $composableBuilder(column: $table.cachedAt, builder: (column) => column); + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); } class $$ApiCacheTableTableManager @@ -3281,7 +6291,10 @@ class $$ApiCacheTableTableManager $$ApiCacheTableAnnotationComposer, $$ApiCacheTableCreateCompanionBuilder, $$ApiCacheTableUpdateCompanionBuilder, - (ApiCacheData, BaseReferences<_$AppDatabase, $ApiCacheTable, ApiCacheData>), + ( + ApiCacheData, + BaseReferences<_$AppDatabase, $ApiCacheTable, ApiCacheData>, + ), ApiCacheData, PrefetchHooks Function() > { @@ -3290,9 +6303,12 @@ class $$ApiCacheTableTableManager TableManagerState( db: db, table: table, - createFilteringComposer: () => $$ApiCacheTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => $$ApiCacheTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => $$ApiCacheTableAnnotationComposer($db: db, $table: table), + createFilteringComposer: () => + $$ApiCacheTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ApiCacheTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ApiCacheTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value cacheKey = const Value.absent(), @@ -3300,7 +6316,13 @@ class $$ApiCacheTableTableManager Value pinned = const Value.absent(), Value cachedAt = const Value.absent(), Value rowid = const Value.absent(), - }) => ApiCacheCompanion(cacheKey: cacheKey, data: data, pinned: pinned, cachedAt: cachedAt, rowid: rowid), + }) => ApiCacheCompanion( + cacheKey: cacheKey, + data: data, + pinned: pinned, + cachedAt: cachedAt, + rowid: rowid, + ), createCompanionCallback: ({ required String cacheKey, @@ -3315,7 +6337,9 @@ class $$ApiCacheTableTableManager cachedAt: cachedAt, rowid: rowid, ), - withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); @@ -3331,14 +6355,19 @@ typedef $$ApiCacheTableProcessedTableManager = $$ApiCacheTableAnnotationComposer, $$ApiCacheTableCreateCompanionBuilder, $$ApiCacheTableUpdateCompanionBuilder, - (ApiCacheData, BaseReferences<_$AppDatabase, $ApiCacheTable, ApiCacheData>), + ( + ApiCacheData, + BaseReferences<_$AppDatabase, $ApiCacheTable, ApiCacheData>, + ), ApiCacheData, PrefetchHooks Function() >; typedef $$OfflineWatchProgressTableCreateCompanionBuilder = OfflineWatchProgressCompanion Function({ Value id, + Value profileId, required String serverId, + Value clientScopeId, required String ratingKey, required String globalKey, required String actionType, @@ -3353,7 +6382,9 @@ typedef $$OfflineWatchProgressTableCreateCompanionBuilder = typedef $$OfflineWatchProgressTableUpdateCompanionBuilder = OfflineWatchProgressCompanion Function({ Value id, + Value profileId, Value serverId, + Value clientScopeId, Value ratingKey, Value globalKey, Value actionType, @@ -3366,7 +6397,8 @@ typedef $$OfflineWatchProgressTableUpdateCompanionBuilder = Value lastError, }); -class $$OfflineWatchProgressTableFilterComposer extends Composer<_$AppDatabase, $OfflineWatchProgressTable> { +class $$OfflineWatchProgressTableFilterComposer + extends Composer<_$AppDatabase, $OfflineWatchProgressTable> { $$OfflineWatchProgressTableFilterComposer({ required super.$db, required super.$table, @@ -3374,43 +6406,79 @@ class $$OfflineWatchProgressTableFilterComposer extends Composer<_$AppDatabase, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get serverId => - $composableBuilder(column: $table.serverId, builder: (column) => ColumnFilters(column)); + ColumnFilters get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get ratingKey => - $composableBuilder(column: $table.ratingKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get serverId => $composableBuilder( + column: $table.serverId, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get globalKey => - $composableBuilder(column: $table.globalKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get clientScopeId => $composableBuilder( + column: $table.clientScopeId, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get actionType => - $composableBuilder(column: $table.actionType, builder: (column) => ColumnFilters(column)); + ColumnFilters get ratingKey => $composableBuilder( + column: $table.ratingKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get viewOffset => - $composableBuilder(column: $table.viewOffset, builder: (column) => ColumnFilters(column)); + ColumnFilters get globalKey => $composableBuilder( + column: $table.globalKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get duration => - $composableBuilder(column: $table.duration, builder: (column) => ColumnFilters(column)); + ColumnFilters get actionType => $composableBuilder( + column: $table.actionType, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get shouldMarkWatched => - $composableBuilder(column: $table.shouldMarkWatched, builder: (column) => ColumnFilters(column)); + ColumnFilters get viewOffset => $composableBuilder( + column: $table.viewOffset, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get duration => $composableBuilder( + column: $table.duration, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get updatedAt => - $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get shouldMarkWatched => $composableBuilder( + column: $table.shouldMarkWatched, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get syncAttempts => - $composableBuilder(column: $table.syncAttempts, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get lastError => - $composableBuilder(column: $table.lastError, builder: (column) => ColumnFilters(column)); + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncAttempts => $composableBuilder( + column: $table.syncAttempts, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnFilters(column), + ); } -class $$OfflineWatchProgressTableOrderingComposer extends Composer<_$AppDatabase, $OfflineWatchProgressTable> { +class $$OfflineWatchProgressTableOrderingComposer + extends Composer<_$AppDatabase, $OfflineWatchProgressTable> { $$OfflineWatchProgressTableOrderingComposer({ required super.$db, required super.$table, @@ -3418,43 +6486,79 @@ class $$OfflineWatchProgressTableOrderingComposer extends Composer<_$AppDatabase super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get serverId => - $composableBuilder(column: $table.serverId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get ratingKey => - $composableBuilder(column: $table.ratingKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get serverId => $composableBuilder( + column: $table.serverId, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get globalKey => - $composableBuilder(column: $table.globalKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get clientScopeId => $composableBuilder( + column: $table.clientScopeId, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get actionType => - $composableBuilder(column: $table.actionType, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get ratingKey => $composableBuilder( + column: $table.ratingKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get viewOffset => - $composableBuilder(column: $table.viewOffset, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get globalKey => $composableBuilder( + column: $table.globalKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get duration => - $composableBuilder(column: $table.duration, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get actionType => $composableBuilder( + column: $table.actionType, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get shouldMarkWatched => - $composableBuilder(column: $table.shouldMarkWatched, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get viewOffset => $composableBuilder( + column: $table.viewOffset, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get duration => $composableBuilder( + column: $table.duration, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get updatedAt => - $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get shouldMarkWatched => $composableBuilder( + column: $table.shouldMarkWatched, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get syncAttempts => - $composableBuilder(column: $table.syncAttempts, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get lastError => - $composableBuilder(column: $table.lastError, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get syncAttempts => $composableBuilder( + column: $table.syncAttempts, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnOrderings(column), + ); } -class $$OfflineWatchProgressTableAnnotationComposer extends Composer<_$AppDatabase, $OfflineWatchProgressTable> { +class $$OfflineWatchProgressTableAnnotationComposer + extends Composer<_$AppDatabase, $OfflineWatchProgressTable> { $$OfflineWatchProgressTableAnnotationComposer({ required super.$db, required super.$table, @@ -3462,30 +6566,57 @@ class $$OfflineWatchProgressTableAnnotationComposer extends Composer<_$AppDataba super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get serverId => $composableBuilder(column: $table.serverId, builder: (column) => column); + GeneratedColumn get profileId => + $composableBuilder(column: $table.profileId, builder: (column) => column); - GeneratedColumn get ratingKey => $composableBuilder(column: $table.ratingKey, builder: (column) => column); + GeneratedColumn get serverId => + $composableBuilder(column: $table.serverId, builder: (column) => column); - GeneratedColumn get globalKey => $composableBuilder(column: $table.globalKey, builder: (column) => column); + GeneratedColumn get clientScopeId => $composableBuilder( + column: $table.clientScopeId, + builder: (column) => column, + ); - GeneratedColumn get actionType => $composableBuilder(column: $table.actionType, builder: (column) => column); + GeneratedColumn get ratingKey => + $composableBuilder(column: $table.ratingKey, builder: (column) => column); - GeneratedColumn get viewOffset => $composableBuilder(column: $table.viewOffset, builder: (column) => column); + GeneratedColumn get globalKey => + $composableBuilder(column: $table.globalKey, builder: (column) => column); - GeneratedColumn get duration => $composableBuilder(column: $table.duration, builder: (column) => column); + GeneratedColumn get actionType => $composableBuilder( + column: $table.actionType, + builder: (column) => column, + ); - GeneratedColumn get shouldMarkWatched => - $composableBuilder(column: $table.shouldMarkWatched, builder: (column) => column); + GeneratedColumn get viewOffset => $composableBuilder( + column: $table.viewOffset, + builder: (column) => column, + ); - GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get duration => + $composableBuilder(column: $table.duration, builder: (column) => column); - GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); + GeneratedColumn get shouldMarkWatched => $composableBuilder( + column: $table.shouldMarkWatched, + builder: (column) => column, + ); - GeneratedColumn get syncAttempts => $composableBuilder(column: $table.syncAttempts, builder: (column) => column); + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get lastError => $composableBuilder(column: $table.lastError, builder: (column) => column); + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get syncAttempts => $composableBuilder( + column: $table.syncAttempts, + builder: (column) => column, + ); + + GeneratedColumn get lastError => + $composableBuilder(column: $table.lastError, builder: (column) => column); } class $$OfflineWatchProgressTableTableManager @@ -3501,23 +6632,40 @@ class $$OfflineWatchProgressTableTableManager $$OfflineWatchProgressTableUpdateCompanionBuilder, ( OfflineWatchProgressItem, - BaseReferences<_$AppDatabase, $OfflineWatchProgressTable, OfflineWatchProgressItem>, + BaseReferences< + _$AppDatabase, + $OfflineWatchProgressTable, + OfflineWatchProgressItem + >, ), OfflineWatchProgressItem, PrefetchHooks Function() > { - $$OfflineWatchProgressTableTableManager(_$AppDatabase db, $OfflineWatchProgressTable table) - : super( + $$OfflineWatchProgressTableTableManager( + _$AppDatabase db, + $OfflineWatchProgressTable table, + ) : super( TableManagerState( db: db, table: table, - createFilteringComposer: () => $$OfflineWatchProgressTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => $$OfflineWatchProgressTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => $$OfflineWatchProgressTableAnnotationComposer($db: db, $table: table), + createFilteringComposer: () => + $$OfflineWatchProgressTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$OfflineWatchProgressTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$OfflineWatchProgressTableAnnotationComposer( + $db: db, + $table: table, + ), updateCompanionCallback: ({ Value id = const Value.absent(), + Value profileId = const Value.absent(), Value serverId = const Value.absent(), + Value clientScopeId = const Value.absent(), Value ratingKey = const Value.absent(), Value globalKey = const Value.absent(), Value actionType = const Value.absent(), @@ -3530,7 +6678,9 @@ class $$OfflineWatchProgressTableTableManager Value lastError = const Value.absent(), }) => OfflineWatchProgressCompanion( id: id, + profileId: profileId, serverId: serverId, + clientScopeId: clientScopeId, ratingKey: ratingKey, globalKey: globalKey, actionType: actionType, @@ -3545,7 +6695,9 @@ class $$OfflineWatchProgressTableTableManager createCompanionCallback: ({ Value id = const Value.absent(), + Value profileId = const Value.absent(), required String serverId, + Value clientScopeId = const Value.absent(), required String ratingKey, required String globalKey, required String actionType, @@ -3558,7 +6710,9 @@ class $$OfflineWatchProgressTableTableManager Value lastError = const Value.absent(), }) => OfflineWatchProgressCompanion.insert( id: id, + profileId: profileId, serverId: serverId, + clientScopeId: clientScopeId, ratingKey: ratingKey, globalKey: globalKey, actionType: actionType, @@ -3570,7 +6724,9 @@ class $$OfflineWatchProgressTableTableManager syncAttempts: syncAttempts, lastError: lastError, ), - withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); @@ -3586,13 +6742,21 @@ typedef $$OfflineWatchProgressTableProcessedTableManager = $$OfflineWatchProgressTableAnnotationComposer, $$OfflineWatchProgressTableCreateCompanionBuilder, $$OfflineWatchProgressTableUpdateCompanionBuilder, - (OfflineWatchProgressItem, BaseReferences<_$AppDatabase, $OfflineWatchProgressTable, OfflineWatchProgressItem>), + ( + OfflineWatchProgressItem, + BaseReferences< + _$AppDatabase, + $OfflineWatchProgressTable, + OfflineWatchProgressItem + >, + ), OfflineWatchProgressItem, PrefetchHooks Function() >; typedef $$SyncRulesTableCreateCompanionBuilder = SyncRulesCompanion Function({ Value id, + Value profileId, required String serverId, required String ratingKey, required String globalKey, @@ -3607,6 +6771,7 @@ typedef $$SyncRulesTableCreateCompanionBuilder = typedef $$SyncRulesTableUpdateCompanionBuilder = SyncRulesCompanion Function({ Value id, + Value profileId, Value serverId, Value ratingKey, Value globalKey, @@ -3619,7 +6784,8 @@ typedef $$SyncRulesTableUpdateCompanionBuilder = Value downloadFilter, }); -class $$SyncRulesTableFilterComposer extends Composer<_$AppDatabase, $SyncRulesTable> { +class $$SyncRulesTableFilterComposer + extends Composer<_$AppDatabase, $SyncRulesTable> { $$SyncRulesTableFilterComposer({ required super.$db, required super.$table, @@ -3627,40 +6793,69 @@ class $$SyncRulesTableFilterComposer extends Composer<_$AppDatabase, $SyncRulesT super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get serverId => - $composableBuilder(column: $table.serverId, builder: (column) => ColumnFilters(column)); + ColumnFilters get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get ratingKey => - $composableBuilder(column: $table.ratingKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get serverId => $composableBuilder( + column: $table.serverId, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get globalKey => - $composableBuilder(column: $table.globalKey, builder: (column) => ColumnFilters(column)); + ColumnFilters get ratingKey => $composableBuilder( + column: $table.ratingKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get targetType => - $composableBuilder(column: $table.targetType, builder: (column) => ColumnFilters(column)); + ColumnFilters get globalKey => $composableBuilder( + column: $table.globalKey, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get episodeCount => - $composableBuilder(column: $table.episodeCount, builder: (column) => ColumnFilters(column)); + ColumnFilters get targetType => $composableBuilder( + column: $table.targetType, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get enabled => - $composableBuilder(column: $table.enabled, builder: (column) => ColumnFilters(column)); + ColumnFilters get episodeCount => $composableBuilder( + column: $table.episodeCount, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get enabled => $composableBuilder( + column: $table.enabled, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get lastExecutedAt => - $composableBuilder(column: $table.lastExecutedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get mediaIndex => - $composableBuilder(column: $table.mediaIndex, builder: (column) => ColumnFilters(column)); + ColumnFilters get lastExecutedAt => $composableBuilder( + column: $table.lastExecutedAt, + builder: (column) => ColumnFilters(column), + ); - ColumnFilters get downloadFilter => - $composableBuilder(column: $table.downloadFilter, builder: (column) => ColumnFilters(column)); + ColumnFilters get mediaIndex => $composableBuilder( + column: $table.mediaIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get downloadFilter => $composableBuilder( + column: $table.downloadFilter, + builder: (column) => ColumnFilters(column), + ); } -class $$SyncRulesTableOrderingComposer extends Composer<_$AppDatabase, $SyncRulesTable> { +class $$SyncRulesTableOrderingComposer + extends Composer<_$AppDatabase, $SyncRulesTable> { $$SyncRulesTableOrderingComposer({ required super.$db, required super.$table, @@ -3668,40 +6863,69 @@ class $$SyncRulesTableOrderingComposer extends Composer<_$AppDatabase, $SyncRule super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get serverId => - $composableBuilder(column: $table.serverId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get ratingKey => - $composableBuilder(column: $table.ratingKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get serverId => $composableBuilder( + column: $table.serverId, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get globalKey => - $composableBuilder(column: $table.globalKey, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get ratingKey => $composableBuilder( + column: $table.ratingKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get targetType => - $composableBuilder(column: $table.targetType, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get globalKey => $composableBuilder( + column: $table.globalKey, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get episodeCount => - $composableBuilder(column: $table.episodeCount, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get targetType => $composableBuilder( + column: $table.targetType, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get enabled => - $composableBuilder(column: $table.enabled, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get episodeCount => $composableBuilder( + column: $table.episodeCount, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get enabled => $composableBuilder( + column: $table.enabled, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get lastExecutedAt => - $composableBuilder(column: $table.lastExecutedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get mediaIndex => - $composableBuilder(column: $table.mediaIndex, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastExecutedAt => $composableBuilder( + column: $table.lastExecutedAt, + builder: (column) => ColumnOrderings(column), + ); - ColumnOrderings get downloadFilter => - $composableBuilder(column: $table.downloadFilter, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get mediaIndex => $composableBuilder( + column: $table.mediaIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get downloadFilter => $composableBuilder( + column: $table.downloadFilter, + builder: (column) => ColumnOrderings(column), + ); } -class $$SyncRulesTableAnnotationComposer extends Composer<_$AppDatabase, $SyncRulesTable> { +class $$SyncRulesTableAnnotationComposer + extends Composer<_$AppDatabase, $SyncRulesTable> { $$SyncRulesTableAnnotationComposer({ required super.$db, required super.$table, @@ -3709,29 +6933,51 @@ class $$SyncRulesTableAnnotationComposer extends Composer<_$AppDatabase, $SyncRu super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get serverId => $composableBuilder(column: $table.serverId, builder: (column) => column); + GeneratedColumn get profileId => + $composableBuilder(column: $table.profileId, builder: (column) => column); - GeneratedColumn get ratingKey => $composableBuilder(column: $table.ratingKey, builder: (column) => column); + GeneratedColumn get serverId => + $composableBuilder(column: $table.serverId, builder: (column) => column); - GeneratedColumn get globalKey => $composableBuilder(column: $table.globalKey, builder: (column) => column); + GeneratedColumn get ratingKey => + $composableBuilder(column: $table.ratingKey, builder: (column) => column); - GeneratedColumn get targetType => $composableBuilder(column: $table.targetType, builder: (column) => column); + GeneratedColumn get globalKey => + $composableBuilder(column: $table.globalKey, builder: (column) => column); - GeneratedColumn get episodeCount => $composableBuilder(column: $table.episodeCount, builder: (column) => column); + GeneratedColumn get targetType => $composableBuilder( + column: $table.targetType, + builder: (column) => column, + ); - GeneratedColumn get enabled => $composableBuilder(column: $table.enabled, builder: (column) => column); + GeneratedColumn get episodeCount => $composableBuilder( + column: $table.episodeCount, + builder: (column) => column, + ); - GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get enabled => + $composableBuilder(column: $table.enabled, builder: (column) => column); - GeneratedColumn get lastExecutedAt => - $composableBuilder(column: $table.lastExecutedAt, builder: (column) => column); + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get mediaIndex => $composableBuilder(column: $table.mediaIndex, builder: (column) => column); + GeneratedColumn get lastExecutedAt => $composableBuilder( + column: $table.lastExecutedAt, + builder: (column) => column, + ); - GeneratedColumn get downloadFilter => - $composableBuilder(column: $table.downloadFilter, builder: (column) => column); + GeneratedColumn get mediaIndex => $composableBuilder( + column: $table.mediaIndex, + builder: (column) => column, + ); + + GeneratedColumn get downloadFilter => $composableBuilder( + column: $table.downloadFilter, + builder: (column) => column, + ); } class $$SyncRulesTableTableManager @@ -3745,7 +6991,10 @@ class $$SyncRulesTableTableManager $$SyncRulesTableAnnotationComposer, $$SyncRulesTableCreateCompanionBuilder, $$SyncRulesTableUpdateCompanionBuilder, - (SyncRuleItem, BaseReferences<_$AppDatabase, $SyncRulesTable, SyncRuleItem>), + ( + SyncRuleItem, + BaseReferences<_$AppDatabase, $SyncRulesTable, SyncRuleItem>, + ), SyncRuleItem, PrefetchHooks Function() > { @@ -3754,12 +7003,16 @@ class $$SyncRulesTableTableManager TableManagerState( db: db, table: table, - createFilteringComposer: () => $$SyncRulesTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => $$SyncRulesTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => $$SyncRulesTableAnnotationComposer($db: db, $table: table), + createFilteringComposer: () => + $$SyncRulesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SyncRulesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SyncRulesTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), + Value profileId = const Value.absent(), Value serverId = const Value.absent(), Value ratingKey = const Value.absent(), Value globalKey = const Value.absent(), @@ -3772,6 +7025,7 @@ class $$SyncRulesTableTableManager Value downloadFilter = const Value.absent(), }) => SyncRulesCompanion( id: id, + profileId: profileId, serverId: serverId, ratingKey: ratingKey, globalKey: globalKey, @@ -3786,6 +7040,7 @@ class $$SyncRulesTableTableManager createCompanionCallback: ({ Value id = const Value.absent(), + Value profileId = const Value.absent(), required String serverId, required String ratingKey, required String globalKey, @@ -3798,6 +7053,7 @@ class $$SyncRulesTableTableManager Value downloadFilter = const Value.absent(), }) => SyncRulesCompanion.insert( id: id, + profileId: profileId, serverId: serverId, ratingKey: ratingKey, globalKey: globalKey, @@ -3809,7 +7065,9 @@ class $$SyncRulesTableTableManager mediaIndex: mediaIndex, downloadFilter: downloadFilter, ), - withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), prefetchHooksCallback: null, ), ); @@ -3825,19 +7083,1036 @@ typedef $$SyncRulesTableProcessedTableManager = $$SyncRulesTableAnnotationComposer, $$SyncRulesTableCreateCompanionBuilder, $$SyncRulesTableUpdateCompanionBuilder, - (SyncRuleItem, BaseReferences<_$AppDatabase, $SyncRulesTable, SyncRuleItem>), + ( + SyncRuleItem, + BaseReferences<_$AppDatabase, $SyncRulesTable, SyncRuleItem>, + ), SyncRuleItem, PrefetchHooks Function() >; +typedef $$ConnectionsTableCreateCompanionBuilder = + ConnectionsCompanion Function({ + required String id, + required String kind, + required String displayName, + required String configJson, + Value isDefault, + required int createdAt, + Value lastAuthenticatedAt, + Value rowid, + }); +typedef $$ConnectionsTableUpdateCompanionBuilder = + ConnectionsCompanion Function({ + Value id, + Value kind, + Value displayName, + Value configJson, + Value isDefault, + Value createdAt, + Value lastAuthenticatedAt, + Value rowid, + }); + +final class $$ConnectionsTableReferences + extends BaseReferences<_$AppDatabase, $ConnectionsTable, ConnectionRow> { + $$ConnectionsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey< + $ProfileConnectionsTable, + List + > + _profileConnectionsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.profileConnections, + aliasName: $_aliasNameGenerator( + db.connections.id, + db.profileConnections.connectionId, + ), + ); + + $$ProfileConnectionsTableProcessedTableManager get profileConnectionsRefs { + final manager = $$ProfileConnectionsTableTableManager( + $_db, + $_db.profileConnections, + ).filter((f) => f.connectionId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _profileConnectionsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$ConnectionsTableFilterComposer + extends Composer<_$AppDatabase, $ConnectionsTable> { + $$ConnectionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get kind => $composableBuilder( + column: $table.kind, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get configJson => $composableBuilder( + column: $table.configJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isDefault => $composableBuilder( + column: $table.isDefault, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastAuthenticatedAt => $composableBuilder( + column: $table.lastAuthenticatedAt, + builder: (column) => ColumnFilters(column), + ); + + Expression profileConnectionsRefs( + Expression Function($$ProfileConnectionsTableFilterComposer f) f, + ) { + final $$ProfileConnectionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.profileConnections, + getReferencedColumn: (t) => t.connectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProfileConnectionsTableFilterComposer( + $db: $db, + $table: $db.profileConnections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$ConnectionsTableOrderingComposer + extends Composer<_$AppDatabase, $ConnectionsTable> { + $$ConnectionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get kind => $composableBuilder( + column: $table.kind, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get configJson => $composableBuilder( + column: $table.configJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isDefault => $composableBuilder( + column: $table.isDefault, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastAuthenticatedAt => $composableBuilder( + column: $table.lastAuthenticatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ConnectionsTableAnnotationComposer + extends Composer<_$AppDatabase, $ConnectionsTable> { + $$ConnectionsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get kind => + $composableBuilder(column: $table.kind, builder: (column) => column); + + GeneratedColumn get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => column, + ); + + GeneratedColumn get configJson => $composableBuilder( + column: $table.configJson, + builder: (column) => column, + ); + + GeneratedColumn get isDefault => + $composableBuilder(column: $table.isDefault, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get lastAuthenticatedAt => $composableBuilder( + column: $table.lastAuthenticatedAt, + builder: (column) => column, + ); + + Expression profileConnectionsRefs( + Expression Function($$ProfileConnectionsTableAnnotationComposer a) f, + ) { + final $$ProfileConnectionsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.profileConnections, + getReferencedColumn: (t) => t.connectionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ProfileConnectionsTableAnnotationComposer( + $db: $db, + $table: $db.profileConnections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$ConnectionsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ConnectionsTable, + ConnectionRow, + $$ConnectionsTableFilterComposer, + $$ConnectionsTableOrderingComposer, + $$ConnectionsTableAnnotationComposer, + $$ConnectionsTableCreateCompanionBuilder, + $$ConnectionsTableUpdateCompanionBuilder, + (ConnectionRow, $$ConnectionsTableReferences), + ConnectionRow, + PrefetchHooks Function({bool profileConnectionsRefs}) + > { + $$ConnectionsTableTableManager(_$AppDatabase db, $ConnectionsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ConnectionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ConnectionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ConnectionsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value kind = const Value.absent(), + Value displayName = const Value.absent(), + Value configJson = const Value.absent(), + Value isDefault = const Value.absent(), + Value createdAt = const Value.absent(), + Value lastAuthenticatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ConnectionsCompanion( + id: id, + kind: kind, + displayName: displayName, + configJson: configJson, + isDefault: isDefault, + createdAt: createdAt, + lastAuthenticatedAt: lastAuthenticatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String kind, + required String displayName, + required String configJson, + Value isDefault = const Value.absent(), + required int createdAt, + Value lastAuthenticatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ConnectionsCompanion.insert( + id: id, + kind: kind, + displayName: displayName, + configJson: configJson, + isDefault: isDefault, + createdAt: createdAt, + lastAuthenticatedAt: lastAuthenticatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$ConnectionsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({profileConnectionsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (profileConnectionsRefs) db.profileConnections, + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (profileConnectionsRefs) + await $_getPrefetchedData< + ConnectionRow, + $ConnectionsTable, + ProfileConnectionRow + >( + currentTable: table, + referencedTable: $$ConnectionsTableReferences + ._profileConnectionsRefsTable(db), + managerFromTypedResult: (p0) => + $$ConnectionsTableReferences( + db, + table, + p0, + ).profileConnectionsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where( + (e) => e.connectionId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$ConnectionsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ConnectionsTable, + ConnectionRow, + $$ConnectionsTableFilterComposer, + $$ConnectionsTableOrderingComposer, + $$ConnectionsTableAnnotationComposer, + $$ConnectionsTableCreateCompanionBuilder, + $$ConnectionsTableUpdateCompanionBuilder, + (ConnectionRow, $$ConnectionsTableReferences), + ConnectionRow, + PrefetchHooks Function({bool profileConnectionsRefs}) + >; +typedef $$ProfilesTableCreateCompanionBuilder = + ProfilesCompanion Function({ + required String id, + required String kind, + required String displayName, + Value avatarThumbUrl, + required String configJson, + Value sortOrder, + required int createdAt, + Value lastUsedAt, + Value rowid, + }); +typedef $$ProfilesTableUpdateCompanionBuilder = + ProfilesCompanion Function({ + Value id, + Value kind, + Value displayName, + Value avatarThumbUrl, + Value configJson, + Value sortOrder, + Value createdAt, + Value lastUsedAt, + Value rowid, + }); + +class $$ProfilesTableFilterComposer + extends Composer<_$AppDatabase, $ProfilesTable> { + $$ProfilesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get kind => $composableBuilder( + column: $table.kind, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get avatarThumbUrl => $composableBuilder( + column: $table.avatarThumbUrl, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get configJson => $composableBuilder( + column: $table.configJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ProfilesTableOrderingComposer + extends Composer<_$AppDatabase, $ProfilesTable> { + $$ProfilesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get kind => $composableBuilder( + column: $table.kind, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get avatarThumbUrl => $composableBuilder( + column: $table.avatarThumbUrl, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get configJson => $composableBuilder( + column: $table.configJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ProfilesTableAnnotationComposer + extends Composer<_$AppDatabase, $ProfilesTable> { + $$ProfilesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get kind => + $composableBuilder(column: $table.kind, builder: (column) => column); + + GeneratedColumn get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => column, + ); + + GeneratedColumn get avatarThumbUrl => $composableBuilder( + column: $table.avatarThumbUrl, + builder: (column) => column, + ); + + GeneratedColumn get configJson => $composableBuilder( + column: $table.configJson, + builder: (column) => column, + ); + + GeneratedColumn get sortOrder => + $composableBuilder(column: $table.sortOrder, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => column, + ); +} + +class $$ProfilesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ProfilesTable, + ProfileRow, + $$ProfilesTableFilterComposer, + $$ProfilesTableOrderingComposer, + $$ProfilesTableAnnotationComposer, + $$ProfilesTableCreateCompanionBuilder, + $$ProfilesTableUpdateCompanionBuilder, + ( + ProfileRow, + BaseReferences<_$AppDatabase, $ProfilesTable, ProfileRow>, + ), + ProfileRow, + PrefetchHooks Function() + > { + $$ProfilesTableTableManager(_$AppDatabase db, $ProfilesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ProfilesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ProfilesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ProfilesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value kind = const Value.absent(), + Value displayName = const Value.absent(), + Value avatarThumbUrl = const Value.absent(), + Value configJson = const Value.absent(), + Value sortOrder = const Value.absent(), + Value createdAt = const Value.absent(), + Value lastUsedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProfilesCompanion( + id: id, + kind: kind, + displayName: displayName, + avatarThumbUrl: avatarThumbUrl, + configJson: configJson, + sortOrder: sortOrder, + createdAt: createdAt, + lastUsedAt: lastUsedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String kind, + required String displayName, + Value avatarThumbUrl = const Value.absent(), + required String configJson, + Value sortOrder = const Value.absent(), + required int createdAt, + Value lastUsedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProfilesCompanion.insert( + id: id, + kind: kind, + displayName: displayName, + avatarThumbUrl: avatarThumbUrl, + configJson: configJson, + sortOrder: sortOrder, + createdAt: createdAt, + lastUsedAt: lastUsedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ProfilesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ProfilesTable, + ProfileRow, + $$ProfilesTableFilterComposer, + $$ProfilesTableOrderingComposer, + $$ProfilesTableAnnotationComposer, + $$ProfilesTableCreateCompanionBuilder, + $$ProfilesTableUpdateCompanionBuilder, + (ProfileRow, BaseReferences<_$AppDatabase, $ProfilesTable, ProfileRow>), + ProfileRow, + PrefetchHooks Function() + >; +typedef $$ProfileConnectionsTableCreateCompanionBuilder = + ProfileConnectionsCompanion Function({ + required String profileId, + required String connectionId, + Value userToken, + required String userIdentifier, + Value isDefault, + Value tokenAcquiredAt, + Value lastUsedAt, + Value rowid, + }); +typedef $$ProfileConnectionsTableUpdateCompanionBuilder = + ProfileConnectionsCompanion Function({ + Value profileId, + Value connectionId, + Value userToken, + Value userIdentifier, + Value isDefault, + Value tokenAcquiredAt, + Value lastUsedAt, + Value rowid, + }); + +final class $$ProfileConnectionsTableReferences + extends + BaseReferences< + _$AppDatabase, + $ProfileConnectionsTable, + ProfileConnectionRow + > { + $$ProfileConnectionsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $ConnectionsTable _connectionIdTable(_$AppDatabase db) => + db.connections.createAlias( + $_aliasNameGenerator( + db.profileConnections.connectionId, + db.connections.id, + ), + ); + + $$ConnectionsTableProcessedTableManager get connectionId { + final $_column = $_itemColumn('connection_id')!; + + final manager = $$ConnectionsTableTableManager( + $_db, + $_db.connections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_connectionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$ProfileConnectionsTableFilterComposer + extends Composer<_$AppDatabase, $ProfileConnectionsTable> { + $$ProfileConnectionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get userToken => $composableBuilder( + column: $table.userToken, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get userIdentifier => $composableBuilder( + column: $table.userIdentifier, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isDefault => $composableBuilder( + column: $table.isDefault, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tokenAcquiredAt => $composableBuilder( + column: $table.tokenAcquiredAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => ColumnFilters(column), + ); + + $$ConnectionsTableFilterComposer get connectionId { + final $$ConnectionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.connectionId, + referencedTable: $db.connections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ConnectionsTableFilterComposer( + $db: $db, + $table: $db.connections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ProfileConnectionsTableOrderingComposer + extends Composer<_$AppDatabase, $ProfileConnectionsTable> { + $$ProfileConnectionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get profileId => $composableBuilder( + column: $table.profileId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get userToken => $composableBuilder( + column: $table.userToken, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get userIdentifier => $composableBuilder( + column: $table.userIdentifier, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isDefault => $composableBuilder( + column: $table.isDefault, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tokenAcquiredAt => $composableBuilder( + column: $table.tokenAcquiredAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => ColumnOrderings(column), + ); + + $$ConnectionsTableOrderingComposer get connectionId { + final $$ConnectionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.connectionId, + referencedTable: $db.connections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ConnectionsTableOrderingComposer( + $db: $db, + $table: $db.connections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ProfileConnectionsTableAnnotationComposer + extends Composer<_$AppDatabase, $ProfileConnectionsTable> { + $$ProfileConnectionsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get profileId => + $composableBuilder(column: $table.profileId, builder: (column) => column); + + GeneratedColumn get userToken => + $composableBuilder(column: $table.userToken, builder: (column) => column); + + GeneratedColumn get userIdentifier => $composableBuilder( + column: $table.userIdentifier, + builder: (column) => column, + ); + + GeneratedColumn get isDefault => + $composableBuilder(column: $table.isDefault, builder: (column) => column); + + GeneratedColumn get tokenAcquiredAt => $composableBuilder( + column: $table.tokenAcquiredAt, + builder: (column) => column, + ); + + GeneratedColumn get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => column, + ); + + $$ConnectionsTableAnnotationComposer get connectionId { + final $$ConnectionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.connectionId, + referencedTable: $db.connections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ConnectionsTableAnnotationComposer( + $db: $db, + $table: $db.connections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ProfileConnectionsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ProfileConnectionsTable, + ProfileConnectionRow, + $$ProfileConnectionsTableFilterComposer, + $$ProfileConnectionsTableOrderingComposer, + $$ProfileConnectionsTableAnnotationComposer, + $$ProfileConnectionsTableCreateCompanionBuilder, + $$ProfileConnectionsTableUpdateCompanionBuilder, + (ProfileConnectionRow, $$ProfileConnectionsTableReferences), + ProfileConnectionRow, + PrefetchHooks Function({bool connectionId}) + > { + $$ProfileConnectionsTableTableManager( + _$AppDatabase db, + $ProfileConnectionsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ProfileConnectionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ProfileConnectionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ProfileConnectionsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value profileId = const Value.absent(), + Value connectionId = const Value.absent(), + Value userToken = const Value.absent(), + Value userIdentifier = const Value.absent(), + Value isDefault = const Value.absent(), + Value tokenAcquiredAt = const Value.absent(), + Value lastUsedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProfileConnectionsCompanion( + profileId: profileId, + connectionId: connectionId, + userToken: userToken, + userIdentifier: userIdentifier, + isDefault: isDefault, + tokenAcquiredAt: tokenAcquiredAt, + lastUsedAt: lastUsedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String profileId, + required String connectionId, + Value userToken = const Value.absent(), + required String userIdentifier, + Value isDefault = const Value.absent(), + Value tokenAcquiredAt = const Value.absent(), + Value lastUsedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProfileConnectionsCompanion.insert( + profileId: profileId, + connectionId: connectionId, + userToken: userToken, + userIdentifier: userIdentifier, + isDefault: isDefault, + tokenAcquiredAt: tokenAcquiredAt, + lastUsedAt: lastUsedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$ProfileConnectionsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({connectionId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (connectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.connectionId, + referencedTable: + $$ProfileConnectionsTableReferences + ._connectionIdTable(db), + referencedColumn: + $$ProfileConnectionsTableReferences + ._connectionIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$ProfileConnectionsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ProfileConnectionsTable, + ProfileConnectionRow, + $$ProfileConnectionsTableFilterComposer, + $$ProfileConnectionsTableOrderingComposer, + $$ProfileConnectionsTableAnnotationComposer, + $$ProfileConnectionsTableCreateCompanionBuilder, + $$ProfileConnectionsTableUpdateCompanionBuilder, + (ProfileConnectionRow, $$ProfileConnectionsTableReferences), + ProfileConnectionRow, + PrefetchHooks Function({bool connectionId}) + >; class $AppDatabaseManager { final _$AppDatabase _db; $AppDatabaseManager(this._db); $$DownloadedMediaTableTableManager get downloadedMedia => $$DownloadedMediaTableTableManager(_db, _db.downloadedMedia); - $$DownloadQueueTableTableManager get downloadQueue => $$DownloadQueueTableTableManager(_db, _db.downloadQueue); - $$ApiCacheTableTableManager get apiCache => $$ApiCacheTableTableManager(_db, _db.apiCache); + $$DownloadOwnersTableTableManager get downloadOwners => + $$DownloadOwnersTableTableManager(_db, _db.downloadOwners); + $$DownloadQueueTableTableManager get downloadQueue => + $$DownloadQueueTableTableManager(_db, _db.downloadQueue); + $$ApiCacheTableTableManager get apiCache => + $$ApiCacheTableTableManager(_db, _db.apiCache); $$OfflineWatchProgressTableTableManager get offlineWatchProgress => $$OfflineWatchProgressTableTableManager(_db, _db.offlineWatchProgress); - $$SyncRulesTableTableManager get syncRules => $$SyncRulesTableTableManager(_db, _db.syncRules); + $$SyncRulesTableTableManager get syncRules => + $$SyncRulesTableTableManager(_db, _db.syncRules); + $$ConnectionsTableTableManager get connections => + $$ConnectionsTableTableManager(_db, _db.connections); + $$ProfilesTableTableManager get profiles => + $$ProfilesTableTableManager(_db, _db.profiles); + $$ProfileConnectionsTableTableManager get profileConnections => + $$ProfileConnectionsTableTableManager(_db, _db.profileConnections); } diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index a8c532ac..90f49591 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -2,12 +2,84 @@ import 'package:drift/drift.dart'; import 'app_database.dart'; import '../models/download_models.dart'; +import '../profiles/profile.dart'; /// Extension methods on AppDatabase for download operations extension DownloadDatabaseOperations on AppDatabase { - /// Insert a new download into the database + Future addDownloadOwner({required String profileId, required String globalKey}) async { + if (profileId.isEmpty) return; + await into(downloadOwners).insert( + DownloadOwnersCompanion.insert( + profileId: profileId, + globalKey: globalKey, + createdAt: DateTime.now().millisecondsSinceEpoch, + ), + mode: InsertMode.insertOrIgnore, + ); + } + + Future removeDownloadOwner({required String profileId, required String globalKey}) async { + await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go(); + } + + Future removeDownloadOwnersForProfile(String profileId) async { + if (profileId.isEmpty) return; + await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId))).go(); + } + + Future> getDownloadOwnerKeysForProfile(String profileId) async { + if (profileId.isEmpty) return const {}; + final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get(); + return rows.map((row) => row.globalKey).toSet(); + } + + Future getDownloadOwnerCount(String globalKey) async { + return (await _validDownloadOwnerRows(globalKey)).length; + } + + Future hasDownloadOwner(String globalKey, {String? excludingProfileId}) async { + final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId); + return rows.isNotEmpty; + } + + Future> _validDownloadOwnerRows(String globalKey, {String? excludingProfileId}) async { + final rows = await (select(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).get(); + if (rows.isEmpty) return const []; + final candidates = rows + .where((row) => excludingProfileId == null || excludingProfileId.isEmpty || row.profileId != excludingProfileId) + .toList(growable: false); + if (candidates.isEmpty) return const []; + + final localProfileRows = await select(profiles).get(); + final localProfileIds = localProfileRows.map((row) => row.id).toSet(); + final connectionRows = await select(connections).get(); + final connectionIds = connectionRows.map((row) => row.id).toSet(); + return candidates + .where((row) { + if (localProfileIds.contains(row.profileId)) return true; + final plexHome = parsePlexHomeProfileId(row.profileId); + if (plexHome != null) return connectionIds.contains(plexHome.accountConnectionId); + return localProfileIds.isEmpty; + }) + .toList(growable: false); + } + + /// Claim pre-v17 shared download rows for [profileId]. Rows that already + /// have any owner are left untouched so later profiles do not inherit them. + Future adoptLegacyDownloadsForProfile(String profileId) async { + if (profileId.isEmpty) return; + final rows = await select(downloadedMedia).get(); + for (final row in rows) { + if (await getDownloadOwnerCount(row.globalKey) == 0) { + await addDownloadOwner(profileId: profileId, globalKey: row.globalKey); + } + } + } + + /// Insert a new download into the database. Future insertDownload({ required String serverId, + String? clientScopeId, required String ratingKey, required String globalKey, required String type, @@ -19,6 +91,7 @@ extension DownloadDatabaseOperations on AppDatabase { await into(downloadedMedia).insert( DownloadedMediaCompanion.insert( serverId: serverId, + clientScopeId: Value(clientScopeId), ratingKey: ratingKey, globalKey: globalKey, type: type, @@ -135,18 +208,41 @@ extension DownloadDatabaseOperations on AppDatabase { /// Delete a download Future deleteDownload(String globalKey) async { + await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go(); await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go(); await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go(); } /// Get all downloaded episodes for a season - Future> getEpisodesBySeason(String seasonKey) { - return (select(downloadedMedia)..where((t) => t.parentRatingKey.equals(seasonKey))).get(); + Future> getEpisodesBySeason( + String seasonKey, { + String? serverId, + String? clientScopeId, + bool filterClientScope = false, + }) { + return (select(downloadedMedia)..where( + (t) => + t.parentRatingKey.equals(seasonKey) & + _optionalServerPredicate(t.serverId, serverId) & + _optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope), + )) + .get(); } /// Get all downloaded episodes for a show - Future> getEpisodesByShow(String showKey) { - return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get(); + Future> getEpisodesByShow( + String showKey, { + String? serverId, + String? clientScopeId, + bool filterClientScope = false, + }) { + return (select(downloadedMedia)..where( + (t) => + t.grandparentRatingKey.equals(showKey) & + _optionalServerPredicate(t.serverId, serverId) & + _optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope), + )) + .get(); } /// Get all downloaded items for a specific server @@ -154,6 +250,24 @@ extension DownloadDatabaseOperations on AppDatabase { return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get(); } + Expression _optionalServerPredicate(GeneratedColumn column, String? serverId) { + return serverId == null ? const Constant(true) : column.equals(serverId); + } + + Expression _optionalClientScopePredicate( + GeneratedColumn column, + String? clientScopeId, { + required bool filterClientScope, + }) { + if (!filterClientScope && (clientScopeId == null || clientScopeId.isEmpty)) { + return const Constant(true); + } + if (clientScopeId == null || clientScopeId.isEmpty) { + return column.isNull() | column.equals(''); + } + return column.equals(clientScopeId); + } + /// Update the background_downloader task ID for a download Future updateBgTaskId(String globalKey, String? taskId) async { await (update( diff --git a/lib/database/tables.dart b/lib/database/tables.dart index a349f910..5cf57b98 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -1,9 +1,10 @@ import 'package:drift/drift.dart'; -/// Key-value cache table for Plex API responses. +/// Key-value cache table for media-server API responses (Plex, Jellyfin). /// Used for offline support - stores raw JSON responses. class ApiCache extends Table { - /// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345") + /// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345" + /// for Plex, "abc123:/Users/.../Items/..." for Jellyfin) TextColumn get cacheKey => text()(); /// JSON response data @@ -37,6 +38,12 @@ class DownloadQueue extends Table { class DownloadedMedia extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get serverId => text()(); + // Downloads are intentionally app-wide/shared, keyed by the public + // serverId:ratingKey globalKey below. For Jellyfin, clientScopeId records + // which scoped client produced the cached metadata/download request; it is + // not part of download ownership and must not be used to hide or duplicate + // the physical downloaded item per user/profile. + TextColumn get clientScopeId => text().nullable()(); TextColumn get ratingKey => text()(); TextColumn get globalKey => text().unique()(); TextColumn get type => text()(); @@ -55,13 +62,35 @@ class DownloadedMedia extends Table { IntColumn get mediaIndex => integer().withDefault(const Constant(0))(); } +/// Profile ownership for shared physical downloads. +/// +/// [DownloadedMedia] stores one physical row per public serverId:ratingKey so +/// files are deduped across profiles. This table controls which active profile +/// can see/use that shared row. +@DataClassName('DownloadOwnerItem') +@TableIndex(name: 'idx_download_owners_profile', columns: {#profileId}) +@TableIndex(name: 'idx_download_owners_global_key', columns: {#globalKey}) +class DownloadOwners extends Table { + TextColumn get profileId => text()(); + TextColumn get globalKey => text()(); + IntColumn get createdAt => integer()(); + + @override + Set get primaryKey => {profileId, globalKey}; +} + /// Persistent sync rules for auto-downloading unwatched episodes. /// /// Each rule keeps a rolling window of N unwatched episodes for a show/season, /// or mirrors the current contents of a collection/playlist. +/// Rules are owned by the active top-level profile. Downloads remain app-wide +/// and shared by public serverId:ratingKey identity, but rule ownership must not +/// cross users because Jellyfin permissions and watch state are user-scoped. @DataClassName('SyncRuleItem') +@TableIndex(name: 'idx_sync_rules_profile', columns: {#profileId}) class SyncRules extends Table { IntColumn get id => integer().autoIncrement()(); + TextColumn get profileId => text().withDefault(const Constant(''))(); TextColumn get serverId => text()(); TextColumn get ratingKey => text()(); TextColumn get globalKey => text().unique()(); @@ -74,18 +103,131 @@ class SyncRules extends Table { TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))(); } +/// 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 +/// [Connection] sealed class. +@DataClassName('ConnectionRow') +@TableIndex(name: 'idx_connections_kind', columns: {#kind}) +class Connections extends Table { + /// Stable identifier for the connection. For Plex it's a generated UUID + /// (one per account); for Jellyfin it's the server's machineId. + TextColumn get id => text()(); + + /// Backend kind: `'plex'` or `'jellyfin'`. + TextColumn get kind => text()(); + + /// User-visible label (account email, server name). + TextColumn get displayName => text()(); + + /// Backend-specific config payload (token, baseUrl, profile id, …). + TextColumn get configJson => text()(); + + /// Whether this is the default connection used at app launch when only + /// one connection is present. + BoolColumn get isDefault => boolean().withDefault(const Constant(false))(); + + /// Timestamp this connection was added (milliseconds since epoch). + IntColumn get createdAt => integer()(); + + /// Timestamp of the most-recent successful auth refresh (milliseconds + /// since epoch). Null until the first successful auth. + IntColumn get lastAuthenticatedAt => integer().nullable()(); + + @override + Set get primaryKey => {id}; +} + +/// Top-level profiles — see [Profile]. +/// +/// A profile is the user-facing identity. Plex Home users auto-surface as +/// `kind='plex_home'` rows when their parent Plex account is added; users +/// can also create `kind='local'` rows manually. Each profile owns one or +/// more connections via [ProfileConnections]. +@DataClassName('ProfileRow') +@TableIndex(name: 'idx_profiles_kind', columns: {#kind}) +class Profiles extends Table { + /// Stable identifier. For Plex Home profiles: `plex-home-{accountId}-{homeUserUuid}` + /// (deterministic so re-discovery is idempotent). For locals: `local-{uuid}`. + TextColumn get id => text()(); + + /// `'local'` | `'plex_home'`. + TextColumn get kind => text()(); + + TextColumn get displayName => text()(); + + /// Plex Home users have a thumb URL; locals fall back to initials/colour. + TextColumn get avatarThumbUrl => text().nullable()(); + + /// Per-kind config: + /// - `local`: `{ "pinHash": "..." }` + /// - `plex_home`: `{ "restricted": bool, "admin": bool, "hasPassword": bool, "parentConnectionId": "..." }` + TextColumn get configJson => text()(); + + IntColumn get sortOrder => integer().withDefault(const Constant(0))(); + IntColumn get createdAt => integer()(); + IntColumn get lastUsedAt => integer().nullable()(); + + @override + Set get primaryKey => {id}; +} + +/// Many-to-many join between [Profiles] and [Connections], carrying the +/// per-profile user-level token used when the profile is active. +/// +/// For Plex: `userToken` is a Home-user token from `/home/users/{uuid}/switch`, +/// `userIdentifier` is the Plex Home user UUID. An empty `userToken` is a +/// lazy-fetch sentinel — the binder calls `/switch` on first activation. +/// The Dart-side [ProfileConnection] model surfaces empty as `null`; the +/// column stays non-nullable here to avoid a schema migration. +/// +/// For Jellyfin: `userToken` mirrors the Connection's accessToken (one user +/// per Jellyfin connection) and `userIdentifier` is the Jellyfin user id. +@DataClassName('ProfileConnectionRow') +@TableIndex(name: 'idx_profile_connections_connection_id', columns: {#connectionId}) +@TableIndex(name: 'idx_profile_connections_profile_id', columns: {#profileId}) +class ProfileConnections extends Table { + // No FK on profile_id: Plex Home profiles are virtual (built by + // Profile.virtualPlexHome from PlexHomeService's live cache, never + // persisted in `profiles`), so an FK here would reject every join row + // they need. The two profile-delete sites clean up join rows manually + // via ProfileConnectionRegistry.removeAllForProfile before calling + // ProfileRegistry.remove. + TextColumn get profileId => text()(); + TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)(); + TextColumn get userToken => text().withDefault(const Constant(''))(); + TextColumn get userIdentifier => text()(); + BoolColumn get isDefault => boolean().withDefault(const Constant(false))(); + IntColumn get tokenAcquiredAt => integer().nullable()(); + IntColumn get lastUsedAt => integer().nullable()(); + + @override + Set get primaryKey => {profileId, connectionId}; +} + /// Queue for offline watch progress and manual watch actions. /// /// Stores watch progress updates and manual watch/unwatch actions -/// that need to be synced to the Plex server when back online. +/// that need to be synced to the originating media server when back online. @DataClassName('OfflineWatchProgressItem') +@TableIndex(name: 'idx_offline_watch_progress_server', columns: {#serverId}) +@TableIndex(name: 'idx_offline_watch_progress_profile', columns: {#profileId}) class OfflineWatchProgress extends Table { /// Auto-incrementing primary key IntColumn get id => integer().autoIncrement()(); + /// Active Plezy profile that owns this queued action. + TextColumn get profileId => text().nullable()(); + /// Server ID this media belongs to TextColumn get serverId => text()(); + /// Optional user-scoped client/cache id for backends where [serverId] is + /// shared by multiple users on the same server. + TextColumn get clientScopeId => text().nullable()(); + /// Rating key of the media item TextColumn get ratingKey => text()(); diff --git a/lib/exceptions/media_server_exceptions.dart b/lib/exceptions/media_server_exceptions.dart new file mode 100644 index 00000000..ca366d0e --- /dev/null +++ b/lib/exceptions/media_server_exceptions.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart'; + +/// Sealed base for backend-agnostic media-server exceptions. Both Plex and +/// Jellyfin auth/HTTP layers throw subtypes from this hierarchy so consumers +/// can catch with one filter and match exhaustively when they care which +/// failure mode it is. +sealed class MediaServerException implements Exception { + final String message; + const MediaServerException(this.message); + + @override + String toString() => '$runtimeType: $message'; +} + +/// The supplied base URL is unreachable, returns the wrong shape, or doesn't +/// look like the expected backend at all. Surfaces in onboarding probes +/// (Jellyfin `/System/Info/Public`, Plex resource discovery). +class MediaServerUrlException extends MediaServerException { + const MediaServerUrlException(super.message); +} + +/// Authentication failed — bad password, expired token, disabled user, +/// rate-limit. [statusCode] is the HTTP status when the failure was a 4xx +/// response; null for transport-layer auth signals (e.g. token rejected +/// during refresh). +class MediaServerAuthException extends MediaServerException { + final int? statusCode; + const MediaServerAuthException(super.message, {this.statusCode}); +} + +/// HTTP transport / non-2xx errors. Carries the status code (when known), +/// the parsed response body, and the originating URI so callers can log +/// useful diagnostics. Both Plex and Jellyfin route their HTTP failures +/// through this type — it's the canonical backend-agnostic transport +/// exception. +enum MediaServerHttpErrorType { connectionTimeout, receiveTimeout, connectionError, cancelled, unknown } + +class MediaServerHttpException extends MediaServerException { + final MediaServerHttpErrorType type; + final int? statusCode; + final dynamic responseData; + final Uri? requestUri; + + MediaServerHttpException({required this.type, String? message, this.statusCode, this.responseData, this.requestUri}) + : super(message ?? ''); + + /// Map a caught exception to a [MediaServerHttpException]. + factory MediaServerHttpException.from(Object error, {Uri? uri}) { + if (error is MediaServerHttpException) return error; + + if (error is RequestAbortedException) { + return MediaServerHttpException( + type: MediaServerHttpErrorType.cancelled, + message: error.message, + requestUri: error.uri ?? uri, + ); + } + + if (error is TimeoutException) { + return MediaServerHttpException( + type: MediaServerHttpErrorType.connectionTimeout, + message: error.message, + requestUri: uri, + ); + } + + if (error is SocketException) { + return MediaServerHttpException( + type: MediaServerHttpErrorType.connectionError, + message: error.message, + requestUri: uri, + ); + } + + if (error is HttpException) { + return MediaServerHttpException( + type: MediaServerHttpErrorType.connectionError, + message: error.message, + requestUri: uri, + ); + } + + if (error is ClientException) { + return MediaServerHttpException( + type: MediaServerHttpErrorType.connectionError, + message: error.message, + requestUri: error.uri ?? uri, + ); + } + + return MediaServerHttpException(type: MediaServerHttpErrorType.unknown, message: error.toString(), requestUri: uri); + } + + /// Whether the error looks transient (network/timeout) and worth retrying. + bool get isTransient => + type == MediaServerHttpErrorType.connectionTimeout || + type == MediaServerHttpErrorType.connectionError || + type == MediaServerHttpErrorType.receiveTimeout; + + @override + String toString() => 'MediaServerHttpException(${type.name}: $message)'; +} diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 6a1ac670..ee6d41f2 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Log ind", "signInWithPlex": "Log ind med Plex", "showQRCode": "Vis QR-kode", "authenticate": "Godkend", "authenticationTimeout": "Godkendelse fik timeout. Prøv igen.", "scanQRToSignIn": "Scan denne QR-kode for at logge ind", "waitingForAuth": "Venter på godkendelse...\nFærdiggør login i din browser.", - "useBrowser": "Brug browser" + "useBrowser": "Brug browser", + "or": "eller", + "connectToJellyfin": "Forbind til Jellyfin", + "useQuickConnect": "Brug Quick Connect", + "quickConnectCode": "Quick Connect-kode", + "quickConnectInstructions": "Åbn din Jellyfin-server i en webbrowser, log ind, og vælg Quick Connect i brugermenuen. Indtast denne kode for at godkende loginnet.", + "quickConnectWaiting": "Venter på godkendelse…", + "quickConnectCancel": "Annullér", + "quickConnectExpired": "Quick Connect-koden udløb inden godkendelse. Prøv igen." }, "common": { "cancel": "Annuller", @@ -104,12 +113,12 @@ "gridView": "Gitter", "listView": "Liste", "showHeroSection": "Vis hero-sektion", - "useGlobalHubs": "Brug Plex Home-layout", - "useGlobalHubsDescription": "Vis startsidehubbe som den officielle Plex-klient. Når slået fra, vises anbefalinger per bibliotek.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Vis servernavn på hubbe", "showServerNameOnHubsDescription": "Vis altid servernavnet i hubtitler. Når slået fra, vises kun ved duplikerede navne.", "groupLibrariesByServer": "Grupper biblioteker efter server", - "groupLibrariesByServerDescription": "Vis en overskrift for hver Plex-server i sidepanelet, når du er forbundet til flere servere.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Hold altid sidepanelet åbent", "alwaysKeepSidebarOpenDescription": "Sidepanelet forbliver udvidet, og indholdsområdet tilpasser sig", "showUnwatchedCount": "Vis antal usete", @@ -452,7 +461,7 @@ "musicNotSupported": "Musikafspilning understøttes endnu ikke", "noDescriptionAvailable": "Ingen beskrivelse tilgængelig", "noProfilesAvailable": "Ingen profiler tilgængelige", - "contactAdminForProfiles": "Kontakt din Plex-administrator for at tilføje profiler", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Kan ikke bestemme biblioteksafdeling for dette element", "logsCleared": "Logs ryddet", "logsCopied": "Logs kopieret til udklipsholder", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Bekræft handling" }, + "profiles": { + "addPlezyProfile": "Tilføj Plezy-profil", + "switchingProfile": "Skifter profil…", + "deleteThisProfileTitle": "Slet denne profil?", + "deleteThisProfileMessage": "${displayName} fjernes. Forbindelser påvirkes ikke.", + "active": "Aktiv", + "manage": "Administrer", + "delete": "Slet", + "signOut": "Log ud", + "signOutPlexTitle": "Log ud af Plex?", + "signOutPlexMessage": "${displayName} og alle Plex Home-brugere på denne konto fjernes fra denne enhed. Du kan logge ind igen når som helst.", + "signedOutPlex": "Logget ud af Plex.", + "signOutFailed": "Log ud mislykkedes.", + "sectionTitle": "Profiler", + "summarySingle": "Tilføj profiler for at blande administrerede brugere og lokale identiteter", + "summaryMultipleWithActive": "${count} profiler · aktiv: ${activeName}", + "summaryMultiple": "${count} profiler", + "removeConnectionTitle": "Fjern forbindelse?", + "removeConnectionMessage": "${displayName} mister adgang til ${connectionLabel}. Forbindelsen forbliver tilgængelig for andre profiler.", + "deleteProfileTitle": "Slet profil?", + "deleteProfileMessage": "Dette fjerner ${displayName} og alle dens forbindelser fra denne enhed. De underliggende Plex/Jellyfin-servere påvirkes ikke.", + "profileNameLabel": "Profilnavn", + "pinProtectionLabel": "PIN-beskyttelse", + "pinManagedByPlex": "PIN administreres af Plex. Rediger på plex.tv.", + "noPinSetEditOnPlex": "Ingen PIN-kode angivet. For at kræve en, redigér Home-brugeren på plex.tv.", + "setPin": "Angiv PIN", + "connectionsLabel": "Forbindelser", + "add": "Tilføj", + "deleteProfileButton": "Slet profil", + "noConnectionsHint": "Ingen forbindelser — tilføj en for at bruge denne profil.", + "plexHomeAccount": "Plex Home-konto", + "connectionDefault": "Standard", + "makeDefault": "Gør til standard", + "removeConnection": "Fjern", + "borrowAddTo": "Tilføj til ${displayName}", + "borrowExplain": "Lån en forbindelse fra en anden profil. PIN-beskyttede kildeprofiler beder om PIN, før de deler.", + "borrowEmpty": "Intet at låne endnu.", + "borrowEmptySubtitle": "Tilslut først en Plex-konto eller Jellyfin-server til en anden profil, og kom så tilbage hertil.", + "newProfile": "Ny profil", + "profileNameHint": "fx. Gæster, Børn, Familiens stue", + "pinProtectionOptional": "PIN-beskyttelse (valgfri)", + "pinExplain": "4-cifret PIN-kode kræves for at skifte til denne profil. Blød barriere — enhver der kan slette appdata, kan omgå den.", + "continueButton": "Fortsæt", + "pinsDontMatch": "PIN-koder matcher ikke" + }, + "connections": { + "sectionTitle": "Forbindelser", + "addConnection": "Tilføj forbindelse", + "addConnectionSubtitleNoProfile": "Log ind med Plex eller forbind til en Jellyfin-server", + "addConnectionSubtitleScoped": "Tilføj til ${displayName} — Plex-konto, Jellyfin-server eller lån fra en anden profil", + "sessionExpiredOne": "Sessionen er udløbet for ${name}", + "sessionExpiredMany": "Sessionen er udløbet for ${count} servere", + "signInAgain": "Log ind igen" + }, "discover": { "title": "Opdag", "switchProfile": "Skift profil", "noContentAvailable": "Intet indhold tilgængeligt", "addMediaToLibraries": "Tilføj medier til dine biblioteker", "continueWatching": "Fortsæt med at se", + "nextUp": "Næste op", + "recentlyAdded": "Nyligt tilføjet", "playEpisode": "S${season}E${episode}", "overview": "Oversigt", "cast": "Rollebesætning", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Søgning mislykkedes: ${error}", "connectionTimeout": "Forbindelsestimeout ved indlæsning af ${context}", - "connectionFailed": "Kunne ikke forbinde til Plex-server", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Kunne ikke indlæse ${context}: ${error}", "noClientAvailable": "Ingen klient tilgængelig", "authenticationFailed": "Godkendelse mislykkedes: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Indtast et token", "invalidToken": "Ugyldigt token", "failedToVerifyToken": "Kunne ikke verificere token: ${error}", - "failedToSwitchProfile": "Kunne ikke skifte til ${displayName}" + "failedToSwitchProfile": "Kunne ikke skifte til ${displayName}", + "failedToDeleteProfile": "Kunne ikke slette ${displayName}", + "failedToRate": "Kunne ikke opdatere bedømmelsen" }, "libraries": { "title": "Biblioteker", @@ -595,13 +662,30 @@ "seasons": "Sæsoner", "episodes": "Episoder", "folders": "Mapper" + }, + "filterCategories": { + "genre": "Genre", + "year": "År", + "contentRating": "Aldersvurdering", + "tag": "Tag" + }, + "sortLabels": { + "title": "Titel", + "dateAdded": "Tilføjet dato", + "releaseDate": "Udgivelsesdato", + "rating": "Vurdering", + "lastPlayed": "Sidst afspillet", + "playCount": "Antal afspilninger", + "random": "Tilfældig", + "dateShared": "Delt dato", + "latestEpisodeAirDate": "Seneste episodes premieredato" } }, "about": { "title": "Om", "openSourceLicenses": "Open source-licenser", "versionLabel": "Version ${version}", - "appDescription": "En smuk Plex-klient til Flutter", + "appDescription": "En smuk Plex- og Jellyfin-klient til Flutter", "viewLicensesDescription": "Se licenser for tredjepartsbiblioteker" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Venter på at andre indlæser...", "recentRooms": "Seneste rum", "renameRoom": "Omdøb rum", - "removeRoom": "Fjern" + "removeRoom": "Fjern", + "guestSwitchUnavailable": "Kunne ikke skifte — server ikke tilgængelig for synkronisering", + "guestSwitchFailed": "Kunne ikke skifte — indhold blev ikke fundet på denne server" }, "downloads": { "title": "Downloads", @@ -817,6 +903,12 @@ "editSyncFilter": "Synkroniseringsfilter", "syncAllItems": "Synkroniserer alle elementer", "syncUnwatchedItems": "Synkroniserer usete elementer", + "syncRuleServerContext": "Server: ${server} • ${status}", + "syncRuleAvailable": "Tilgængelig", + "syncRuleOffline": "Offline", + "syncRuleSignInRequired": "Log ind påkrævet", + "syncRuleNotAvailableForProfile": "Ikke tilgængelig for nuværende profil", + "syncRuleUnknownServer": "Ukendt server", "syncRuleListCreated": "Synkroniseringsregel oprettet" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Biblioteker", "noLibraries": "Ingen biblioteker tilgængelige" } + }, + "addServer": { + "addJellyfinTitle": "Tilføj Jellyfin-server", + "jellyfinUrlIntro": "Angiv URL'en til din Jellyfin-server — f.eks. `https://jellyfin.example.com`. Du kan logge ind bagefter.", + "serverUrl": "Server-URL", + "findServer": "Find server", + "username": "Brugernavn", + "password": "Adgangskode", + "signIn": "Log ind", + "change": "Ændr", + "required": "Påkrævet", + "couldNotReachServer": "Kunne ikke nå serveren: ${error}", + "signInFailed": "Login mislykkedes: ${error}", + "quickConnectFailed": "Quick Connect mislykkedes: ${error}", + "addPlexTitle": "Log ind med Plex", + "plexAuthIntro": "Vælg hvordan du vil logge ind på Plex. Browser-flowet åbner plex.tv, hvor du bekræfter forbindelsen; QR-koden er praktisk til TV / fjern-enheder.", + "plexQRPrompt": "Scan denne QR-kode for at logge ind.", + "waitingForPlexConfirmation": "Venter på at plex.tv bekræfter login…", + "pinExpired": "PIN udløb før login. Prøv igen.", + "duplicatePlexAccount": "Denne enhed er allerede logget ind på en Plex-konto. Log ud fra indstillingerne for at skifte konto.", + "failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}", + "enterJellyfinUrlError": "Angiv URL'en til din Jellyfin-server", + "addConnectionTitle": "Tilføj forbindelse", + "addConnectionTitleScoped": "Tilføj til ${name}", + "addConnectionIntroGlobal": "Tilføj endnu en medieserver. Du kan blande Plex-konti og Jellyfin-servere — indhold fra alle tilkoblede backender vises samlet på startsiden.", + "addConnectionIntroScoped": "Tilføj en ny server, eller lån en fra en anden profil.", + "signInWithPlexCard": "Log ind med Plex", + "signInWithPlexCardSubtitle": "Godkend denne enhed mod din Plex-konto. Servere delt med kontoen følger med automatisk.", + "signInWithPlexCardSubtitleScoped": "Godkend en ny Plex-konto. Dens Home-brugere vises som profiler.", + "connectToJellyfinCard": "Forbind til Jellyfin", + "connectToJellyfinCardSubtitle": "Angiv URL'en til din Jellyfin-server og log ind med brugernavn + adgangskode (Quick Connect kommer snart).", + "connectToJellyfinCardSubtitleScoped": "Log ind på en Jellyfin-server. Tilknyttes ${name}.", + "borrowFromAnotherProfile": "Lån fra en anden profil", + "borrowFromAnotherProfileSubtitle": "Genbrug en forbindelse, der allerede er tilknyttet en anden profil. PIN-beskyttede kilde-profiler beder om PIN." } } diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 7ebea685..b1bd5bb0 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Anmelden", "signInWithPlex": "Mit Plex anmelden", "showQRCode": "QR-Code anzeigen", "authenticate": "Authentifizieren", "authenticationTimeout": "Authentifizierung abgelaufen. Bitte erneut versuchen.", "scanQRToSignIn": "QR-Code scannen zum Anmelden", "waitingForAuth": "Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.", - "useBrowser": "Browser verwenden" + "useBrowser": "Browser verwenden", + "or": "oder", + "connectToJellyfin": "Mit Jellyfin verbinden", + "useQuickConnect": "Quick Connect verwenden", + "quickConnectCode": "Quick Connect-Code", + "quickConnectInstructions": "Öffne deinen Jellyfin-Server im Browser, melde dich an und wähle Quick Connect im Benutzermenü. Gib diesen Code ein, um die Anmeldung zu bestätigen.", + "quickConnectWaiting": "Warte auf Bestätigung…", + "quickConnectCancel": "Abbrechen", + "quickConnectExpired": "Quick Connect-Code ist vor der Bestätigung abgelaufen. Bitte erneut versuchen." }, "common": { "cancel": "Abbrechen", @@ -104,12 +113,12 @@ "gridView": "Raster", "listView": "Liste", "showHeroSection": "Hero-Bereich anzeigen", - "useGlobalHubs": "Plex-Startseiten-Layout verwenden", - "useGlobalHubsDescription": "Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Servername bei Hubs anzeigen", "showServerNameOnHubsDescription": "Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.", "groupLibrariesByServer": "Mediatheken nach Server gruppieren", - "groupLibrariesByServerDescription": "Zeigt eine Überschrift für jeden Plex-Server in der Seitenleiste an, wenn du mit mehreren Servern verbunden bist.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Seitenleiste immer geöffnet halten", "alwaysKeepSidebarOpenDescription": "Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an", "showUnwatchedCount": "Anzahl nicht gesehener Folgen anzeigen", @@ -452,7 +461,7 @@ "musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt", "noDescriptionAvailable": "Keine Beschreibung verfügbar", "noProfilesAvailable": "Keine Profile verfügbar", - "contactAdminForProfiles": "Kontaktiere deinen Plex-Administrator, um Profile hinzuzufügen", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Bibliotheksbereich für dieses Element kann nicht ermittelt werden", "logsCleared": "Protokolle gelöscht", "logsCopied": "Protokolle in Zwischenablage kopiert", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Aktion bestätigen" }, + "profiles": { + "addPlezyProfile": "Plezy-Profil hinzufügen", + "switchingProfile": "Profil wird gewechselt…", + "deleteThisProfileTitle": "Dieses Profil löschen?", + "deleteThisProfileMessage": "${displayName} wird entfernt. Verbindungen sind davon nicht betroffen.", + "active": "Aktiv", + "manage": "Verwalten", + "delete": "Löschen", + "signOut": "Abmelden", + "signOutPlexTitle": "Von Plex abmelden?", + "signOutPlexMessage": "${displayName} und alle Plex Home-Benutzer dieses Kontos werden von diesem Gerät entfernt. Du kannst dich jederzeit wieder anmelden.", + "signedOutPlex": "Von Plex abgemeldet.", + "signOutFailed": "Abmeldung fehlgeschlagen.", + "sectionTitle": "Profile", + "summarySingle": "Profile hinzufügen, um verwaltete Benutzer und lokale Identitäten zu mischen", + "summaryMultipleWithActive": "${count} Profile · aktiv: ${activeName}", + "summaryMultiple": "${count} Profile", + "removeConnectionTitle": "Verbindung entfernen?", + "removeConnectionMessage": "${displayName} verliert den Zugriff auf ${connectionLabel}. Die Verbindung bleibt für andere Profile verfügbar.", + "deleteProfileTitle": "Profil löschen?", + "deleteProfileMessage": "Dies entfernt ${displayName} und alle zugehörigen Verbindungen von diesem Gerät. Die zugrunde liegenden Plex-/Jellyfin-Server sind nicht betroffen.", + "profileNameLabel": "Profilname", + "pinProtectionLabel": "PIN-Schutz", + "pinManagedByPlex": "PIN wird von Plex verwaltet. Auf plex.tv bearbeiten.", + "noPinSetEditOnPlex": "Keine PIN festgelegt. Um eine zu verlangen, bearbeite den Home-Benutzer auf plex.tv.", + "setPin": "PIN festlegen", + "connectionsLabel": "Verbindungen", + "add": "Hinzufügen", + "deleteProfileButton": "Profil löschen", + "noConnectionsHint": "Keine Verbindungen — füge eine hinzu, um dieses Profil zu nutzen.", + "plexHomeAccount": "Plex Home-Konto", + "connectionDefault": "Standard", + "makeDefault": "Als Standard", + "removeConnection": "Entfernen", + "borrowAddTo": "Zu ${displayName} hinzufügen", + "borrowExplain": "Eine Verbindung von einem anderen Profil ausleihen. PIN-geschützte Quellprofile fordern die PIN vor der Freigabe an.", + "borrowEmpty": "Noch nichts zum Ausleihen.", + "borrowEmptySubtitle": "Verbinde zuerst ein Plex-Konto oder einen Jellyfin-Server mit einem anderen Profil und komme dann hierher zurück.", + "newProfile": "Neues Profil", + "profileNameHint": "z. B. Gäste, Kinder, Wohnzimmer", + "pinProtectionOptional": "PIN-Schutz (optional)", + "pinExplain": "4-stellige PIN erforderlich, um zu diesem Profil zu wechseln. Weiche Barriere — wer App-Daten löschen kann, kann sie umgehen.", + "continueButton": "Weiter", + "pinsDontMatch": "PINs stimmen nicht überein" + }, + "connections": { + "sectionTitle": "Verbindungen", + "addConnection": "Verbindung hinzufügen", + "addConnectionSubtitleNoProfile": "Mit Plex anmelden oder Jellyfin-Server verbinden", + "addConnectionSubtitleScoped": "Zu ${displayName} hinzufügen — Plex-Konto, Jellyfin-Server oder von einem anderen Profil ausleihen", + "sessionExpiredOne": "Sitzung für ${name} abgelaufen", + "sessionExpiredMany": "Sitzungen für ${count} Server abgelaufen", + "signInAgain": "Erneut anmelden" + }, "discover": { "title": "Entdecken", "switchProfile": "Profil wechseln", "noContentAvailable": "Kein Inhalt verfügbar", "addMediaToLibraries": "Medien zur Mediathek hinzufügen", "continueWatching": "Weiterschauen", + "nextUp": "Als Nächstes", + "recentlyAdded": "Kürzlich hinzugefügt", "playEpisode": "S${season}E${episode}", "overview": "Übersicht", "cast": "Besetzung", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Suche fehlgeschlagen: ${error}", "connectionTimeout": "Zeitüberschreitung beim Laden von ${context}", - "connectionFailed": "Verbindung zum Plex-Server fehlgeschlagen", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Fehler beim Laden von ${context}: ${error}", "noClientAvailable": "Kein Client verfügbar", "authenticationFailed": "Authentifizierung fehlgeschlagen: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Bitte Token eingeben", "invalidToken": "Ungültiges Token", "failedToVerifyToken": "Token-Verifizierung fehlgeschlagen: ${error}", - "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen" + "failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen", + "failedToDeleteProfile": "Löschen von ${displayName} fehlgeschlagen", + "failedToRate": "Bewertung konnte nicht aktualisiert werden" }, "libraries": { "title": "Mediatheken", @@ -595,13 +662,30 @@ "seasons": "Staffeln", "episodes": "Episoden", "folders": "Ordner" + }, + "filterCategories": { + "genre": "Genre", + "year": "Jahr", + "contentRating": "Altersfreigabe", + "tag": "Tag" + }, + "sortLabels": { + "title": "Titel", + "dateAdded": "Hinzugefügt am", + "releaseDate": "Erscheinungsdatum", + "rating": "Bewertung", + "lastPlayed": "Zuletzt abgespielt", + "playCount": "Wiedergaben", + "random": "Zufällig", + "dateShared": "Datum geteilt", + "latestEpisodeAirDate": "Letztes Folgenausstrahlungsdatum" } }, "about": { "title": "Über", "openSourceLicenses": "Open-Source-Lizenzen", "versionLabel": "Version ${version}", - "appDescription": "Ein schöner Plex-Client für Flutter", + "appDescription": "Ein schöner Plex- und Jellyfin-Client für Flutter", "viewLicensesDescription": "Lizenzen von Drittanbieter-Bibliotheken anzeigen" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Warte auf andere zum Laden...", "recentRooms": "Letzte Räume", "renameRoom": "Raum umbenennen", - "removeRoom": "Entfernen" + "removeRoom": "Entfernen", + "guestSwitchUnavailable": "Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar", + "guestSwitchFailed": "Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden" }, "downloads": { "title": "Downloads", @@ -817,6 +903,12 @@ "editSyncFilter": "Synchronisierungsfilter", "syncAllItems": "Alle Einträge synchronisieren", "syncUnwatchedItems": "Ungesehene Einträge synchronisieren", + "syncRuleServerContext": "Server: ${server} • ${status}", + "syncRuleAvailable": "Verfügbar", + "syncRuleOffline": "Offline", + "syncRuleSignInRequired": "Anmeldung erforderlich", + "syncRuleNotAvailableForProfile": "Für aktuelles Profil nicht verfügbar", + "syncRuleUnknownServer": "Unbekannter Server", "syncRuleListCreated": "Sync-Regel erstellt" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Bibliotheken", "noLibraries": "Keine Bibliotheken verfügbar" } + }, + "addServer": { + "addJellyfinTitle": "Jellyfin-Server hinzufügen", + "jellyfinUrlIntro": "Gib die URL deines Jellyfin-Servers ein — z. B. `https://jellyfin.example.com`. Anmelden kannst du dich danach.", + "serverUrl": "Server-URL", + "findServer": "Server finden", + "username": "Benutzername", + "password": "Passwort", + "signIn": "Anmelden", + "change": "Ändern", + "required": "Erforderlich", + "couldNotReachServer": "Server nicht erreichbar: ${error}", + "signInFailed": "Anmeldung fehlgeschlagen: ${error}", + "quickConnectFailed": "Quick Connect fehlgeschlagen: ${error}", + "addPlexTitle": "Mit Plex anmelden", + "plexAuthIntro": "Wähle, wie du dich bei Plex anmelden möchtest. Der Browser-Ablauf öffnet plex.tv, wo du die Verbindung bestätigst; die QR-Option eignet sich für TV / Remote-Geräte.", + "plexQRPrompt": "Scanne diesen QR-Code zum Anmelden.", + "waitingForPlexConfirmation": "Warte auf Bestätigung durch plex.tv…", + "pinExpired": "PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.", + "duplicatePlexAccount": "Dieses Gerät ist bereits bei einem Plex-Konto angemeldet. Melde dich in den Einstellungen ab, um das Konto zu wechseln.", + "failedToRegisterAccount": "Konto konnte nicht registriert werden: ${error}", + "enterJellyfinUrlError": "Gib die URL deines Jellyfin-Servers ein", + "addConnectionTitle": "Verbindung hinzufügen", + "addConnectionTitleScoped": "Zu ${name} hinzufügen", + "addConnectionIntroGlobal": "Füge einen weiteren Medienserver hinzu. Du kannst Plex-Konten und Jellyfin-Server kombinieren — Inhalte aller verbundenen Backends erscheinen gemeinsam auf dem Startbildschirm.", + "addConnectionIntroScoped": "Füge einen neuen Server hinzu oder leihe einen von einem anderen Profil aus.", + "signInWithPlexCard": "Mit Plex anmelden", + "signInWithPlexCardSubtitle": "Autorisiere dieses Gerät mit deinem Plex-Konto. Mit dem Konto geteilte Server kommen automatisch mit.", + "signInWithPlexCardSubtitleScoped": "Autorisiere ein neues Plex-Konto. Dessen Home-Benutzer erscheinen als Profile.", + "connectToJellyfinCard": "Mit Jellyfin verbinden", + "connectToJellyfinCardSubtitle": "Gib die URL deines Jellyfin-Servers ein und melde dich mit Benutzername + Passwort an (Quick Connect kommt bald).", + "connectToJellyfinCardSubtitleScoped": "Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.", + "borrowFromAnotherProfile": "Von einem anderen Profil ausleihen", + "borrowFromAnotherProfileSubtitle": "Verwende eine Verbindung wieder, die bereits einem anderen Profil zugeordnet ist. Bei PIN-geschützten Quellprofilen wird die PIN abgefragt." } } diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 38f88128..500c7602 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Sign in", "signInWithPlex": "Sign in with Plex", "showQRCode": "Show QR Code", "authenticate": "Authenticate", "authenticationTimeout": "Authentication timed out. Please try again.", "scanQRToSignIn": "Scan this QR code to sign in", "waitingForAuth": "Waiting for authentication...\nPlease complete sign-in in your browser.", - "useBrowser": "Use browser" + "useBrowser": "Use browser", + "or": "or", + "connectToJellyfin": "Connect to Jellyfin", + "useQuickConnect": "Use Quick Connect", + "quickConnectCode": "Quick Connect code", + "quickConnectInstructions": "Open your Jellyfin server in a web browser, sign in, and choose Quick Connect from the user menu. Enter this code to approve sign-in.", + "quickConnectWaiting": "Waiting for approval…", + "quickConnectCancel": "Cancel", + "quickConnectExpired": "Quick Connect code expired before approval. Please try again." }, "common": { "cancel": "Cancel", @@ -104,12 +113,12 @@ "gridView": "Grid", "listView": "List", "showHeroSection": "Show Hero Section", - "useGlobalHubs": "Use Plex Home Layout", - "useGlobalHubsDescription": "Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Show Server Name on Hubs", "showServerNameOnHubsDescription": "Always display the server name in hub titles. When off, only shows for duplicate hub names.", "groupLibrariesByServer": "Group Libraries by Server", - "groupLibrariesByServerDescription": "Show a header for each Plex server in the sidebar when you're connected to multiple servers.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Always Keep Sidebar Open", "alwaysKeepSidebarOpenDescription": "Sidebar stays expanded and content area adjusts to fit", "showUnwatchedCount": "Show Unwatched Count", @@ -452,7 +461,7 @@ "musicNotSupported": "Music playback is not yet supported", "noDescriptionAvailable": "No description available", "noProfilesAvailable": "No profiles available", - "contactAdminForProfiles": "Contact your Plex administrator to add profiles", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Unable to determine library section for this item", "logsCleared": "Logs cleared", "logsCopied": "Logs copied to clipboard", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Confirm Action" }, + "profiles": { + "addPlezyProfile": "Add Plezy profile", + "switchingProfile": "Switching profile…", + "deleteThisProfileTitle": "Delete this profile?", + "deleteThisProfileMessage": "${displayName} will be removed. Connections themselves are not affected.", + "active": "Active", + "manage": "Manage", + "delete": "Delete", + "signOut": "Sign out", + "signOutPlexTitle": "Sign out of Plex?", + "signOutPlexMessage": "${displayName} and every Plex Home user on this account will be removed from this device. You can sign back in any time.", + "signedOutPlex": "Signed out of Plex.", + "signOutFailed": "Sign out failed.", + "sectionTitle": "Profiles", + "summarySingle": "Add profiles to mix managed users and local identities", + "summaryMultipleWithActive": "${count} profiles · active: ${activeName}", + "summaryMultiple": "${count} profiles", + "removeConnectionTitle": "Remove connection?", + "removeConnectionMessage": "${displayName} will lose access to ${connectionLabel}. The connection itself stays available to other profiles.", + "deleteProfileTitle": "Delete profile?", + "deleteProfileMessage": "This removes ${displayName} and all its connections from this device. The underlying Plex/Jellyfin servers aren't affected.", + "profileNameLabel": "Profile name", + "pinProtectionLabel": "PIN protection", + "pinManagedByPlex": "PIN managed by Plex. Edit on plex.tv.", + "noPinSetEditOnPlex": "No PIN set. To require one, edit the home user on plex.tv.", + "setPin": "Set PIN", + "connectionsLabel": "Connections", + "add": "Add", + "deleteProfileButton": "Delete profile", + "noConnectionsHint": "No connections — add one to use this profile.", + "plexHomeAccount": "Plex Home account", + "connectionDefault": "Default", + "makeDefault": "Make default", + "removeConnection": "Remove", + "borrowAddTo": "Add to ${displayName}", + "borrowExplain": "Borrow a connection from another profile. PIN-protected source profiles ask for the PIN before sharing.", + "borrowEmpty": "Nothing to borrow yet.", + "borrowEmptySubtitle": "Connect a Plex account or Jellyfin server to another profile first, then come back here.", + "newProfile": "New profile", + "profileNameHint": "e.g. Guests, Kids, Family Room", + "pinProtectionOptional": "PIN protection (optional)", + "pinExplain": "4-digit PIN required to switch into this profile. Soft barrier — anyone who can clear app data can bypass it.", + "continueButton": "Continue", + "pinsDontMatch": "PINs don't match" + }, + "connections": { + "sectionTitle": "Connections", + "addConnection": "Add connection", + "addConnectionSubtitleNoProfile": "Sign in with Plex or connect a Jellyfin server", + "addConnectionSubtitleScoped": "Add to ${displayName} — Plex account, Jellyfin server, or borrow from another profile", + "sessionExpiredOne": "Session expired for ${name}", + "sessionExpiredMany": "Session expired for ${count} servers", + "signInAgain": "Sign in again" + }, "discover": { "title": "Discover", "switchProfile": "Switch Profile", "noContentAvailable": "No content available", "addMediaToLibraries": "Add some media to your libraries", "continueWatching": "Continue Watching", + "nextUp": "Next Up", + "recentlyAdded": "Recently Added", "playEpisode": "S${season}E${episode}", "overview": "Overview", "cast": "Cast", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Search failed: ${error}", "connectionTimeout": "Connection timeout while loading ${context}", - "connectionFailed": "Unable to connect to Plex server", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Failed to load ${context}: ${error}", "noClientAvailable": "No client available", "authenticationFailed": "Authentication failed: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Please enter a token", "invalidToken": "Invalid token", "failedToVerifyToken": "Failed to verify token: ${error}", - "failedToSwitchProfile": "Failed to switch to ${displayName}" + "failedToSwitchProfile": "Failed to switch to ${displayName}", + "failedToDeleteProfile": "Failed to delete ${displayName}", + "failedToRate": "Couldn't update rating" }, "libraries": { "title": "Libraries", @@ -595,13 +662,30 @@ "seasons": "Seasons", "episodes": "Episodes", "folders": "Folders" + }, + "filterCategories": { + "genre": "Genre", + "year": "Year", + "contentRating": "Content Rating", + "tag": "Tag" + }, + "sortLabels": { + "title": "Title", + "dateAdded": "Date Added", + "releaseDate": "Release Date", + "rating": "Rating", + "lastPlayed": "Last Played", + "playCount": "Play Count", + "random": "Random", + "dateShared": "Date Shared", + "latestEpisodeAirDate": "Latest Episode Air Date" } }, "about": { "title": "About", "openSourceLicenses": "Open Source Licenses", "versionLabel": "Version ${version}", - "appDescription": "A beautiful Plex client for Flutter", + "appDescription": "A beautiful Plex and Jellyfin client for Flutter", "viewLicensesDescription": "View licenses of third-party libraries" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Waiting for others to load...", "recentRooms": "Recent Rooms", "renameRoom": "Rename Room", - "removeRoom": "Remove" + "removeRoom": "Remove", + "guestSwitchUnavailable": "Couldn't switch — server unavailable for sync", + "guestSwitchFailed": "Couldn't switch — content not found on this server" }, "downloads": { "title": "Downloads", @@ -817,6 +903,12 @@ "editSyncFilter": "Sync filter", "syncAllItems": "Syncing all items", "syncUnwatchedItems": "Syncing unwatched items", + "syncRuleServerContext": "Server: ${server} • ${status}", + "syncRuleAvailable": "Available", + "syncRuleOffline": "Offline", + "syncRuleSignInRequired": "Sign in required", + "syncRuleNotAvailableForProfile": "Not available for current profile", + "syncRuleUnknownServer": "Unknown server", "syncRuleListCreated": "Sync rule created" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Libraries", "noLibraries": "No libraries available" } + }, + "addServer": { + "addJellyfinTitle": "Add Jellyfin server", + "jellyfinUrlIntro": "Enter your Jellyfin server URL — e.g. `https://jellyfin.example.com`. You can sign in afterwards.", + "serverUrl": "Server URL", + "findServer": "Find server", + "username": "Username", + "password": "Password", + "signIn": "Sign in", + "change": "Change", + "required": "Required", + "couldNotReachServer": "Could not reach the server: ${error}", + "signInFailed": "Sign-in failed: ${error}", + "quickConnectFailed": "Quick Connect failed: ${error}", + "addPlexTitle": "Sign in with Plex", + "plexAuthIntro": "Choose how to sign in to Plex. The browser flow opens plex.tv where you confirm the connection; the QR option is handy for TV / remote devices.", + "plexQRPrompt": "Scan this QR code to sign in.", + "waitingForPlexConfirmation": "Waiting for plex.tv to confirm your sign-in…", + "pinExpired": "PIN expired before sign-in. Please try again.", + "duplicatePlexAccount": "This device is already signed in to a Plex account. Sign out from settings to switch accounts.", + "failedToRegisterAccount": "Failed to register account: ${error}", + "enterJellyfinUrlError": "Enter your Jellyfin server URL", + "addConnectionTitle": "Add connection", + "addConnectionTitleScoped": "Add to ${name}", + "addConnectionIntroGlobal": "Add another media server. You can mix Plex accounts and Jellyfin servers — items from every connected backend appear together on the home screen.", + "addConnectionIntroScoped": "Add a new server, or borrow one from another profile.", + "signInWithPlexCard": "Sign in with Plex", + "signInWithPlexCardSubtitle": "Authorize this device against your Plex account. Servers shared with the account come along automatically.", + "signInWithPlexCardSubtitleScoped": "Authorize a new Plex account. Its Home users appear as profiles.", + "connectToJellyfinCard": "Connect to Jellyfin", + "connectToJellyfinCardSubtitle": "Enter your Jellyfin server URL and sign in with username + password (Quick Connect coming soon).", + "connectToJellyfinCardSubtitleScoped": "Sign in to a Jellyfin server. Binds to ${name}.", + "borrowFromAnotherProfile": "Borrow from another profile", + "borrowFromAnotherProfileSubtitle": "Reuse a connection that's already attached to a different profile. PIN-protected source profiles ask for the PIN." } } diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index d82fd2fa..55b7b905 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Iniciar sesión", "signInWithPlex": "Inicia sesión con Plex", "showQRCode": "Mostrar código QR", "authenticate": "Autenticar", "authenticationTimeout": "Tiempo de autenticación agotado. Por favor, intenta de nuevo.", "scanQRToSignIn": "Escanea este código QR para iniciar sesión", "waitingForAuth": "Esperando autenticación...\nPor favor completa el inicio de sesión en tu navegador.", - "useBrowser": "Usar navegador" + "useBrowser": "Usar navegador", + "or": "o", + "connectToJellyfin": "Conectar a Jellyfin", + "useQuickConnect": "Usar Quick Connect", + "quickConnectCode": "Código de Quick Connect", + "quickConnectInstructions": "Abre tu servidor Jellyfin en un navegador web, inicia sesión y selecciona Quick Connect en el menú de usuario. Introduce este código para aprobar el inicio de sesión.", + "quickConnectWaiting": "Esperando aprobación…", + "quickConnectCancel": "Cancelar", + "quickConnectExpired": "El código de Quick Connect caducó antes de ser aprobado. Inténtalo de nuevo." }, "common": { "cancel": "Cancelar", @@ -104,12 +113,12 @@ "gridView": "Cuadrícula", "listView": "Lista", "showHeroSection": "Mostrar Sección Destacada", - "useGlobalHubs": "Usar Diseño de Inicio de Plex", - "useGlobalHubsDescription": "Mostrar los hubs de la página de inicio como el cliente oficial de Plex. Cuando está desactivado, muestra recomendaciones por biblioteca en su lugar.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Mostrar Nombre del Servidor en los Hubs", "showServerNameOnHubsDescription": "Mostrar siempre el nombre del servidor en los títulos de los hubs. Cuando está desactivado, solo se muestra para nombres de hubs duplicados.", "groupLibrariesByServer": "Agrupar bibliotecas por servidor", - "groupLibrariesByServerDescription": "Muestra un encabezado para cada servidor Plex en la barra lateral cuando estás conectado a varios servidores.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Mantener siempre la barra lateral abierta", "alwaysKeepSidebarOpenDescription": "La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse", "showUnwatchedCount": "Mostrar conteo de no vistos", @@ -452,7 +461,7 @@ "musicNotSupported": "La reproducción de música aún no está soportada", "noDescriptionAvailable": "No hay descripción disponible", "noProfilesAvailable": "No hay perfiles disponibles", - "contactAdminForProfiles": "Contacta con tu administrador de Plex para añadir perfiles", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "No se puede determinar la sección de biblioteca para este elemento", "logsCleared": "Logs borrados", "logsCopied": "Logs copiados al portapapeles", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Confirmar Acción" }, + "profiles": { + "addPlezyProfile": "Añadir perfil de Plezy", + "switchingProfile": "Cambiando de perfil…", + "deleteThisProfileTitle": "¿Eliminar este perfil?", + "deleteThisProfileMessage": "${displayName} se eliminará. Las conexiones no se verán afectadas.", + "active": "Activo", + "manage": "Administrar", + "delete": "Eliminar", + "signOut": "Cerrar sesión", + "signOutPlexTitle": "¿Cerrar sesión de Plex?", + "signOutPlexMessage": "${displayName} y todos los usuarios de Plex Home de esta cuenta se eliminarán de este dispositivo. Puedes volver a iniciar sesión en cualquier momento.", + "signedOutPlex": "Sesión de Plex cerrada.", + "signOutFailed": "Error al cerrar sesión.", + "sectionTitle": "Perfiles", + "summarySingle": "Añade perfiles para mezclar usuarios gestionados e identidades locales", + "summaryMultipleWithActive": "${count} perfiles · activo: ${activeName}", + "summaryMultiple": "${count} perfiles", + "removeConnectionTitle": "¿Eliminar conexión?", + "removeConnectionMessage": "${displayName} perderá acceso a ${connectionLabel}. La conexión seguirá disponible para otros perfiles.", + "deleteProfileTitle": "¿Eliminar perfil?", + "deleteProfileMessage": "Esto elimina ${displayName} y todas sus conexiones de este dispositivo. Los servidores Plex/Jellyfin subyacentes no se ven afectados.", + "profileNameLabel": "Nombre del perfil", + "pinProtectionLabel": "Protección con PIN", + "pinManagedByPlex": "PIN gestionado por Plex. Edita en plex.tv.", + "noPinSetEditOnPlex": "Sin PIN establecido. Para requerir uno, edita el usuario Home en plex.tv.", + "setPin": "Establecer PIN", + "connectionsLabel": "Conexiones", + "add": "Añadir", + "deleteProfileButton": "Eliminar perfil", + "noConnectionsHint": "Sin conexiones — añade una para usar este perfil.", + "plexHomeAccount": "Cuenta Plex Home", + "connectionDefault": "Predeterminada", + "makeDefault": "Establecer como predeterminada", + "removeConnection": "Eliminar", + "borrowAddTo": "Añadir a ${displayName}", + "borrowExplain": "Toma prestada una conexión de otro perfil. Los perfiles de origen protegidos con PIN piden el PIN antes de compartir.", + "borrowEmpty": "Nada para tomar prestado todavía.", + "borrowEmptySubtitle": "Conecta primero una cuenta Plex o un servidor Jellyfin a otro perfil y vuelve aquí.", + "newProfile": "Nuevo perfil", + "profileNameHint": "p. ej. Invitados, Niños, Sala familiar", + "pinProtectionOptional": "Protección con PIN (opcional)", + "pinExplain": "Se requiere un PIN de 4 dígitos para cambiar a este perfil. Barrera blanda — cualquiera que pueda borrar los datos de la app puede saltarla.", + "continueButton": "Continuar", + "pinsDontMatch": "Los PIN no coinciden" + }, + "connections": { + "sectionTitle": "Conexiones", + "addConnection": "Añadir conexión", + "addConnectionSubtitleNoProfile": "Inicia sesión con Plex o conecta un servidor de Jellyfin", + "addConnectionSubtitleScoped": "Añadir a ${displayName} — cuenta de Plex, servidor de Jellyfin o tomar prestado de otro perfil", + "sessionExpiredOne": "Sesión caducada para ${name}", + "sessionExpiredMany": "Sesión caducada para ${count} servidores", + "signInAgain": "Iniciar sesión de nuevo" + }, "discover": { "title": "Descubrir", "switchProfile": "Cambiar Perfil", "noContentAvailable": "No hay contenido disponible", "addMediaToLibraries": "Añade contenido a tus bibliotecas", "continueWatching": "Seguir Viendo", + "nextUp": "A continuación", + "recentlyAdded": "Añadido recientemente", "playEpisode": "T${season}E${episode}", "overview": "Resumen", "cast": "Reparto", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Error en la búsqueda: ${error}", "connectionTimeout": "Tiempo de conexión agotado al cargar ${context}", - "connectionFailed": "No se pudo conectar con el servidor Plex", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Error al cargar ${context}: ${error}", "noClientAvailable": "No hay cliente disponible", "authenticationFailed": "Error de autenticación: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Por favor, introduce un token", "invalidToken": "Token no válido", "failedToVerifyToken": "Error al verificar el token: ${error}", - "failedToSwitchProfile": "Error al cambiar al perfil ${displayName}" + "failedToSwitchProfile": "Error al cambiar al perfil ${displayName}", + "failedToDeleteProfile": "Error al eliminar ${displayName}", + "failedToRate": "No se pudo actualizar la calificación" }, "libraries": { "title": "Bibliotecas", @@ -595,13 +662,30 @@ "seasons": "Temporadas", "episodes": "Episodios", "folders": "Carpetas" + }, + "filterCategories": { + "genre": "Género", + "year": "Año", + "contentRating": "Clasificación", + "tag": "Etiqueta" + }, + "sortLabels": { + "title": "Título", + "dateAdded": "Fecha de adición", + "releaseDate": "Fecha de estreno", + "rating": "Valoración", + "lastPlayed": "Última reproducción", + "playCount": "Reproducciones", + "random": "Aleatorio", + "dateShared": "Fecha de compartición", + "latestEpisodeAirDate": "Última fecha de emisión del episodio" } }, "about": { "title": "Acerca de", "openSourceLicenses": "Licencias de Código Abierto", "versionLabel": "Versión ${version}", - "appDescription": "Un cliente de Plex para Flutter", + "appDescription": "Un cliente de Plex y Jellyfin para Flutter", "viewLicensesDescription": "Ver licencias de librerías de terceros" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Esperando a que otros carguen...", "recentRooms": "Salas recientes", "renameRoom": "Renombrar sala", - "removeRoom": "Eliminar" + "removeRoom": "Eliminar", + "guestSwitchUnavailable": "No se pudo cambiar — servidor no disponible para sincronización", + "guestSwitchFailed": "No se pudo cambiar — contenido no encontrado en este servidor" }, "downloads": { "title": "Descargas", @@ -817,6 +903,12 @@ "editSyncFilter": "Filtro de sincronización", "syncAllItems": "Sincronizando todos los elementos", "syncUnwatchedItems": "Sincronizando elementos no vistos", + "syncRuleServerContext": "Servidor: ${server} • ${status}", + "syncRuleAvailable": "Disponible", + "syncRuleOffline": "Sin conexión", + "syncRuleSignInRequired": "Se requiere iniciar sesión", + "syncRuleNotAvailableForProfile": "No disponible para el perfil actual", + "syncRuleUnknownServer": "Servidor desconocido", "syncRuleListCreated": "Regla de sincronización creada" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Bibliotecas", "noLibraries": "No hay bibliotecas disponibles" } + }, + "addServer": { + "addJellyfinTitle": "Añadir servidor Jellyfin", + "jellyfinUrlIntro": "Introduce la URL de tu servidor Jellyfin — p. ej. `https://jellyfin.example.com`. Podrás iniciar sesión después.", + "serverUrl": "URL del servidor", + "findServer": "Buscar servidor", + "username": "Usuario", + "password": "Contraseña", + "signIn": "Iniciar sesión", + "change": "Cambiar", + "required": "Obligatorio", + "couldNotReachServer": "No se pudo conectar con el servidor: ${error}", + "signInFailed": "Error al iniciar sesión: ${error}", + "quickConnectFailed": "Quick Connect ha fallado: ${error}", + "addPlexTitle": "Iniciar sesión con Plex", + "plexAuthIntro": "Elige cómo iniciar sesión en Plex. El flujo del navegador abre plex.tv, donde confirmas la conexión; la opción QR es práctica para TV o dispositivos remotos.", + "plexQRPrompt": "Escanea este código QR para iniciar sesión.", + "waitingForPlexConfirmation": "Esperando que plex.tv confirme tu inicio de sesión…", + "pinExpired": "El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.", + "duplicatePlexAccount": "Este dispositivo ya está conectado a una cuenta de Plex. Cierra sesión desde los ajustes para cambiar de cuenta.", + "failedToRegisterAccount": "No se pudo registrar la cuenta: ${error}", + "enterJellyfinUrlError": "Introduce la URL de tu servidor Jellyfin", + "addConnectionTitle": "Añadir conexión", + "addConnectionTitleScoped": "Añadir a ${name}", + "addConnectionIntroGlobal": "Añade otro servidor multimedia. Puedes combinar cuentas de Plex y servidores Jellyfin — el contenido de cada backend conectado aparece junto en la pantalla de inicio.", + "addConnectionIntroScoped": "Añade un servidor nuevo o toma prestado uno de otro perfil.", + "signInWithPlexCard": "Iniciar sesión con Plex", + "signInWithPlexCardSubtitle": "Autoriza este dispositivo en tu cuenta de Plex. Los servidores compartidos con la cuenta se añaden automáticamente.", + "signInWithPlexCardSubtitleScoped": "Autoriza una nueva cuenta de Plex. Sus usuarios Home aparecen como perfiles.", + "connectToJellyfinCard": "Conectar a Jellyfin", + "connectToJellyfinCardSubtitle": "Introduce la URL de tu servidor Jellyfin e inicia sesión con usuario + contraseña (Quick Connect próximamente).", + "connectToJellyfinCardSubtitleScoped": "Inicia sesión en un servidor Jellyfin. Se vincula a ${name}.", + "borrowFromAnotherProfile": "Tomar prestado de otro perfil", + "borrowFromAnotherProfileSubtitle": "Reutiliza una conexión ya asociada a otro perfil. Los perfiles de origen protegidos con PIN piden el PIN." } } diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 531dfced..2e4adec6 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Se connecter", "signInWithPlex": "S'inscrire avec Plex", "showQRCode": "Afficher le QR Code", "authenticate": "S'authentifier", "authenticationTimeout": "Délai d'authentification expiré. Veuillez réessayer.", "scanQRToSignIn": "Scannez ce QR code pour vous connecter", "waitingForAuth": "En attente d'authentification...\nVeuillez vous connecter dans votre navigateur.", - "useBrowser": "Utiliser le navigateur" + "useBrowser": "Utiliser le navigateur", + "or": "ou", + "connectToJellyfin": "Se connecter à Jellyfin", + "useQuickConnect": "Utiliser Quick Connect", + "quickConnectCode": "Code Quick Connect", + "quickConnectInstructions": "Ouvrez votre serveur Jellyfin dans un navigateur, connectez-vous et choisissez Quick Connect dans le menu utilisateur. Saisissez ce code pour valider la connexion.", + "quickConnectWaiting": "En attente d'approbation…", + "quickConnectCancel": "Annuler", + "quickConnectExpired": "Le code Quick Connect a expiré avant l'approbation. Veuillez réessayer." }, "common": { "cancel": "Annuler", @@ -104,12 +113,12 @@ "gridView": "Grille", "listView": "Liste", "showHeroSection": "Afficher la section Hero", - "useGlobalHubs": "Utiliser la disposition Plex Home", - "useGlobalHubsDescription": "Afficher les hubs de la page d'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Afficher le nom du serveur sur les hubs", "showServerNameOnHubsDescription": "Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s'affichent.", "groupLibrariesByServer": "Grouper les bibliothèques par serveur", - "groupLibrariesByServerDescription": "Affiche un en-tête pour chaque serveur Plex dans la barre latérale lorsque vous êtes connecté à plusieurs serveurs.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Toujours garder la barre latérale ouverte", "alwaysKeepSidebarOpenDescription": "La barre latérale reste étendue et la zone de contenu s'adapte", "showUnwatchedCount": "Afficher le nombre non visionné", @@ -452,7 +461,7 @@ "musicNotSupported": "La lecture de musique n'est pas encore prise en charge", "noDescriptionAvailable": "Aucune description disponible", "noProfilesAvailable": "Aucun profil disponible", - "contactAdminForProfiles": "Contactez votre administrateur Plex pour ajouter des profils", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Impossible de déterminer la section de la bibliothèque pour cet élément", "logsCleared": "Logs effacés", "logsCopied": "Logs copiés dans le presse-papier", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Confirmer l'action" }, + "profiles": { + "addPlezyProfile": "Ajouter un profil Plezy", + "switchingProfile": "Changement de profil…", + "deleteThisProfileTitle": "Supprimer ce profil ?", + "deleteThisProfileMessage": "${displayName} sera supprimé. Les connexions ne seront pas affectées.", + "active": "Actif", + "manage": "Gérer", + "delete": "Supprimer", + "signOut": "Se déconnecter", + "signOutPlexTitle": "Se déconnecter de Plex ?", + "signOutPlexMessage": "${displayName} et tous les utilisateurs Plex Home de ce compte seront supprimés de cet appareil. Vous pouvez vous reconnecter à tout moment.", + "signedOutPlex": "Déconnecté de Plex.", + "signOutFailed": "Échec de la déconnexion.", + "sectionTitle": "Profils", + "summarySingle": "Ajoutez des profils pour mélanger utilisateurs gérés et identités locales", + "summaryMultipleWithActive": "${count} profils · actif : ${activeName}", + "summaryMultiple": "${count} profils", + "removeConnectionTitle": "Retirer la connexion ?", + "removeConnectionMessage": "${displayName} perdra l'accès à ${connectionLabel}. La connexion reste disponible pour les autres profils.", + "deleteProfileTitle": "Supprimer le profil ?", + "deleteProfileMessage": "Cela supprime ${displayName} et toutes ses connexions de cet appareil. Les serveurs Plex/Jellyfin sous-jacents ne sont pas affectés.", + "profileNameLabel": "Nom du profil", + "pinProtectionLabel": "Protection par code PIN", + "pinManagedByPlex": "PIN géré par Plex. Modifier sur plex.tv.", + "noPinSetEditOnPlex": "Aucun PIN défini. Pour en exiger un, modifiez l'utilisateur Home sur plex.tv.", + "setPin": "Définir un PIN", + "connectionsLabel": "Connexions", + "add": "Ajouter", + "deleteProfileButton": "Supprimer le profil", + "noConnectionsHint": "Aucune connexion — ajoutez-en une pour utiliser ce profil.", + "plexHomeAccount": "Compte Plex Home", + "connectionDefault": "Par défaut", + "makeDefault": "Définir par défaut", + "removeConnection": "Retirer", + "borrowAddTo": "Ajouter à ${displayName}", + "borrowExplain": "Empruntez une connexion à un autre profil. Les profils sources protégés par PIN demandent le PIN avant de partager.", + "borrowEmpty": "Rien à emprunter pour le moment.", + "borrowEmptySubtitle": "Connectez d'abord un compte Plex ou un serveur Jellyfin à un autre profil, puis revenez ici.", + "newProfile": "Nouveau profil", + "profileNameHint": "ex. Invités, Enfants, Salon familial", + "pinProtectionOptional": "Protection par PIN (optionnelle)", + "pinExplain": "PIN à 4 chiffres requis pour basculer sur ce profil. Barrière souple — quiconque peut effacer les données de l'app peut la contourner.", + "continueButton": "Continuer", + "pinsDontMatch": "Les PIN ne correspondent pas" + }, + "connections": { + "sectionTitle": "Connexions", + "addConnection": "Ajouter une connexion", + "addConnectionSubtitleNoProfile": "Connectez-vous avec Plex ou connectez un serveur Jellyfin", + "addConnectionSubtitleScoped": "Ajouter à ${displayName} — compte Plex, serveur Jellyfin ou emprunter à un autre profil", + "sessionExpiredOne": "Session expirée pour ${name}", + "sessionExpiredMany": "Session expirée pour ${count} serveurs", + "signInAgain": "Se reconnecter" + }, "discover": { "title": "Découvrez", "switchProfile": "Changer de profil", "noContentAvailable": "Aucun contenu disponible", "addMediaToLibraries": "Ajoutez des médias à votre bibliothèque", "continueWatching": "Continuer à regarder", + "nextUp": "À suivre", + "recentlyAdded": "Récemment ajouté", "playEpisode": "S${season}E${episode}", "overview": "Aperçu", "cast": "Cast", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Recherche échouée: ${error}", "connectionTimeout": "Délai d'attente de connexion dépassé pendant le chargement ${context}", - "connectionFailed": "Impossible de se connecter au serveur Plex", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Échec du chargement ${context}: ${error}", "noClientAvailable": "Aucun client disponible", "authenticationFailed": "Échec de l'authentification: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Veuillez saisir un token", "invalidToken": "Token invalide", "failedToVerifyToken": "Échec de la vérification du token: ${error}", - "failedToSwitchProfile": "Impossible de changer de profil vers ${displayName}" + "failedToSwitchProfile": "Impossible de changer de profil vers ${displayName}", + "failedToDeleteProfile": "Impossible de supprimer ${displayName}", + "failedToRate": "Impossible de mettre à jour la note" }, "libraries": { "title": "Bibliothèques", @@ -595,13 +662,30 @@ "seasons": "Saisons", "episodes": "Épisodes", "folders": "Dossiers" + }, + "filterCategories": { + "genre": "Genre", + "year": "Année", + "contentRating": "Classification", + "tag": "Tag" + }, + "sortLabels": { + "title": "Titre", + "dateAdded": "Date d'ajout", + "releaseDate": "Date de sortie", + "rating": "Note", + "lastPlayed": "Dernière lecture", + "playCount": "Lectures", + "random": "Aléatoire", + "dateShared": "Date de partage", + "latestEpisodeAirDate": "Dernière date de diffusion" } }, "about": { "title": "À propos", "openSourceLicenses": "Licences Open Source", "versionLabel": "Version ${version}", - "appDescription": "Un magnifique client Plex pour Flutter", + "appDescription": "Un magnifique client Plex et Jellyfin pour Flutter", "viewLicensesDescription": "Afficher les licences des bibliothèques tierces" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "En attente du chargement des autres...", "recentRooms": "Salons récents", "renameRoom": "Renommer le salon", - "removeRoom": "Supprimer" + "removeRoom": "Supprimer", + "guestSwitchUnavailable": "Impossible de changer — serveur indisponible pour la synchronisation", + "guestSwitchFailed": "Impossible de changer — contenu introuvable sur ce serveur" }, "downloads": { "title": "Téléchargements", @@ -817,6 +903,12 @@ "editSyncFilter": "Filtre de synchronisation", "syncAllItems": "Synchronisation de tous les éléments", "syncUnwatchedItems": "Synchronisation des éléments non vus", + "syncRuleServerContext": "Serveur : ${server} • ${status}", + "syncRuleAvailable": "Disponible", + "syncRuleOffline": "Hors ligne", + "syncRuleSignInRequired": "Connexion requise", + "syncRuleNotAvailableForProfile": "Non disponible pour le profil actuel", + "syncRuleUnknownServer": "Serveur inconnu", "syncRuleListCreated": "Règle de synchronisation créée" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Bibliothèques", "noLibraries": "Aucune bibliothèque disponible" } + }, + "addServer": { + "addJellyfinTitle": "Ajouter un serveur Jellyfin", + "jellyfinUrlIntro": "Saisissez l'URL de votre serveur Jellyfin — p. ex. `https://jellyfin.example.com`. Vous pourrez vous connecter ensuite.", + "serverUrl": "URL du serveur", + "findServer": "Rechercher un serveur", + "username": "Nom d'utilisateur", + "password": "Mot de passe", + "signIn": "Se connecter", + "change": "Modifier", + "required": "Requis", + "couldNotReachServer": "Impossible de joindre le serveur : ${error}", + "signInFailed": "Échec de la connexion : ${error}", + "quickConnectFailed": "Échec de Quick Connect : ${error}", + "addPlexTitle": "Se connecter avec Plex", + "plexAuthIntro": "Choisissez comment vous connecter à Plex. Le flux navigateur ouvre plex.tv où vous confirmez la connexion ; l'option QR est pratique pour la TV ou les appareils distants.", + "plexQRPrompt": "Scannez ce QR code pour vous connecter.", + "waitingForPlexConfirmation": "En attente de la confirmation de plex.tv…", + "pinExpired": "Le PIN a expiré avant la connexion. Veuillez réessayer.", + "duplicatePlexAccount": "Cet appareil est déjà connecté à un compte Plex. Déconnectez-vous depuis les paramètres pour changer de compte.", + "failedToRegisterAccount": "Échec de l'enregistrement du compte : ${error}", + "enterJellyfinUrlError": "Saisissez l'URL de votre serveur Jellyfin", + "addConnectionTitle": "Ajouter une connexion", + "addConnectionTitleScoped": "Ajouter à ${name}", + "addConnectionIntroGlobal": "Ajoutez un autre serveur média. Vous pouvez mélanger comptes Plex et serveurs Jellyfin — les contenus de tous les backends connectés apparaissent ensemble sur l'écran d'accueil.", + "addConnectionIntroScoped": "Ajoutez un nouveau serveur, ou empruntez-en un à un autre profil.", + "signInWithPlexCard": "Se connecter avec Plex", + "signInWithPlexCardSubtitle": "Autorisez cet appareil avec votre compte Plex. Les serveurs partagés avec le compte suivent automatiquement.", + "signInWithPlexCardSubtitleScoped": "Autorisez un nouveau compte Plex. Ses utilisateurs Home apparaissent comme profils.", + "connectToJellyfinCard": "Se connecter à Jellyfin", + "connectToJellyfinCardSubtitle": "Saisissez l'URL de votre serveur Jellyfin et connectez-vous avec nom d'utilisateur + mot de passe (Quick Connect bientôt disponible).", + "connectToJellyfinCardSubtitleScoped": "Connectez-vous à un serveur Jellyfin. Lié à ${name}.", + "borrowFromAnotherProfile": "Emprunter à un autre profil", + "borrowFromAnotherProfileSubtitle": "Réutilisez une connexion déjà associée à un autre profil. Les profils source protégés par PIN demandent le PIN." } } diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index f5a1a88d..e4db8b5d 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Accedi", "signInWithPlex": "Accedi con Plex", "showQRCode": "Mostra QR Code", "authenticate": "Autenticazione", "authenticationTimeout": "Autenticazione scaduta. Riprova.", "scanQRToSignIn": "Scansiona il QR code per accedere", "waitingForAuth": "In attesa di autenticazione...\nCompleta l'accesso dal tuo browser.", - "useBrowser": "Usa browser" + "useBrowser": "Usa browser", + "or": "o", + "connectToJellyfin": "Connetti a Jellyfin", + "useQuickConnect": "Usa Quick Connect", + "quickConnectCode": "Codice Quick Connect", + "quickConnectInstructions": "Apri il tuo server Jellyfin in un browser, accedi e scegli Quick Connect dal menu utente. Inserisci questo codice per approvare l'accesso.", + "quickConnectWaiting": "In attesa di approvazione…", + "quickConnectCancel": "Annulla", + "quickConnectExpired": "Il codice Quick Connect è scaduto prima dell'approvazione. Riprova." }, "common": { "cancel": "Cancella", @@ -104,12 +113,12 @@ "gridView": "Griglia", "listView": "Elenco", "showHeroSection": "Mostra sezione principale", - "useGlobalHubs": "Usa layout Home di Plex", - "useGlobalHubsDescription": "Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Mostra nome server sugli hub", "showServerNameOnHubsDescription": "Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.", "groupLibrariesByServer": "Raggruppa librerie per server", - "groupLibrariesByServerDescription": "Mostra un'intestazione per ogni server Plex nella barra laterale quando sei connesso a più server.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Mantieni sempre aperta la barra laterale", "alwaysKeepSidebarOpenDescription": "La barra laterale rimane espansa e l'area del contenuto si adatta", "showUnwatchedCount": "Mostra conteggio non visti", @@ -452,7 +461,7 @@ "musicNotSupported": "La riproduzione musicale non è ancora supportata", "noDescriptionAvailable": "Nessuna descrizione disponibile", "noProfilesAvailable": "Nessun profilo disponibile", - "contactAdminForProfiles": "Contatta il tuo amministratore Plex per aggiungere profili", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Impossibile determinare la sezione della libreria per questo elemento", "logsCleared": "Log eliminati", "logsCopied": "Log copiati negli appunti", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Conferma azione" }, + "profiles": { + "addPlezyProfile": "Aggiungi profilo Plezy", + "switchingProfile": "Cambio profilo…", + "deleteThisProfileTitle": "Eliminare questo profilo?", + "deleteThisProfileMessage": "${displayName} verrà rimosso. Le connessioni non saranno influenzate.", + "active": "Attivo", + "manage": "Gestisci", + "delete": "Elimina", + "signOut": "Esci", + "signOutPlexTitle": "Uscire da Plex?", + "signOutPlexMessage": "${displayName} e tutti gli utenti Plex Home di questo account verranno rimossi da questo dispositivo. Puoi accedere di nuovo in qualsiasi momento.", + "signedOutPlex": "Uscito da Plex.", + "signOutFailed": "Uscita non riuscita.", + "sectionTitle": "Profili", + "summarySingle": "Aggiungi profili per combinare utenti gestiti e identità locali", + "summaryMultipleWithActive": "${count} profili · attivo: ${activeName}", + "summaryMultiple": "${count} profili", + "removeConnectionTitle": "Rimuovere la connessione?", + "removeConnectionMessage": "${displayName} perderà l'accesso a ${connectionLabel}. La connessione resta disponibile per gli altri profili.", + "deleteProfileTitle": "Eliminare il profilo?", + "deleteProfileMessage": "Questo rimuove ${displayName} e tutte le sue connessioni da questo dispositivo. I server Plex/Jellyfin sottostanti non sono interessati.", + "profileNameLabel": "Nome profilo", + "pinProtectionLabel": "Protezione PIN", + "pinManagedByPlex": "PIN gestito da Plex. Modifica su plex.tv.", + "noPinSetEditOnPlex": "Nessun PIN impostato. Per richiederne uno, modifica l'utente Home su plex.tv.", + "setPin": "Imposta PIN", + "connectionsLabel": "Connessioni", + "add": "Aggiungi", + "deleteProfileButton": "Elimina profilo", + "noConnectionsHint": "Nessuna connessione — aggiungine una per usare questo profilo.", + "plexHomeAccount": "Account Plex Home", + "connectionDefault": "Predefinita", + "makeDefault": "Imposta come predefinita", + "removeConnection": "Rimuovi", + "borrowAddTo": "Aggiungi a ${displayName}", + "borrowExplain": "Prendi in prestito una connessione da un altro profilo. I profili sorgente protetti da PIN richiedono il PIN prima di condividere.", + "borrowEmpty": "Nulla da prendere in prestito al momento.", + "borrowEmptySubtitle": "Collega prima un account Plex o un server Jellyfin a un altro profilo, poi torna qui.", + "newProfile": "Nuovo profilo", + "profileNameHint": "es. Ospiti, Bambini, Soggiorno", + "pinProtectionOptional": "Protezione PIN (opzionale)", + "pinExplain": "PIN a 4 cifre richiesto per passare a questo profilo. Barriera leggera — chiunque può cancellare i dati dell'app per aggirarla.", + "continueButton": "Continua", + "pinsDontMatch": "I PIN non corrispondono" + }, + "connections": { + "sectionTitle": "Connessioni", + "addConnection": "Aggiungi connessione", + "addConnectionSubtitleNoProfile": "Accedi con Plex o collega un server Jellyfin", + "addConnectionSubtitleScoped": "Aggiungi a ${displayName} — account Plex, server Jellyfin o prendi in prestito da un altro profilo", + "sessionExpiredOne": "Sessione scaduta per ${name}", + "sessionExpiredMany": "Sessione scaduta per ${count} server", + "signInAgain": "Accedi di nuovo" + }, "discover": { "title": "Esplora", "switchProfile": "Cambia profilo", "noContentAvailable": "Nessun contenuto disponibile", "addMediaToLibraries": "Aggiungi alcuni file multimediali alle tue librerie", "continueWatching": "Continua a guardare", + "nextUp": "Prossimi", + "recentlyAdded": "Aggiunti di recente", "playEpisode": "S${season}E${episode}", "overview": "Panoramica", "cast": "Attori", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Ricerca fallita: ${error}", "connectionTimeout": "Timeout connessione durante caricamento di ${context}", - "connectionFailed": "Impossibile connettersi al server Plex.", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Impossibile caricare ${context}: ${error}", "noClientAvailable": "Nessun client disponibile", "authenticationFailed": "Autenticazione fallita: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Inserisci token", "invalidToken": "Token non valido", "failedToVerifyToken": "Verifica token fallita: ${error}", - "failedToSwitchProfile": "Impossibile passare a ${displayName}" + "failedToSwitchProfile": "Impossibile passare a ${displayName}", + "failedToDeleteProfile": "Impossibile eliminare ${displayName}", + "failedToRate": "Impossibile aggiornare la valutazione" }, "libraries": { "title": "Librerie", @@ -595,13 +662,30 @@ "seasons": "Stagioni", "episodes": "Episodi", "folders": "Cartelle" + }, + "filterCategories": { + "genre": "Genere", + "year": "Anno", + "contentRating": "Classificazione", + "tag": "Tag" + }, + "sortLabels": { + "title": "Titolo", + "dateAdded": "Data di aggiunta", + "releaseDate": "Data di uscita", + "rating": "Valutazione", + "lastPlayed": "Ultima riproduzione", + "playCount": "Riproduzioni", + "random": "Casuale", + "dateShared": "Data di condivisione", + "latestEpisodeAirDate": "Data ultima messa in onda" } }, "about": { "title": "Informazioni", "openSourceLicenses": "Licenze Open Source", "versionLabel": "Versione ${version}", - "appDescription": "Un bellissimo client Plex per Flutter", + "appDescription": "Un bellissimo client Plex e Jellyfin per Flutter", "viewLicensesDescription": "Visualizza le licenze delle librerie di terze parti" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "In attesa che gli altri carichino...", "recentRooms": "Stanze recenti", "renameRoom": "Rinomina stanza", - "removeRoom": "Rimuovi" + "removeRoom": "Rimuovi", + "guestSwitchUnavailable": "Impossibile cambiare — server non disponibile per la sincronizzazione", + "guestSwitchFailed": "Impossibile cambiare — contenuto non trovato su questo server" }, "downloads": { "title": "Download", @@ -817,6 +903,12 @@ "editSyncFilter": "Filtro di sincronizzazione", "syncAllItems": "Sincronizzazione di tutti gli elementi", "syncUnwatchedItems": "Sincronizzazione degli elementi non visti", + "syncRuleServerContext": "Server: ${server} • ${status}", + "syncRuleAvailable": "Disponibile", + "syncRuleOffline": "Offline", + "syncRuleSignInRequired": "Accesso richiesto", + "syncRuleNotAvailableForProfile": "Non disponibile per il profilo attuale", + "syncRuleUnknownServer": "Server sconosciuto", "syncRuleListCreated": "Regola di sincronizzazione creata" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Librerie", "noLibraries": "Nessuna libreria disponibile" } + }, + "addServer": { + "addJellyfinTitle": "Aggiungi server Jellyfin", + "jellyfinUrlIntro": "Inserisci l'URL del tuo server Jellyfin — es. `https://jellyfin.example.com`. Potrai accedere subito dopo.", + "serverUrl": "URL del server", + "findServer": "Trova server", + "username": "Nome utente", + "password": "Password", + "signIn": "Accedi", + "change": "Modifica", + "required": "Obbligatorio", + "couldNotReachServer": "Impossibile raggiungere il server: ${error}", + "signInFailed": "Accesso non riuscito: ${error}", + "quickConnectFailed": "Quick Connect non riuscito: ${error}", + "addPlexTitle": "Accedi con Plex", + "plexAuthIntro": "Scegli come accedere a Plex. Il flusso browser apre plex.tv dove confermi la connessione; l'opzione QR è comoda per TV / dispositivi remoti.", + "plexQRPrompt": "Scansiona questo QR code per accedere.", + "waitingForPlexConfirmation": "In attesa della conferma da plex.tv…", + "pinExpired": "PIN scaduto prima dell'accesso. Riprova.", + "duplicatePlexAccount": "Questo dispositivo è già connesso a un account Plex. Disconnettiti dalle impostazioni per cambiare account.", + "failedToRegisterAccount": "Registrazione account non riuscita: ${error}", + "enterJellyfinUrlError": "Inserisci l'URL del tuo server Jellyfin", + "addConnectionTitle": "Aggiungi connessione", + "addConnectionTitleScoped": "Aggiungi a ${name}", + "addConnectionIntroGlobal": "Aggiungi un altro server media. Puoi combinare account Plex e server Jellyfin — i contenuti di ogni backend connesso compaiono insieme nella schermata principale.", + "addConnectionIntroScoped": "Aggiungi un nuovo server, o prendine in prestito uno da un altro profilo.", + "signInWithPlexCard": "Accedi con Plex", + "signInWithPlexCardSubtitle": "Autorizza questo dispositivo con il tuo account Plex. I server condivisi con l'account vengono inclusi automaticamente.", + "signInWithPlexCardSubtitleScoped": "Autorizza un nuovo account Plex. I suoi utenti Home appaiono come profili.", + "connectToJellyfinCard": "Connetti a Jellyfin", + "connectToJellyfinCardSubtitle": "Inserisci l'URL del tuo server Jellyfin e accedi con nome utente + password (Quick Connect in arrivo).", + "connectToJellyfinCardSubtitleScoped": "Accedi a un server Jellyfin. Collegato a ${name}.", + "borrowFromAnotherProfile": "Prendi in prestito da un altro profilo", + "borrowFromAnotherProfileSubtitle": "Riutilizza una connessione già associata a un altro profilo. I profili sorgente protetti da PIN richiedono il PIN." } } diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 557ddcaf..a9b56d87 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "サインイン", "signInWithPlex": "Plexでサインイン", "showQRCode": "QRコードを表示", "authenticate": "認証", "authenticationTimeout": "認証がタイムアウトしました。もう一度お試しください。", "scanQRToSignIn": "このQRコードをスキャンしてサインイン", "waitingForAuth": "認証を待機中...\nブラウザでサインインを完了してください。", - "useBrowser": "ブラウザを使用" + "useBrowser": "ブラウザを使用", + "or": "または", + "connectToJellyfin": "Jellyfinに接続", + "useQuickConnect": "Quick Connect を使う", + "quickConnectCode": "Quick Connect コード", + "quickConnectInstructions": "Web ブラウザで Jellyfin サーバーを開いてログインし、ユーザーメニューから Quick Connect を選択します。このコードを入力してサインインを承認してください。", + "quickConnectWaiting": "承認を待っています…", + "quickConnectCancel": "キャンセル", + "quickConnectExpired": "承認される前に Quick Connect コードの有効期限が切れました。もう一度お試しください。" }, "common": { "cancel": "キャンセル", @@ -104,12 +113,12 @@ "gridView": "グリッド", "listView": "リスト", "showHeroSection": "ヒーローセクションを表示", - "useGlobalHubs": "Plex Homeレイアウトを使用", - "useGlobalHubsDescription": "公式Plexクライアントのようにホームページのハブを表示。オフにすると、ライブラリごとのおすすめを表示。", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "ハブにサーバー名を表示", "showServerNameOnHubsDescription": "ハブタイトルに常にサーバー名を表示。オフにすると、重複名のみ表示。", "groupLibrariesByServer": "サーバーごとにライブラリをグループ化", - "groupLibrariesByServerDescription": "複数のサーバーに接続しているとき、サイドバーに各 Plex サーバーのヘッダーを表示します。", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "サイドバーを常に開いておく", "alwaysKeepSidebarOpenDescription": "サイドバーを展開したまま、コンテンツ領域が調整される", "showUnwatchedCount": "未視聴数を表示", @@ -452,7 +461,7 @@ "musicNotSupported": "音楽の再生はまだサポートされていません", "noDescriptionAvailable": "説明はありません", "noProfilesAvailable": "利用可能なプロフィールがありません", - "contactAdminForProfiles": "プロフィールを追加するにはPlex管理者にお問い合わせください", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "このアイテムのライブラリセクションを判別できません", "logsCleared": "ログをクリアしました", "logsCopied": "ログをクリップボードにコピーしました", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "操作の確認" }, + "profiles": { + "addPlezyProfile": "Plezyプロファイルを追加", + "switchingProfile": "プロファイルを切り替え中…", + "deleteThisProfileTitle": "このプロファイルを削除しますか?", + "deleteThisProfileMessage": "${displayName} が削除されます。接続自体は影響を受けません。", + "active": "アクティブ", + "manage": "管理", + "delete": "削除", + "signOut": "サインアウト", + "signOutPlexTitle": "Plex からサインアウトしますか?", + "signOutPlexMessage": "${displayName} とこのアカウントのすべての Plex Home ユーザーがこのデバイスから削除されます。いつでも再度サインインできます。", + "signedOutPlex": "Plex からサインアウトしました。", + "signOutFailed": "サインアウトに失敗しました。", + "sectionTitle": "プロファイル", + "summarySingle": "プロファイルを追加して、管理対象ユーザーとローカルIDを混在させます", + "summaryMultipleWithActive": "${count}個のプロファイル · アクティブ: ${activeName}", + "summaryMultiple": "${count}個のプロファイル", + "removeConnectionTitle": "接続を削除しますか?", + "removeConnectionMessage": "${displayName}は${connectionLabel}へのアクセスを失います。接続自体は他のプロファイルで引き続き使用できます。", + "deleteProfileTitle": "プロファイルを削除しますか?", + "deleteProfileMessage": "このデバイスから${displayName}とそのすべての接続が削除されます。Plex/Jellyfinサーバー自体には影響しません。", + "profileNameLabel": "プロファイル名", + "pinProtectionLabel": "PIN保護", + "pinManagedByPlex": "PINはPlexで管理されています。plex.tvで編集してください。", + "noPinSetEditOnPlex": "PINが設定されていません。要求するには、plex.tvでHomeユーザーを編集してください。", + "setPin": "PINを設定", + "connectionsLabel": "接続", + "add": "追加", + "deleteProfileButton": "プロファイルを削除", + "noConnectionsHint": "接続がありません — このプロファイルを使うには1つ追加してください。", + "plexHomeAccount": "Plex Homeアカウント", + "connectionDefault": "デフォルト", + "makeDefault": "デフォルトに設定", + "removeConnection": "削除", + "borrowAddTo": "${displayName}に追加", + "borrowExplain": "別のプロファイルから接続を借ります。PIN保護されたソースプロファイルは、共有前にPINを要求します。", + "borrowEmpty": "まだ借りるものがありません。", + "borrowEmptySubtitle": "まず別のプロファイルにPlexアカウントまたはJellyfinサーバーを接続してから、ここに戻ってきてください。", + "newProfile": "新しいプロファイル", + "profileNameHint": "例:ゲスト、キッズ、ファミリールーム", + "pinProtectionOptional": "PIN保護(オプション)", + "pinExplain": "このプロファイルに切り替えるには4桁のPINが必要です。ソフトバリア — アプリデータを消去できる人なら回避できます。", + "continueButton": "続ける", + "pinsDontMatch": "PINが一致しません" + }, + "connections": { + "sectionTitle": "接続", + "addConnection": "接続を追加", + "addConnectionSubtitleNoProfile": "Plexでサインインするか、Jellyfinサーバーに接続", + "addConnectionSubtitleScoped": "${displayName} に追加 — Plexアカウント、Jellyfinサーバー、または別のプロファイルから借用", + "sessionExpiredOne": "${name} のセッションの有効期限が切れました", + "sessionExpiredMany": "${count} 台のサーバーのセッションの有効期限が切れました", + "signInAgain": "再度サインイン" + }, "discover": { "title": "探す", "switchProfile": "プロフィール切替", "noContentAvailable": "コンテンツがありません", "addMediaToLibraries": "ライブラリにメディアを追加してください", "continueWatching": "視聴を続ける", + "nextUp": "次のエピソード", + "recentlyAdded": "最近追加", "playEpisode": "S${season}E${episode}", "overview": "あらすじ", "cast": "キャスト", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "検索に失敗しました: ${error}", "connectionTimeout": "${context}の読み込み中に接続がタイムアウトしました", - "connectionFailed": "Plexサーバーに接続できません", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "${context}の読み込みに失敗しました: ${error}", "noClientAvailable": "クライアントが利用できません", "authenticationFailed": "認証に失敗しました: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "トークンを入力してください", "invalidToken": "無効なトークン", "failedToVerifyToken": "トークンの検証に失敗しました: ${error}", - "failedToSwitchProfile": "${displayName}への切替に失敗しました" + "failedToSwitchProfile": "${displayName}への切替に失敗しました", + "failedToDeleteProfile": "${displayName}の削除に失敗しました", + "failedToRate": "評価を更新できませんでした" }, "libraries": { "title": "ライブラリ", @@ -595,13 +662,30 @@ "seasons": "シーズン", "episodes": "エピソード", "folders": "フォルダ" + }, + "filterCategories": { + "genre": "ジャンル", + "year": "年", + "contentRating": "視聴年齢区分", + "tag": "タグ" + }, + "sortLabels": { + "title": "タイトル", + "dateAdded": "追加日", + "releaseDate": "リリース日", + "rating": "評価", + "lastPlayed": "最終再生", + "playCount": "再生回数", + "random": "ランダム", + "dateShared": "共有日", + "latestEpisodeAirDate": "最新エピソード放送日" } }, "about": { "title": "アプリについて", "openSourceLicenses": "オープンソースライセンス", "versionLabel": "バージョン ${version}", - "appDescription": "Flutter製の美しいPlexクライアント", + "appDescription": "Flutter製の美しいPlex・Jellyfinクライアント", "viewLicensesDescription": "サードパーティライブラリのライセンスを表示" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "他の参加者の読み込みを待っています...", "recentRooms": "最近のルーム", "renameRoom": "ルーム名を変更", - "removeRoom": "削除" + "removeRoom": "削除", + "guestSwitchUnavailable": "切り替えできません — サーバーが同期できません", + "guestSwitchFailed": "切り替えできません — このサーバーにコンテンツが見つかりません" }, "downloads": { "title": "ダウンロード", @@ -817,6 +903,12 @@ "editSyncFilter": "同期フィルター", "syncAllItems": "すべてのアイテムを同期中", "syncUnwatchedItems": "未視聴のアイテムを同期中", + "syncRuleServerContext": "サーバー: ${server} • ${status}", + "syncRuleAvailable": "利用可能", + "syncRuleOffline": "オフライン", + "syncRuleSignInRequired": "サインインが必要", + "syncRuleNotAvailableForProfile": "現在のプロフィールでは利用できません", + "syncRuleUnknownServer": "不明なサーバー", "syncRuleListCreated": "同期ルールを作成しました" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "ライブラリ", "noLibraries": "利用できるライブラリがありません" } + }, + "addServer": { + "addJellyfinTitle": "Jellyfinサーバーを追加", + "jellyfinUrlIntro": "JellyfinサーバーのURLを入力してください — 例: `https://jellyfin.example.com`。サインインは後から行えます。", + "serverUrl": "サーバーURL", + "findServer": "サーバーを検索", + "username": "ユーザー名", + "password": "パスワード", + "signIn": "サインイン", + "change": "変更", + "required": "必須", + "couldNotReachServer": "サーバーに接続できませんでした: ${error}", + "signInFailed": "サインインに失敗しました: ${error}", + "quickConnectFailed": "Quick Connectに失敗しました: ${error}", + "addPlexTitle": "Plexでサインイン", + "plexAuthIntro": "Plexへのサインイン方法を選択します。ブラウザフローではplex.tvが開き、接続を確認します。QRオプションはTVやリモートデバイスに便利です。", + "plexQRPrompt": "このQRコードをスキャンしてサインインしてください。", + "waitingForPlexConfirmation": "plex.tvがサインインを確認するのを待っています…", + "pinExpired": "サインイン前にPINの有効期限が切れました。もう一度お試しください。", + "duplicatePlexAccount": "このデバイスはすでにPlexアカウントにサインインしています。アカウントを切り替えるには設定からサインアウトしてください。", + "failedToRegisterAccount": "アカウントの登録に失敗しました: ${error}", + "enterJellyfinUrlError": "JellyfinサーバーのURLを入力してください", + "addConnectionTitle": "接続を追加", + "addConnectionTitleScoped": "${name}に追加", + "addConnectionIntroGlobal": "別のメディアサーバーを追加します。PlexアカウントとJellyfinサーバーを組み合わせて使用でき、接続済みのすべてのバックエンドのアイテムがホーム画面に並びます。", + "addConnectionIntroScoped": "新しいサーバーを追加するか、別のプロファイルから借りてください。", + "signInWithPlexCard": "Plexでサインイン", + "signInWithPlexCardSubtitle": "このデバイスをあなたのPlexアカウントで認証します。アカウントで共有されているサーバーは自動的に含まれます。", + "signInWithPlexCardSubtitleScoped": "新しいPlexアカウントを認証します。そのHomeユーザーがプロファイルとして表示されます。", + "connectToJellyfinCard": "Jellyfinに接続", + "connectToJellyfinCardSubtitle": "JellyfinサーバーのURLを入力し、ユーザー名+パスワードでサインインします(Quick Connectは近日対応予定)。", + "connectToJellyfinCardSubtitleScoped": "Jellyfinサーバーにサインインします。${name}に紐付けられます。", + "borrowFromAnotherProfile": "別のプロファイルから借りる", + "borrowFromAnotherProfileSubtitle": "別のプロファイルに紐付け済みの接続を再利用します。PIN保護されたソースプロファイルはPINの入力を求めます。" } } diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index c84f2f9e..5c2f38ad 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "로그인", "signInWithPlex": "Plex 계정으로 로그인", "showQRCode": "QR 코드", "authenticate": "인증", "authenticationTimeout": "인증 시간이 초과되었습니다. 다시 시도해 주세요.", "scanQRToSignIn": "QR 코드를 스캔하여 로그인", "waitingForAuth": "인증 대기 중... 브라우저에서 로그인을 완료해 주세요.", - "useBrowser": "브라우저 사용" + "useBrowser": "브라우저 사용", + "or": "또는", + "connectToJellyfin": "Jellyfin에 연결", + "useQuickConnect": "Quick Connect 사용", + "quickConnectCode": "Quick Connect 코드", + "quickConnectInstructions": "웹 브라우저에서 Jellyfin 서버를 열어 로그인한 뒤 사용자 메뉴에서 Quick Connect를 선택하세요. 이 코드를 입력해 로그인을 승인하세요.", + "quickConnectWaiting": "승인 대기 중…", + "quickConnectCancel": "취소", + "quickConnectExpired": "승인되기 전에 Quick Connect 코드가 만료되었습니다. 다시 시도하세요." }, "common": { "cancel": "취소", @@ -104,12 +113,12 @@ "gridView": "그리드 보기", "listView": "목록 보기", "showHeroSection": "주요 추천 영역 표시", - "useGlobalHubs": "Plex 홈 레이아웃 사용", - "useGlobalHubsDescription": "공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "허브에 서버 이름 표시", "showServerNameOnHubsDescription": "허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.", "groupLibrariesByServer": "서버별로 라이브러리 그룹화", - "groupLibrariesByServerDescription": "여러 서버에 연결되어 있을 때 사이드바에 각 Plex 서버의 헤더를 표시합니다.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "사이드바 항상 열어두기", "alwaysKeepSidebarOpenDescription": "사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다", "showUnwatchedCount": "미시청 수 표시", @@ -452,7 +461,7 @@ "musicNotSupported": "음악 재생 미지원", "noDescriptionAvailable": "설명이 없습니다", "noProfilesAvailable": "사용 가능한 프로필이 없습니다", - "contactAdminForProfiles": "프로필을 추가하려면 Plex 관리자에게 문의하세요", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "이 항목의 라이브러리 섹션을 확인할 수 없습니다", "logsCleared": "로그가 삭제 되었습니다", "logsCopied": "로그가 클립보드에 복사 되었습니다", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "확인" }, + "profiles": { + "addPlezyProfile": "Plezy 프로필 추가", + "switchingProfile": "프로필 전환 중…", + "deleteThisProfileTitle": "이 프로필을 삭제하시겠습니까?", + "deleteThisProfileMessage": "${displayName} 이(가) 제거됩니다. 연결 자체는 영향을 받지 않습니다.", + "active": "활성", + "manage": "관리", + "delete": "삭제", + "signOut": "로그아웃", + "signOutPlexTitle": "Plex에서 로그아웃하시겠습니까?", + "signOutPlexMessage": "${displayName} 및 이 계정의 모든 Plex Home 사용자가 이 기기에서 제거됩니다. 언제든지 다시 로그인할 수 있습니다.", + "signedOutPlex": "Plex에서 로그아웃되었습니다.", + "signOutFailed": "로그아웃에 실패했습니다.", + "sectionTitle": "프로필", + "summarySingle": "관리되는 사용자와 로컬 ID를 혼합하려면 프로필을 추가하세요", + "summaryMultipleWithActive": "${count}개 프로필 · 활성: ${activeName}", + "summaryMultiple": "${count}개 프로필", + "removeConnectionTitle": "연결을 제거하시겠습니까?", + "removeConnectionMessage": "${displayName}이(가) ${connectionLabel}에 대한 액세스를 잃게 됩니다. 연결 자체는 다른 프로필에서 계속 사용할 수 있습니다.", + "deleteProfileTitle": "프로필을 삭제하시겠습니까?", + "deleteProfileMessage": "이 기기에서 ${displayName}과(와) 모든 연결이 제거됩니다. 기본 Plex/Jellyfin 서버에는 영향을 주지 않습니다.", + "profileNameLabel": "프로필 이름", + "pinProtectionLabel": "PIN 보호", + "pinManagedByPlex": "PIN은 Plex에서 관리됩니다. plex.tv에서 편집하세요.", + "noPinSetEditOnPlex": "설정된 PIN이 없습니다. 요구하려면 plex.tv에서 Home 사용자를 편집하세요.", + "setPin": "PIN 설정", + "connectionsLabel": "연결", + "add": "추가", + "deleteProfileButton": "프로필 삭제", + "noConnectionsHint": "연결이 없습니다 — 이 프로필을 사용하려면 하나 추가하세요.", + "plexHomeAccount": "Plex Home 계정", + "connectionDefault": "기본값", + "makeDefault": "기본값으로 설정", + "removeConnection": "제거", + "borrowAddTo": "${displayName}에 추가", + "borrowExplain": "다른 프로필에서 연결을 빌립니다. PIN 보호된 원본 프로필은 공유 전에 PIN을 요구합니다.", + "borrowEmpty": "아직 빌릴 것이 없습니다.", + "borrowEmptySubtitle": "먼저 다른 프로필에 Plex 계정 또는 Jellyfin 서버를 연결한 다음 여기로 돌아오세요.", + "newProfile": "새 프로필", + "profileNameHint": "예: 손님, 어린이, 가족실", + "pinProtectionOptional": "PIN 보호 (선택 사항)", + "pinExplain": "이 프로필로 전환하려면 4자리 PIN이 필요합니다. 부드러운 장벽 — 앱 데이터를 지울 수 있는 사람은 우회할 수 있습니다.", + "continueButton": "계속", + "pinsDontMatch": "PIN이 일치하지 않습니다" + }, + "connections": { + "sectionTitle": "연결", + "addConnection": "연결 추가", + "addConnectionSubtitleNoProfile": "Plex로 로그인하거나 Jellyfin 서버에 연결", + "addConnectionSubtitleScoped": "${displayName} 에 추가 — Plex 계정, Jellyfin 서버 또는 다른 프로필에서 빌리기", + "sessionExpiredOne": "${name} 의 세션이 만료되었습니다", + "sessionExpiredMany": "${count} 개의 서버에서 세션이 만료되었습니다", + "signInAgain": "다시 로그인" + }, "discover": { "title": "발견", "switchProfile": "사용자 전환", "noContentAvailable": "사용 가능한 콘텐츠가 없습니다", "addMediaToLibraries": "미디어 라이브러리에 미디어를 추가해 주세요", "continueWatching": "계속 시청", + "nextUp": "다음 에피소드", + "recentlyAdded": "최근에 추가됨", "playEpisode": "S${season}E${episode}", "overview": "개요", "cast": "출연진", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "검색 실패: ${error}", "connectionTimeout": "${context} 로드 중 연결 시간 초과", - "connectionFailed": "Plex 서버에 연결할 수 없음", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "${context} 로드 실패: ${error}", "noClientAvailable": "사용 가능한 클라이언트가 없습니다", "authenticationFailed": "인증 실패: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "토큰을 입력해 주세요", "invalidToken": "토큰이 유효하지 않습니다", "failedToVerifyToken": "토큰을 확인할 수 없습니다: ${error}", - "failedToSwitchProfile": "${displayName}으로 전환할 수 없습니다" + "failedToSwitchProfile": "${displayName}으로 전환할 수 없습니다", + "failedToDeleteProfile": "${displayName}을(를) 삭제할 수 없습니다", + "failedToRate": "평점을 업데이트하지 못했습니다" }, "libraries": { "title": "미디어 라이브러리", @@ -595,13 +662,30 @@ "seasons": "시즌", "episodes": "화", "folders": "폴더" + }, + "filterCategories": { + "genre": "장르", + "year": "연도", + "contentRating": "시청 등급", + "tag": "태그" + }, + "sortLabels": { + "title": "제목", + "dateAdded": "추가된 날짜", + "releaseDate": "출시일", + "rating": "평점", + "lastPlayed": "마지막 재생", + "playCount": "재생 횟수", + "random": "무작위", + "dateShared": "공유된 날짜", + "latestEpisodeAirDate": "최신 에피소드 방영일" } }, "about": { "title": "소개", "openSourceLicenses": "오픈소스 라이선스", "versionLabel": "버전 ${version}", - "appDescription": "아름다운 Flutter Plex 클라이언트", + "appDescription": "아름다운 Flutter용 Plex 및 Jellyfin 클라이언트", "viewLicensesDescription": "타사 라이브러리 라이선스 보기" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "다른 참가자의 로딩을 기다리는 중...", "recentRooms": "최근 방", "renameRoom": "방 이름 변경", - "removeRoom": "제거" + "removeRoom": "제거", + "guestSwitchUnavailable": "전환할 수 없음 — 동기화 서버를 사용할 수 없습니다", + "guestSwitchFailed": "전환할 수 없음 — 이 서버에서 콘텐츠를 찾을 수 없습니다" }, "downloads": { "title": "다운로드", @@ -817,6 +903,12 @@ "editSyncFilter": "동기화 필터", "syncAllItems": "모든 항목 동기화 중", "syncUnwatchedItems": "시청하지 않은 항목 동기화 중", + "syncRuleServerContext": "서버: ${server} • ${status}", + "syncRuleAvailable": "사용 가능", + "syncRuleOffline": "오프라인", + "syncRuleSignInRequired": "로그인 필요", + "syncRuleNotAvailableForProfile": "현재 프로필에서 사용할 수 없음", + "syncRuleUnknownServer": "알 수 없는 서버", "syncRuleListCreated": "동기화 규칙이 생성되었습니다" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "라이브러리", "noLibraries": "사용 가능한 라이브러리가 없습니다" } + }, + "addServer": { + "addJellyfinTitle": "Jellyfin 서버 추가", + "jellyfinUrlIntro": "Jellyfin 서버 URL을 입력하세요 — 예: `https://jellyfin.example.com`. 이후에 로그인할 수 있습니다.", + "serverUrl": "서버 URL", + "findServer": "서버 찾기", + "username": "사용자 이름", + "password": "비밀번호", + "signIn": "로그인", + "change": "변경", + "required": "필수", + "couldNotReachServer": "서버에 연결할 수 없습니다: ${error}", + "signInFailed": "로그인 실패: ${error}", + "quickConnectFailed": "Quick Connect 실패: ${error}", + "addPlexTitle": "Plex로 로그인", + "plexAuthIntro": "Plex에 로그인할 방법을 선택하세요. 브라우저 플로우는 plex.tv를 열어 연결을 확인하며, QR 옵션은 TV나 원격 장치에 편리합니다.", + "plexQRPrompt": "이 QR 코드를 스캔하여 로그인하세요.", + "waitingForPlexConfirmation": "plex.tv에서 로그인을 확인하는 중…", + "pinExpired": "로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.", + "duplicatePlexAccount": "이 기기는 이미 Plex 계정에 로그인되어 있습니다. 계정을 변경하려면 설정에서 로그아웃하세요.", + "failedToRegisterAccount": "계정 등록 실패: ${error}", + "enterJellyfinUrlError": "Jellyfin 서버 URL을 입력하세요", + "addConnectionTitle": "연결 추가", + "addConnectionTitleScoped": "${name}에 추가", + "addConnectionIntroGlobal": "다른 미디어 서버를 추가하세요. Plex 계정과 Jellyfin 서버를 함께 사용할 수 있으며, 연결된 모든 백엔드의 항목이 홈 화면에 함께 표시됩니다.", + "addConnectionIntroScoped": "새 서버를 추가하거나 다른 프로필에서 빌리세요.", + "signInWithPlexCard": "Plex로 로그인", + "signInWithPlexCardSubtitle": "이 기기를 Plex 계정으로 인증합니다. 계정과 공유된 서버가 자동으로 함께 추가됩니다.", + "signInWithPlexCardSubtitleScoped": "새 Plex 계정을 인증합니다. 해당 Home 사용자가 프로필로 표시됩니다.", + "connectToJellyfinCard": "Jellyfin에 연결", + "connectToJellyfinCardSubtitle": "Jellyfin 서버 URL을 입력하고 사용자 이름 + 비밀번호로 로그인하세요 (Quick Connect는 곧 지원 예정).", + "connectToJellyfinCardSubtitleScoped": "Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.", + "borrowFromAnotherProfile": "다른 프로필에서 빌리기", + "borrowFromAnotherProfileSubtitle": "이미 다른 프로필에 연결된 연결을 재사용합니다. PIN으로 보호된 소스 프로필은 PIN을 요청합니다." } } diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index eda1d9f7..17895d83 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Logg på", "signInWithPlex": "Logg inn med Plex", "showQRCode": "Vis QR-kode", "authenticate": "Autentiser", "authenticationTimeout": "Autentiseringen ble tidsavbrutt. Prøv igjen.", "scanQRToSignIn": "Skann denne QR-koden for å logge inn", "waitingForAuth": "Venter på autentisering...\nFullfør innloggingen i nettleseren din.", - "useBrowser": "Bruk nettleser" + "useBrowser": "Bruk nettleser", + "or": "eller", + "connectToJellyfin": "Koble til Jellyfin", + "useQuickConnect": "Bruk Quick Connect", + "quickConnectCode": "Quick Connect-kode", + "quickConnectInstructions": "Åpne Jellyfin-serveren din i en nettleser, logg inn, og velg Quick Connect i brukermenyen. Skriv inn denne koden for å godkjenne påloggingen.", + "quickConnectWaiting": "Venter på godkjenning…", + "quickConnectCancel": "Avbryt", + "quickConnectExpired": "Quick Connect-koden utløp før godkjenning. Prøv igjen." }, "common": { "cancel": "Avbryt", @@ -104,12 +113,12 @@ "gridView": "Rutenett", "listView": "Liste", "showHeroSection": "Vis fremhevet seksjon", - "useGlobalHubs": "Bruk Plex Home-layout", - "useGlobalHubsDescription": "Vis hjemmeside-huber som den offisielle Plex-klienten. Når av, vises per-bibliotek-anbefalinger i stedet.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Vis servernavn på huber", "showServerNameOnHubsDescription": "Vis alltid servernavnet i hubtitler. Når av, vises kun for dupliserte hubnavn.", "groupLibrariesByServer": "Grupper biblioteker etter server", - "groupLibrariesByServerDescription": "Vis en overskrift for hver Plex-server i sidefeltet når du er koblet til flere servere.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Hold sidefeltet alltid åpent", "alwaysKeepSidebarOpenDescription": "Sidefeltet forblir utvidet og innholdsområdet tilpasser seg", "showUnwatchedCount": "Vis antall usette", @@ -452,7 +461,7 @@ "musicNotSupported": "Musikkavspilling støttes ikke ennå", "noDescriptionAvailable": "Ingen beskrivelse tilgjengelig", "noProfilesAvailable": "Ingen profiler tilgjengelige", - "contactAdminForProfiles": "Kontakt Plex-administratoren for å legge til profiler", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Kan ikke fastslå bibliotekseksjonen for dette elementet", "logsCleared": "Logger tømt", "logsCopied": "Logger kopiert til utklippstavle", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Bekreft handling" }, + "profiles": { + "addPlezyProfile": "Legg til Plezy-profil", + "switchingProfile": "Bytter profil…", + "deleteThisProfileTitle": "Slett denne profilen?", + "deleteThisProfileMessage": "${displayName} fjernes. Tilkoblinger påvirkes ikke.", + "active": "Aktiv", + "manage": "Administrer", + "delete": "Slett", + "signOut": "Logg ut", + "signOutPlexTitle": "Logge ut av Plex?", + "signOutPlexMessage": "${displayName} og alle Plex Home-brukere på denne kontoen fjernes fra denne enheten. Du kan logge inn igjen når som helst.", + "signedOutPlex": "Logget ut av Plex.", + "signOutFailed": "Utlogging mislyktes.", + "sectionTitle": "Profiler", + "summarySingle": "Legg til profiler for å blande administrerte brukere og lokale identiteter", + "summaryMultipleWithActive": "${count} profiler · aktiv: ${activeName}", + "summaryMultiple": "${count} profiler", + "removeConnectionTitle": "Fjerne tilkobling?", + "removeConnectionMessage": "${displayName} mister tilgang til ${connectionLabel}. Tilkoblingen er fortsatt tilgjengelig for andre profiler.", + "deleteProfileTitle": "Slette profil?", + "deleteProfileMessage": "Dette fjerner ${displayName} og alle dens tilkoblinger fra denne enheten. De underliggende Plex/Jellyfin-serverne berøres ikke.", + "profileNameLabel": "Profilnavn", + "pinProtectionLabel": "PIN-beskyttelse", + "pinManagedByPlex": "PIN administreres av Plex. Rediger på plex.tv.", + "noPinSetEditOnPlex": "Ingen PIN er satt. For å kreve én, rediger Home-brukeren på plex.tv.", + "setPin": "Sett PIN", + "connectionsLabel": "Tilkoblinger", + "add": "Legg til", + "deleteProfileButton": "Slett profil", + "noConnectionsHint": "Ingen tilkoblinger — legg til én for å bruke denne profilen.", + "plexHomeAccount": "Plex Home-konto", + "connectionDefault": "Standard", + "makeDefault": "Gjør til standard", + "removeConnection": "Fjern", + "borrowAddTo": "Legg til ${displayName}", + "borrowExplain": "Lån en tilkobling fra en annen profil. PIN-beskyttede kildeprofiler ber om PIN før deling.", + "borrowEmpty": "Ingenting å låne enda.", + "borrowEmptySubtitle": "Koble først en Plex-konto eller Jellyfin-server til en annen profil, og kom så tilbake hit.", + "newProfile": "Ny profil", + "profileNameHint": "f.eks. Gjester, Barn, Familierom", + "pinProtectionOptional": "PIN-beskyttelse (valgfri)", + "pinExplain": "4-sifret PIN kreves for å bytte til denne profilen. Myk barriere — alle som kan fjerne appdata kan omgå den.", + "continueButton": "Fortsett", + "pinsDontMatch": "PIN-ene samsvarer ikke" + }, + "connections": { + "sectionTitle": "Tilkoblinger", + "addConnection": "Legg til tilkobling", + "addConnectionSubtitleNoProfile": "Logg inn med Plex eller koble til en Jellyfin-server", + "addConnectionSubtitleScoped": "Legg til ${displayName} — Plex-konto, Jellyfin-server eller lån fra en annen profil", + "sessionExpiredOne": "Økten er utløpt for ${name}", + "sessionExpiredMany": "Økten er utløpt for ${count} servere", + "signInAgain": "Logg inn igjen" + }, "discover": { "title": "Oppdag", "switchProfile": "Bytt profil", "noContentAvailable": "Ingen innhold tilgjengelig", "addMediaToLibraries": "Legg til medier i bibliotekene dine", "continueWatching": "Fortsett å se", + "nextUp": "Neste opp", + "recentlyAdded": "Nylig lagt til", "playEpisode": "S${season}E${episode}", "overview": "Oversikt", "cast": "Skuespillere", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Søk mislyktes: ${error}", "connectionTimeout": "Tidsavbrudd ved lasting av ${context}", - "connectionFailed": "Kunne ikke koble til Plex-server", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Kunne ikke laste ${context}: ${error}", "noClientAvailable": "Ingen klient tilgjengelig", "authenticationFailed": "Autentisering mislyktes: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Vennligst skriv inn et token", "invalidToken": "Ugyldig token", "failedToVerifyToken": "Kunne ikke verifisere token: ${error}", - "failedToSwitchProfile": "Kunne ikke bytte til ${displayName}" + "failedToSwitchProfile": "Kunne ikke bytte til ${displayName}", + "failedToDeleteProfile": "Kunne ikke slette ${displayName}", + "failedToRate": "Kunne ikke oppdatere vurderingen" }, "libraries": { "title": "Biblioteker", @@ -595,13 +662,30 @@ "seasons": "Sesonger", "episodes": "Episoder", "folders": "Mapper" + }, + "filterCategories": { + "genre": "Sjanger", + "year": "År", + "contentRating": "Aldersgrense", + "tag": "Tag" + }, + "sortLabels": { + "title": "Tittel", + "dateAdded": "Lagt til-dato", + "releaseDate": "Utgivelsesdato", + "rating": "Vurdering", + "lastPlayed": "Sist spilt", + "playCount": "Avspillinger", + "random": "Tilfeldig", + "dateShared": "Delingsdato", + "latestEpisodeAirDate": "Siste episodes sendedato" } }, "about": { "title": "Om", "openSourceLicenses": "Åpen kildekode-lisenser", "versionLabel": "Versjon ${version}", - "appDescription": "En vakker Plex-klient for Flutter", + "appDescription": "En vakker Plex- og Jellyfin-klient for Flutter", "viewLicensesDescription": "Vis lisenser for tredjepartsbiblioteker" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Venter på at andre laster inn...", "recentRooms": "Nylige rom", "renameRoom": "Gi nytt navn til rom", - "removeRoom": "Fjern" + "removeRoom": "Fjern", + "guestSwitchUnavailable": "Kunne ikke bytte — server ikke tilgjengelig for synkronisering", + "guestSwitchFailed": "Kunne ikke bytte — innhold ble ikke funnet på denne serveren" }, "downloads": { "title": "Nedlastinger", @@ -817,6 +903,12 @@ "editSyncFilter": "Synkroniseringsfilter", "syncAllItems": "Synkroniserer alle elementer", "syncUnwatchedItems": "Synkroniserer usette elementer", + "syncRuleServerContext": "Server: ${server} • ${status}", + "syncRuleAvailable": "Tilgjengelig", + "syncRuleOffline": "Frakoblet", + "syncRuleSignInRequired": "Innlogging kreves", + "syncRuleNotAvailableForProfile": "Ikke tilgjengelig for gjeldende profil", + "syncRuleUnknownServer": "Ukjent server", "syncRuleListCreated": "Synkroniseringsregel opprettet" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Biblioteker", "noLibraries": "Ingen biblioteker tilgjengelige" } + }, + "addServer": { + "addJellyfinTitle": "Legg til Jellyfin-server", + "jellyfinUrlIntro": "Oppgi URL-en til Jellyfin-serveren din — f.eks. `https://jellyfin.example.com`. Du kan logge på etterpå.", + "serverUrl": "Server-URL", + "findServer": "Finn server", + "username": "Brukernavn", + "password": "Passord", + "signIn": "Logg på", + "change": "Endre", + "required": "Påkrevd", + "couldNotReachServer": "Kunne ikke nå serveren: ${error}", + "signInFailed": "Pålogging mislyktes: ${error}", + "quickConnectFailed": "Quick Connect mislyktes: ${error}", + "addPlexTitle": "Logg på med Plex", + "plexAuthIntro": "Velg hvordan du vil logge på Plex. Nettleserflyten åpner plex.tv der du bekrefter tilkoblingen; QR-alternativet er praktisk for TV / fjern-enheter.", + "plexQRPrompt": "Skann denne QR-koden for å logge på.", + "waitingForPlexConfirmation": "Venter på at plex.tv bekrefter påloggingen…", + "pinExpired": "PIN-koden gikk ut før pålogging. Prøv igjen.", + "duplicatePlexAccount": "Denne enheten er allerede pålogget en Plex-konto. Logg ut fra innstillingene for å bytte konto.", + "failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}", + "enterJellyfinUrlError": "Oppgi URL-en til Jellyfin-serveren din", + "addConnectionTitle": "Legg til tilkobling", + "addConnectionTitleScoped": "Legg til i ${name}", + "addConnectionIntroGlobal": "Legg til enda en medieserver. Du kan blande Plex-kontoer og Jellyfin-servere — innhold fra alle tilkoblede backender vises sammen på startsiden.", + "addConnectionIntroScoped": "Legg til en ny server, eller lån en fra en annen profil.", + "signInWithPlexCard": "Logg på med Plex", + "signInWithPlexCardSubtitle": "Autoriser denne enheten mot Plex-kontoen din. Servere delt med kontoen følger med automatisk.", + "signInWithPlexCardSubtitleScoped": "Autoriser en ny Plex-konto. Dens Home-brukere vises som profiler.", + "connectToJellyfinCard": "Koble til Jellyfin", + "connectToJellyfinCardSubtitle": "Oppgi URL-en til Jellyfin-serveren din og logg på med brukernavn + passord (Quick Connect kommer snart).", + "connectToJellyfinCardSubtitleScoped": "Logg på en Jellyfin-server. Knyttes til ${name}.", + "borrowFromAnotherProfile": "Lån fra en annen profil", + "borrowFromAnotherProfileSubtitle": "Gjenbruk en tilkobling som allerede er tilknyttet en annen profil. PIN-beskyttede kildeprofiler ber om PIN." } } diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 5694bd91..343ba905 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Inloggen", "signInWithPlex": "Inloggen met Plex", "showQRCode": "Toon QR-code", "authenticate": "Authenticeren", "authenticationTimeout": "Authenticatie verlopen. Probeer opnieuw.", "scanQRToSignIn": "Scan deze QR-code om in te loggen", "waitingForAuth": "Wachten op authenticatie...\nVoltooi het inloggen in je browser.", - "useBrowser": "Gebruik browser" + "useBrowser": "Gebruik browser", + "or": "of", + "connectToJellyfin": "Verbinden met Jellyfin", + "useQuickConnect": "Quick Connect gebruiken", + "quickConnectCode": "Quick Connect-code", + "quickConnectInstructions": "Open je Jellyfin-server in een webbrowser, log in en kies Quick Connect in het gebruikersmenu. Voer deze code in om de aanmelding goed te keuren.", + "quickConnectWaiting": "Wachten op goedkeuring…", + "quickConnectCancel": "Annuleren", + "quickConnectExpired": "De Quick Connect-code is verlopen voordat hij werd goedgekeurd. Probeer het opnieuw." }, "common": { "cancel": "Annuleren", @@ -104,12 +113,12 @@ "gridView": "Raster", "listView": "Lijst", "showHeroSection": "Toon hoofdsectie", - "useGlobalHubs": "Plex Home-indeling gebruiken", - "useGlobalHubsDescription": "Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Servernaam tonen bij hubs", "showServerNameOnHubsDescription": "Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.", "groupLibrariesByServer": "Bibliotheken groeperen per server", - "groupLibrariesByServerDescription": "Toont een kop voor elke Plex-server in de zijbalk wanneer je met meerdere servers verbonden bent.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Zijbalk altijd open houden", "alwaysKeepSidebarOpenDescription": "Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan", "showUnwatchedCount": "Aantal ongekeken tonen", @@ -452,7 +461,7 @@ "musicNotSupported": "Muziek afspelen wordt nog niet ondersteund", "noDescriptionAvailable": "Geen beschrijving beschikbaar", "noProfilesAvailable": "Geen profielen beschikbaar", - "contactAdminForProfiles": "Neem contact op met je Plex-beheerder om profielen toe te voegen", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Kan bibliotheeksectie voor dit item niet bepalen", "logsCleared": "Logs gewist", "logsCopied": "Logs gekopieerd naar klembord", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Bevestig actie" }, + "profiles": { + "addPlezyProfile": "Plezy-profiel toevoegen", + "switchingProfile": "Profiel wisselen…", + "deleteThisProfileTitle": "Dit profiel verwijderen?", + "deleteThisProfileMessage": "${displayName} wordt verwijderd. Verbindingen worden niet aangetast.", + "active": "Actief", + "manage": "Beheren", + "delete": "Verwijderen", + "signOut": "Afmelden", + "signOutPlexTitle": "Afmelden bij Plex?", + "signOutPlexMessage": "${displayName} en alle Plex Home-gebruikers van dit account worden van dit apparaat verwijderd. Je kunt op elk moment opnieuw inloggen.", + "signedOutPlex": "Afgemeld bij Plex.", + "signOutFailed": "Afmelden mislukt.", + "sectionTitle": "Profielen", + "summarySingle": "Voeg profielen toe om beheerde gebruikers en lokale identiteiten te combineren", + "summaryMultipleWithActive": "${count} profielen · actief: ${activeName}", + "summaryMultiple": "${count} profielen", + "removeConnectionTitle": "Verbinding verwijderen?", + "removeConnectionMessage": "${displayName} verliest toegang tot ${connectionLabel}. De verbinding blijft beschikbaar voor andere profielen.", + "deleteProfileTitle": "Profiel verwijderen?", + "deleteProfileMessage": "Hiermee worden ${displayName} en al zijn verbindingen van dit apparaat verwijderd. De onderliggende Plex/Jellyfin-servers worden niet beïnvloed.", + "profileNameLabel": "Profielnaam", + "pinProtectionLabel": "PIN-beveiliging", + "pinManagedByPlex": "PIN wordt beheerd door Plex. Bewerk op plex.tv.", + "noPinSetEditOnPlex": "Geen PIN ingesteld. Bewerk de Home-gebruiker op plex.tv om er één te vereisen.", + "setPin": "PIN instellen", + "connectionsLabel": "Verbindingen", + "add": "Toevoegen", + "deleteProfileButton": "Profiel verwijderen", + "noConnectionsHint": "Geen verbindingen — voeg er één toe om dit profiel te gebruiken.", + "plexHomeAccount": "Plex Home-account", + "connectionDefault": "Standaard", + "makeDefault": "Als standaard instellen", + "removeConnection": "Verwijderen", + "borrowAddTo": "Toevoegen aan ${displayName}", + "borrowExplain": "Leen een verbinding van een ander profiel. PIN-beveiligde bronprofielen vragen om de PIN voordat ze delen.", + "borrowEmpty": "Nog niets te lenen.", + "borrowEmptySubtitle": "Verbind eerst een Plex-account of Jellyfin-server met een ander profiel en kom dan hier terug.", + "newProfile": "Nieuw profiel", + "profileNameHint": "bijv. Gasten, Kinderen, Woonkamer", + "pinProtectionOptional": "PIN-beveiliging (optioneel)", + "pinExplain": "4-cijferige PIN vereist om naar dit profiel te schakelen. Zachte barrière — iedereen die appgegevens kan wissen, kan deze omzeilen.", + "continueButton": "Doorgaan", + "pinsDontMatch": "PIN-codes komen niet overeen" + }, + "connections": { + "sectionTitle": "Verbindingen", + "addConnection": "Verbinding toevoegen", + "addConnectionSubtitleNoProfile": "Meld je aan met Plex of verbind een Jellyfin-server", + "addConnectionSubtitleScoped": "Toevoegen aan ${displayName} — Plex-account, Jellyfin-server of lenen van een ander profiel", + "sessionExpiredOne": "Sessie verlopen voor ${name}", + "sessionExpiredMany": "Sessie verlopen voor ${count} servers", + "signInAgain": "Opnieuw aanmelden" + }, "discover": { "title": "Ontdekken", "switchProfile": "Wissel van profiel", "noContentAvailable": "Geen inhoud beschikbaar", "addMediaToLibraries": "Voeg wat media toe aan je bibliotheken", "continueWatching": "Verder kijken", + "nextUp": "Volgende", + "recentlyAdded": "Recent toegevoegd", "playEpisode": "S${season}E${episode}", "overview": "Overzicht", "cast": "Acteurs", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Zoeken mislukt: ${error}", "connectionTimeout": "Verbinding time-out tijdens laden ${context}", - "connectionFailed": "Kan geen verbinding maken met Plex server", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Kon ${context} niet laden: ${error}", "noClientAvailable": "Geen client beschikbaar", "authenticationFailed": "Authenticatie mislukt: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Voer een token in", "invalidToken": "Ongeldig token", "failedToVerifyToken": "Kon token niet verifiëren: ${error}", - "failedToSwitchProfile": "Kon niet wisselen naar ${displayName}" + "failedToSwitchProfile": "Kon niet wisselen naar ${displayName}", + "failedToDeleteProfile": "Kon ${displayName} niet verwijderen", + "failedToRate": "Beoordeling kon niet worden bijgewerkt" }, "libraries": { "title": "Bibliotheken", @@ -595,13 +662,30 @@ "seasons": "Seizoenen", "episodes": "Afleveringen", "folders": "Mappen" + }, + "filterCategories": { + "genre": "Genre", + "year": "Jaar", + "contentRating": "Leeftijdsclassificatie", + "tag": "Tag" + }, + "sortLabels": { + "title": "Titel", + "dateAdded": "Toegevoegd op", + "releaseDate": "Uitgavedatum", + "rating": "Beoordeling", + "lastPlayed": "Laatst afgespeeld", + "playCount": "Aantal afspelingen", + "random": "Willekeurig", + "dateShared": "Gedeeld op", + "latestEpisodeAirDate": "Laatste afleveringsuitzending" } }, "about": { "title": "Over", "openSourceLicenses": "Open Source licenties", "versionLabel": "Versie ${version}", - "appDescription": "Een mooie Plex client voor Flutter", + "appDescription": "Een mooie Plex- en Jellyfin-client voor Flutter", "viewLicensesDescription": "Bekijk licenties van third-party bibliotheken" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Wachten tot anderen geladen zijn...", "recentRooms": "Recente kamers", "renameRoom": "Kamer hernoemen", - "removeRoom": "Verwijderen" + "removeRoom": "Verwijderen", + "guestSwitchUnavailable": "Kon niet schakelen — server niet beschikbaar voor synchronisatie", + "guestSwitchFailed": "Kon niet schakelen — inhoud niet gevonden op deze server" }, "downloads": { "title": "Downloads", @@ -817,6 +903,12 @@ "editSyncFilter": "Synchronisatiefilter", "syncAllItems": "Alle items synchroniseren", "syncUnwatchedItems": "Ongekeken items synchroniseren", + "syncRuleServerContext": "Server: ${server} • ${status}", + "syncRuleAvailable": "Beschikbaar", + "syncRuleOffline": "Offline", + "syncRuleSignInRequired": "Inloggen vereist", + "syncRuleNotAvailableForProfile": "Niet beschikbaar voor huidig profiel", + "syncRuleUnknownServer": "Onbekende server", "syncRuleListCreated": "Synchronisatieregel aangemaakt" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Bibliotheken", "noLibraries": "Geen bibliotheken beschikbaar" } + }, + "addServer": { + "addJellyfinTitle": "Jellyfin-server toevoegen", + "jellyfinUrlIntro": "Voer de URL van je Jellyfin-server in — bijv. `https://jellyfin.example.com`. Je kunt daarna inloggen.", + "serverUrl": "Server-URL", + "findServer": "Server zoeken", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "signIn": "Inloggen", + "change": "Wijzigen", + "required": "Vereist", + "couldNotReachServer": "Kon de server niet bereiken: ${error}", + "signInFailed": "Inloggen mislukt: ${error}", + "quickConnectFailed": "Quick Connect mislukt: ${error}", + "addPlexTitle": "Inloggen met Plex", + "plexAuthIntro": "Kies hoe je wilt inloggen bij Plex. De browserflow opent plex.tv waar je de verbinding bevestigt; de QR-optie is handig voor TV / externe apparaten.", + "plexQRPrompt": "Scan deze QR-code om in te loggen.", + "waitingForPlexConfirmation": "Wachten tot plex.tv je inloggen bevestigt…", + "pinExpired": "PIN verlopen vóór inloggen. Probeer opnieuw.", + "duplicatePlexAccount": "Dit apparaat is al ingelogd op een Plex-account. Log uit via instellingen om van account te wisselen.", + "failedToRegisterAccount": "Account registreren mislukt: ${error}", + "enterJellyfinUrlError": "Voer de URL van je Jellyfin-server in", + "addConnectionTitle": "Verbinding toevoegen", + "addConnectionTitleScoped": "Toevoegen aan ${name}", + "addConnectionIntroGlobal": "Voeg nog een mediaserver toe. Je kunt Plex-accounts en Jellyfin-servers combineren — items van alle gekoppelde backends verschijnen samen op het startscherm.", + "addConnectionIntroScoped": "Voeg een nieuwe server toe, of leen er een van een ander profiel.", + "signInWithPlexCard": "Inloggen met Plex", + "signInWithPlexCardSubtitle": "Autoriseer dit apparaat met je Plex-account. Servers gedeeld met het account worden automatisch toegevoegd.", + "signInWithPlexCardSubtitleScoped": "Autoriseer een nieuw Plex-account. De bijbehorende Home-gebruikers verschijnen als profielen.", + "connectToJellyfinCard": "Verbinden met Jellyfin", + "connectToJellyfinCardSubtitle": "Voer de URL van je Jellyfin-server in en log in met gebruikersnaam + wachtwoord (Quick Connect komt eraan).", + "connectToJellyfinCardSubtitleScoped": "Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.", + "borrowFromAnotherProfile": "Lenen van een ander profiel", + "borrowFromAnotherProfileSubtitle": "Hergebruik een verbinding die al aan een ander profiel is gekoppeld. PIN-beveiligde bronprofielen vragen om de PIN." } } diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index f55dd7b0..68d070ad 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Zaloguj się", "signInWithPlex": "Zaloguj się przez Plex", "showQRCode": "Pokaż kod QR", "authenticate": "Uwierzytelnienie", "authenticationTimeout": "Upłynął czas uwierzytelniania. Spróbuj ponownie.", "scanQRToSignIn": "Zeskanuj ten kod QR, aby się zalogować", "waitingForAuth": "Oczekiwanie na uwierzytelnienie...\nDokończ logowanie w przeglądarce.", - "useBrowser": "Użyj przeglądarki" + "useBrowser": "Użyj przeglądarki", + "or": "lub", + "connectToJellyfin": "Połącz z Jellyfin", + "useQuickConnect": "Użyj Quick Connect", + "quickConnectCode": "Kod Quick Connect", + "quickConnectInstructions": "Otwórz swój serwer Jellyfin w przeglądarce, zaloguj się i wybierz Quick Connect z menu użytkownika. Wprowadź ten kod, aby zatwierdzić logowanie.", + "quickConnectWaiting": "Oczekiwanie na zatwierdzenie…", + "quickConnectCancel": "Anuluj", + "quickConnectExpired": "Kod Quick Connect wygasł przed zatwierdzeniem. Spróbuj ponownie." }, "common": { "cancel": "Anuluj", @@ -104,12 +113,12 @@ "gridView": "Siatka", "listView": "Lista", "showHeroSection": "Pokaż sekcję wyróżnioną", - "useGlobalHubs": "Użyj układu Plex Home", - "useGlobalHubsDescription": "Pokaż huby strony głównej jak w oficjalnym kliencie Plex. Gdy wyłączone, pokazuje rekomendacje per biblioteka.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Pokaż nazwę serwera w hubach", "showServerNameOnHubsDescription": "Zawsze wyświetlaj nazwę serwera w tytułach hubów. Gdy wyłączone, pokazuje tylko dla zduplikowanych nazw.", "groupLibrariesByServer": "Grupuj biblioteki według serwera", - "groupLibrariesByServerDescription": "Pokazuj nagłówek dla każdego serwera Plex na pasku bocznym, gdy jesteś połączony z wieloma serwerami.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Zawsze utrzymuj panel boczny otwarty", "alwaysKeepSidebarOpenDescription": "Panel boczny jest rozwinięty, a obszar treści dostosowuje się", "showUnwatchedCount": "Pokaż liczbę nieobejrzanych", @@ -452,7 +461,7 @@ "musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane", "noDescriptionAvailable": "Brak dostępnego opisu", "noProfilesAvailable": "Brak dostępnych profili", - "contactAdminForProfiles": "Skontaktuj się z administratorem Plex, aby dodać profile", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Nie można określić sekcji biblioteki dla tego elementu", "logsCleared": "Logi wyczyszczone", "logsCopied": "Logi skopiowane do schowka", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Potwierdź działanie" }, + "profiles": { + "addPlezyProfile": "Dodaj profil Plezy", + "switchingProfile": "Przełączanie profilu…", + "deleteThisProfileTitle": "Usunąć ten profil?", + "deleteThisProfileMessage": "${displayName} zostanie usunięty. Połączenia nie zostaną zmienione.", + "active": "Aktywny", + "manage": "Zarządzaj", + "delete": "Usuń", + "signOut": "Wyloguj się", + "signOutPlexTitle": "Wylogować się z Plex?", + "signOutPlexMessage": "${displayName} oraz wszyscy użytkownicy Plex Home na tym koncie zostaną usunięci z tego urządzenia. W każdej chwili możesz się zalogować ponownie.", + "signedOutPlex": "Wylogowano z Plex.", + "signOutFailed": "Wylogowanie nie powiodło się.", + "sectionTitle": "Profile", + "summarySingle": "Dodaj profile, aby łączyć zarządzanych użytkowników i tożsamości lokalne", + "summaryMultipleWithActive": "${count} profili · aktywny: ${activeName}", + "summaryMultiple": "${count} profili", + "removeConnectionTitle": "Usunąć połączenie?", + "removeConnectionMessage": "${displayName} straci dostęp do ${connectionLabel}. Samo połączenie pozostanie dostępne dla innych profili.", + "deleteProfileTitle": "Usunąć profil?", + "deleteProfileMessage": "Spowoduje to usunięcie ${displayName} i wszystkich jego połączeń z tego urządzenia. Nie wpłynie to na same serwery Plex/Jellyfin.", + "profileNameLabel": "Nazwa profilu", + "pinProtectionLabel": "Ochrona PIN-em", + "pinManagedByPlex": "PIN zarządzany przez Plex. Edytuj na plex.tv.", + "noPinSetEditOnPlex": "Nie ustawiono PIN-u. Aby go wymagać, edytuj użytkownika Home na plex.tv.", + "setPin": "Ustaw PIN", + "connectionsLabel": "Połączenia", + "add": "Dodaj", + "deleteProfileButton": "Usuń profil", + "noConnectionsHint": "Brak połączeń — dodaj jedno, aby używać tego profilu.", + "plexHomeAccount": "Konto Plex Home", + "connectionDefault": "Domyślne", + "makeDefault": "Ustaw jako domyślne", + "removeConnection": "Usuń", + "borrowAddTo": "Dodaj do ${displayName}", + "borrowExplain": "Pożycz połączenie z innego profilu. Profile źródłowe chronione PIN-em proszą o PIN przed udostępnieniem.", + "borrowEmpty": "Nic do pożyczenia.", + "borrowEmptySubtitle": "Podłącz najpierw konto Plex lub serwer Jellyfin do innego profilu i wróć tutaj.", + "newProfile": "Nowy profil", + "profileNameHint": "np. Goście, Dzieci, Salon", + "pinProtectionOptional": "Ochrona PIN-em (opcjonalnie)", + "pinExplain": "4-cyfrowy PIN wymagany do przełączenia się na ten profil. Miękka bariera — każdy kto może wyczyścić dane aplikacji, może ją obejść.", + "continueButton": "Kontynuuj", + "pinsDontMatch": "PIN-y nie pasują" + }, + "connections": { + "sectionTitle": "Połączenia", + "addConnection": "Dodaj połączenie", + "addConnectionSubtitleNoProfile": "Zaloguj się przez Plex lub połącz serwer Jellyfin", + "addConnectionSubtitleScoped": "Dodaj do ${displayName} — konto Plex, serwer Jellyfin lub pożycz z innego profilu", + "sessionExpiredOne": "Sesja wygasła dla ${name}", + "sessionExpiredMany": "Sesja wygasła dla ${count} serwerów", + "signInAgain": "Zaloguj się ponownie" + }, "discover": { "title": "Odkryj", "switchProfile": "Zmień profil", "noContentAvailable": "Brak dostępnych treści", "addMediaToLibraries": "Dodaj multimedia do swoich bibliotek", "continueWatching": "Kontynuuj oglądanie", + "nextUp": "Następny odcinek", + "recentlyAdded": "Ostatnio dodane", "playEpisode": "S${season}E${episode}", "overview": "Opis", "cast": "Obsada", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Wyszukiwanie nie powiodło się: ${error}", "connectionTimeout": "Limit czasu połączenia przy ładowaniu ${context}", - "connectionFailed": "Nie można połączyć z serwerem Plex", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Nie udało się załadować ${context}: ${error}", "noClientAvailable": "Brak dostępnego klienta", "authenticationFailed": "Uwierzytelnienie nie powiodło się: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Wprowadź token", "invalidToken": "Nieprawidłowy token", "failedToVerifyToken": "Nie udało się zweryfikować tokena: ${error}", - "failedToSwitchProfile": "Nie udało się przełączyć na ${displayName}" + "failedToSwitchProfile": "Nie udało się przełączyć na ${displayName}", + "failedToDeleteProfile": "Nie udało się usunąć ${displayName}", + "failedToRate": "Nie udało się zaktualizować oceny" }, "libraries": { "title": "Biblioteki", @@ -595,13 +662,30 @@ "seasons": "Sezony", "episodes": "Odcinki", "folders": "Foldery" + }, + "filterCategories": { + "genre": "Gatunek", + "year": "Rok", + "contentRating": "Klasyfikacja wiekowa", + "tag": "Tag" + }, + "sortLabels": { + "title": "Tytuł", + "dateAdded": "Data dodania", + "releaseDate": "Data premiery", + "rating": "Ocena", + "lastPlayed": "Ostatnio odtwarzane", + "playCount": "Liczba odtworzeń", + "random": "Losowo", + "dateShared": "Data udostępnienia", + "latestEpisodeAirDate": "Data emisji ostatniego odcinka" } }, "about": { "title": "O aplikacji", "openSourceLicenses": "Licencje open source", "versionLabel": "Wersja ${version}", - "appDescription": "Piękny klient Plex na Flutter", + "appDescription": "Piękny klient Plex i Jellyfin na Flutter", "viewLicensesDescription": "Zobacz licencje bibliotek zewnętrznych" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Oczekiwanie na załadowanie u innych...", "recentRooms": "Ostatnie pokoje", "renameRoom": "Zmień nazwę pokoju", - "removeRoom": "Usuń" + "removeRoom": "Usuń", + "guestSwitchUnavailable": "Nie można przełączyć — serwer niedostępny do synchronizacji", + "guestSwitchFailed": "Nie można przełączyć — nie znaleziono treści na tym serwerze" }, "downloads": { "title": "Pobrania", @@ -817,6 +903,12 @@ "editSyncFilter": "Filtr synchronizacji", "syncAllItems": "Synchronizuję wszystkie elementy", "syncUnwatchedItems": "Synchronizuję nieobejrzane elementy", + "syncRuleServerContext": "Serwer: ${server} • ${status}", + "syncRuleAvailable": "Dostępne", + "syncRuleOffline": "Offline", + "syncRuleSignInRequired": "Wymagane logowanie", + "syncRuleNotAvailableForProfile": "Niedostępne dla bieżącego profilu", + "syncRuleUnknownServer": "Nieznany serwer", "syncRuleListCreated": "Utworzono regułę synchronizacji" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Biblioteki", "noLibraries": "Brak dostępnych bibliotek" } + }, + "addServer": { + "addJellyfinTitle": "Dodaj serwer Jellyfin", + "jellyfinUrlIntro": "Podaj URL serwera Jellyfin — np. `https://jellyfin.example.com`. Możesz się zalogować później.", + "serverUrl": "URL serwera", + "findServer": "Znajdź serwer", + "username": "Nazwa użytkownika", + "password": "Hasło", + "signIn": "Zaloguj się", + "change": "Zmień", + "required": "Wymagane", + "couldNotReachServer": "Nie udało się połączyć z serwerem: ${error}", + "signInFailed": "Logowanie nie powiodło się: ${error}", + "quickConnectFailed": "Quick Connect nie powiodło się: ${error}", + "addPlexTitle": "Zaloguj się przez Plex", + "plexAuthIntro": "Wybierz sposób logowania do Plex. Przeglądarka otwiera plex.tv, gdzie potwierdzasz połączenie; opcja QR jest wygodna dla TV i urządzeń zdalnych.", + "plexQRPrompt": "Zeskanuj ten kod QR, aby się zalogować.", + "waitingForPlexConfirmation": "Czekam na potwierdzenie logowania przez plex.tv…", + "pinExpired": "PIN wygasł przed zalogowaniem. Spróbuj ponownie.", + "duplicatePlexAccount": "To urządzenie jest już zalogowane do konta Plex. Wyloguj się w ustawieniach, aby zmienić konto.", + "failedToRegisterAccount": "Nie udało się zarejestrować konta: ${error}", + "enterJellyfinUrlError": "Podaj URL serwera Jellyfin", + "addConnectionTitle": "Dodaj połączenie", + "addConnectionTitleScoped": "Dodaj do ${name}", + "addConnectionIntroGlobal": "Dodaj kolejny serwer multimediów. Możesz łączyć konta Plex i serwery Jellyfin — zawartość ze wszystkich podłączonych backendów pojawi się razem na ekranie głównym.", + "addConnectionIntroScoped": "Dodaj nowy serwer lub pożycz z innego profilu.", + "signInWithPlexCard": "Zaloguj się przez Plex", + "signInWithPlexCardSubtitle": "Autoryzuj to urządzenie dla swojego konta Plex. Serwery udostępnione kontu zostaną dodane automatycznie.", + "signInWithPlexCardSubtitleScoped": "Autoryzuj nowe konto Plex. Jego użytkownicy Home pojawią się jako profile.", + "connectToJellyfinCard": "Połącz z Jellyfin", + "connectToJellyfinCardSubtitle": "Podaj URL serwera Jellyfin i zaloguj się nazwą użytkownika + hasłem (Quick Connect wkrótce).", + "connectToJellyfinCardSubtitleScoped": "Zaloguj się do serwera Jellyfin. Powiązane z ${name}.", + "borrowFromAnotherProfile": "Pożycz z innego profilu", + "borrowFromAnotherProfileSubtitle": "Wykorzystaj ponownie połączenie przypisane już do innego profilu. Profile źródłowe chronione PIN-em poproszą o PIN." } } diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 18263928..3f4338c7 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Entrar", "signInWithPlex": "Entrar com Plex", "showQRCode": "Mostrar QR Code", "authenticate": "Autenticar", "authenticationTimeout": "A autenticação expirou. Tente novamente.", "scanQRToSignIn": "Escaneie este QR code para entrar", "waitingForAuth": "Aguardando autenticação...\nConclua o login no seu navegador.", - "useBrowser": "Usar navegador" + "useBrowser": "Usar navegador", + "or": "ou", + "connectToJellyfin": "Conectar ao Jellyfin", + "useQuickConnect": "Usar Quick Connect", + "quickConnectCode": "Código do Quick Connect", + "quickConnectInstructions": "Abra o seu servidor Jellyfin num navegador, inicie sessão e escolha Quick Connect no menu do utilizador. Insira este código para aprovar o início de sessão.", + "quickConnectWaiting": "A aguardar aprovação…", + "quickConnectCancel": "Cancelar", + "quickConnectExpired": "O código do Quick Connect expirou antes da aprovação. Tente novamente." }, "common": { "cancel": "Cancelar", @@ -104,12 +113,12 @@ "gridView": "Grade", "listView": "Lista", "showHeroSection": "Mostrar Seção de Destaque", - "useGlobalHubs": "Usar Layout Plex Home", - "useGlobalHubsDescription": "Mostrar hubs da página inicial como o cliente oficial Plex. Quando desativado, mostra recomendações por biblioteca.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Mostrar Nome do Servidor nos Hubs", "showServerNameOnHubsDescription": "Sempre exibir o nome do servidor nos títulos dos hubs. Quando desativado, mostra apenas para nomes duplicados.", "groupLibrariesByServer": "Agrupar Bibliotecas por Servidor", - "groupLibrariesByServerDescription": "Mostra um cabeçalho para cada servidor Plex na barra lateral quando você está conectado a vários servidores.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Manter Barra Lateral Sempre Aberta", "alwaysKeepSidebarOpenDescription": "A barra lateral fica expandida e a área de conteúdo se ajusta", "showUnwatchedCount": "Mostrar Contagem de Não Assistidos", @@ -452,7 +461,7 @@ "musicNotSupported": "Reprodução de música ainda não é suportada", "noDescriptionAvailable": "Nenhuma descrição disponível", "noProfilesAvailable": "Nenhum perfil disponível", - "contactAdminForProfiles": "Contacte o seu administrador Plex para adicionar perfis", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Não é possível determinar a secção da biblioteca para este item", "logsCleared": "Logs limpos", "logsCopied": "Logs copiados para a área de transferência", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Confirmar Ação" }, + "profiles": { + "addPlezyProfile": "Adicionar perfil Plezy", + "switchingProfile": "Mudando perfil…", + "deleteThisProfileTitle": "Excluir este perfil?", + "deleteThisProfileMessage": "${displayName} será removido. As conexões não serão afetadas.", + "active": "Ativo", + "manage": "Gerenciar", + "delete": "Excluir", + "signOut": "Sair", + "signOutPlexTitle": "Sair do Plex?", + "signOutPlexMessage": "${displayName} e todos os usuários do Plex Home desta conta serão removidos deste dispositivo. Você pode entrar novamente a qualquer momento.", + "signedOutPlex": "Saiu do Plex.", + "signOutFailed": "Falha ao sair.", + "sectionTitle": "Perfis", + "summarySingle": "Adicione perfis para mesclar usuários gerenciados e identidades locais", + "summaryMultipleWithActive": "${count} perfis · ativo: ${activeName}", + "summaryMultiple": "${count} perfis", + "removeConnectionTitle": "Remover conexão?", + "removeConnectionMessage": "${displayName} perderá o acesso a ${connectionLabel}. A conexão em si continua disponível para outros perfis.", + "deleteProfileTitle": "Excluir perfil?", + "deleteProfileMessage": "Isso remove ${displayName} e todas as suas conexões deste dispositivo. Os servidores Plex/Jellyfin subjacentes não são afetados.", + "profileNameLabel": "Nome do perfil", + "pinProtectionLabel": "Proteção por PIN", + "pinManagedByPlex": "PIN gerenciado pelo Plex. Edite em plex.tv.", + "noPinSetEditOnPlex": "Nenhum PIN definido. Para exigir um, edite o usuário Home em plex.tv.", + "setPin": "Definir PIN", + "connectionsLabel": "Conexões", + "add": "Adicionar", + "deleteProfileButton": "Excluir perfil", + "noConnectionsHint": "Sem conexões — adicione uma para usar este perfil.", + "plexHomeAccount": "Conta Plex Home", + "connectionDefault": "Padrão", + "makeDefault": "Definir como padrão", + "removeConnection": "Remover", + "borrowAddTo": "Adicionar a ${displayName}", + "borrowExplain": "Tome emprestada uma conexão de outro perfil. Perfis de origem protegidos por PIN pedem o PIN antes de compartilhar.", + "borrowEmpty": "Nada para emprestar ainda.", + "borrowEmptySubtitle": "Conecte primeiro uma conta Plex ou servidor Jellyfin a outro perfil e volte aqui.", + "newProfile": "Novo perfil", + "profileNameHint": "ex.: Visitantes, Crianças, Sala de família", + "pinProtectionOptional": "Proteção por PIN (opcional)", + "pinExplain": "PIN de 4 dígitos necessário para alternar para este perfil. Barreira suave — quem puder limpar os dados do app pode contorná-la.", + "continueButton": "Continuar", + "pinsDontMatch": "Os PINs não correspondem" + }, + "connections": { + "sectionTitle": "Conexões", + "addConnection": "Adicionar conexão", + "addConnectionSubtitleNoProfile": "Faça login com Plex ou conecte um servidor Jellyfin", + "addConnectionSubtitleScoped": "Adicionar a ${displayName} — conta Plex, servidor Jellyfin ou emprestar de outro perfil", + "sessionExpiredOne": "Sessão expirada para ${name}", + "sessionExpiredMany": "Sessão expirada para ${count} servidores", + "signInAgain": "Entrar novamente" + }, "discover": { "title": "Descobrir", "switchProfile": "Trocar Perfil", "noContentAvailable": "Nenhum conteúdo disponível", "addMediaToLibraries": "Adicione mídias às suas bibliotecas", "continueWatching": "Continuar Assistindo", + "nextUp": "A seguir", + "recentlyAdded": "Adicionados recentemente", "playEpisode": "S${season}E${episode}", "overview": "Sinopse", "cast": "Elenco", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Falha na busca: ${error}", "connectionTimeout": "Tempo de conexão esgotado ao carregar ${context}", - "connectionFailed": "Não foi possível conectar ao servidor Plex", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Falha ao carregar ${context}: ${error}", "noClientAvailable": "Nenhum cliente disponível", "authenticationFailed": "Falha na autenticação: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Insira um token", "invalidToken": "Token inválido", "failedToVerifyToken": "Falha ao verificar token: ${error}", - "failedToSwitchProfile": "Falha ao trocar para ${displayName}" + "failedToSwitchProfile": "Falha ao trocar para ${displayName}", + "failedToDeleteProfile": "Falha ao excluir ${displayName}", + "failedToRate": "Não foi possível atualizar a classificação" }, "libraries": { "title": "Bibliotecas", @@ -595,13 +662,30 @@ "seasons": "Temporadas", "episodes": "Episódios", "folders": "Pastas" + }, + "filterCategories": { + "genre": "Gênero", + "year": "Ano", + "contentRating": "Classificação", + "tag": "Tag" + }, + "sortLabels": { + "title": "Título", + "dateAdded": "Data de adição", + "releaseDate": "Data de lançamento", + "rating": "Avaliação", + "lastPlayed": "Última reprodução", + "playCount": "Reproduções", + "random": "Aleatório", + "dateShared": "Data de compartilhamento", + "latestEpisodeAirDate": "Última data de exibição do episódio" } }, "about": { "title": "Sobre", "openSourceLicenses": "Licenças Open Source", "versionLabel": "Versão ${version}", - "appDescription": "Um belo cliente Plex para Flutter", + "appDescription": "Um belo cliente Plex e Jellyfin para Flutter", "viewLicensesDescription": "Ver licenças de bibliotecas de terceiros" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Aguardando outros carregarem...", "recentRooms": "Salas recentes", "renameRoom": "Renomear sala", - "removeRoom": "Remover" + "removeRoom": "Remover", + "guestSwitchUnavailable": "Não foi possível trocar — servidor indisponível para sincronização", + "guestSwitchFailed": "Não foi possível trocar — conteúdo não encontrado neste servidor" }, "downloads": { "title": "Downloads", @@ -817,6 +903,12 @@ "editSyncFilter": "Filtro de sincronização", "syncAllItems": "Sincronizando todos os itens", "syncUnwatchedItems": "Sincronizando itens não vistos", + "syncRuleServerContext": "Servidor: ${server} • ${status}", + "syncRuleAvailable": "Disponível", + "syncRuleOffline": "Offline", + "syncRuleSignInRequired": "Início de sessão necessário", + "syncRuleNotAvailableForProfile": "Indisponível para o perfil atual", + "syncRuleUnknownServer": "Servidor desconhecido", "syncRuleListCreated": "Regra de sincronização criada" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Bibliotecas", "noLibraries": "Nenhuma biblioteca disponível" } + }, + "addServer": { + "addJellyfinTitle": "Adicionar servidor Jellyfin", + "jellyfinUrlIntro": "Insira a URL do seu servidor Jellyfin — ex.: `https://jellyfin.example.com`. Você pode entrar em seguida.", + "serverUrl": "URL do servidor", + "findServer": "Encontrar servidor", + "username": "Usuário", + "password": "Senha", + "signIn": "Entrar", + "change": "Alterar", + "required": "Obrigatório", + "couldNotReachServer": "Não foi possível conectar ao servidor: ${error}", + "signInFailed": "Falha ao entrar: ${error}", + "quickConnectFailed": "Quick Connect falhou: ${error}", + "addPlexTitle": "Entrar com Plex", + "plexAuthIntro": "Escolha como entrar no Plex. O fluxo do navegador abre plex.tv onde você confirma a conexão; a opção QR é prática para TV / dispositivos remotos.", + "plexQRPrompt": "Escaneie este código QR para entrar.", + "waitingForPlexConfirmation": "Aguardando o plex.tv confirmar o login…", + "pinExpired": "O PIN expirou antes do login. Tente novamente.", + "duplicatePlexAccount": "Este dispositivo já está conectado a uma conta Plex. Saia nas configurações para trocar de conta.", + "failedToRegisterAccount": "Falha ao registrar a conta: ${error}", + "enterJellyfinUrlError": "Insira a URL do seu servidor Jellyfin", + "addConnectionTitle": "Adicionar conexão", + "addConnectionTitleScoped": "Adicionar a ${name}", + "addConnectionIntroGlobal": "Adicione outro servidor de mídia. Você pode misturar contas Plex e servidores Jellyfin — itens de cada backend conectado aparecem juntos na tela inicial.", + "addConnectionIntroScoped": "Adicione um novo servidor ou pegue um emprestado de outro perfil.", + "signInWithPlexCard": "Entrar com Plex", + "signInWithPlexCardSubtitle": "Autorize este dispositivo na sua conta Plex. Servidores compartilhados com a conta vêm junto automaticamente.", + "signInWithPlexCardSubtitleScoped": "Autorize uma nova conta Plex. Seus usuários Home aparecem como perfis.", + "connectToJellyfinCard": "Conectar ao Jellyfin", + "connectToJellyfinCardSubtitle": "Insira a URL do seu servidor Jellyfin e entre com usuário + senha (Quick Connect em breve).", + "connectToJellyfinCardSubtitleScoped": "Entre em um servidor Jellyfin. Vinculado a ${name}.", + "borrowFromAnotherProfile": "Pegar emprestado de outro perfil", + "borrowFromAnotherProfileSubtitle": "Reutilize uma conexão já associada a outro perfil. Perfis de origem protegidos por PIN solicitarão o PIN." } } diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 578b8d0b..ae9c940a 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Войти", "signInWithPlex": "Войти через Plex", "showQRCode": "Показать QR-код", "authenticate": "Аутентификация", "authenticationTimeout": "Время аутентификации истекло. Попробуйте снова.", "scanQRToSignIn": "Отсканируйте QR-код для входа", "waitingForAuth": "Ожидание аутентификации...\nЗавершите вход в браузере.", - "useBrowser": "Использовать браузер" + "useBrowser": "Использовать браузер", + "or": "или", + "connectToJellyfin": "Подключиться к Jellyfin", + "useQuickConnect": "Использовать Quick Connect", + "quickConnectCode": "Код Quick Connect", + "quickConnectInstructions": "Откройте сервер Jellyfin в браузере, войдите и выберите Quick Connect в меню пользователя. Введите этот код, чтобы подтвердить вход.", + "quickConnectWaiting": "Ожидание подтверждения…", + "quickConnectCancel": "Отмена", + "quickConnectExpired": "Срок действия кода Quick Connect истёк до подтверждения. Повторите попытку." }, "common": { "cancel": "Отмена", @@ -104,12 +113,12 @@ "gridView": "Сетка", "listView": "Список", "showHeroSection": "Показать раздел избранного", - "useGlobalHubs": "Использовать макет Plex Home", - "useGlobalHubsDescription": "Показывать хабы главной страницы как в официальном клиенте Plex. При выключении показывает рекомендации по библиотекам.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Показывать имя сервера в хабах", "showServerNameOnHubsDescription": "Всегда показывать имя сервера в заголовках хабов. При выключении показывает только для дублирующихся имён.", "groupLibrariesByServer": "Группировать библиотеки по серверам", - "groupLibrariesByServerDescription": "Показывать заголовок для каждого сервера Plex на боковой панели при подключении к нескольким серверам.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Всегда держать боковую панель открытой", "alwaysKeepSidebarOpenDescription": "Боковая панель остаётся развёрнутой, область контента подстраивается", "showUnwatchedCount": "Показывать количество непросмотренных", @@ -452,7 +461,7 @@ "musicNotSupported": "Воспроизведение музыки пока не поддерживается", "noDescriptionAvailable": "Описание недоступно", "noProfilesAvailable": "Профили недоступны", - "contactAdminForProfiles": "Обратитесь к администратору Plex, чтобы добавить профили", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Не удаётся определить раздел библиотеки для этого элемента", "logsCleared": "Логи очищены", "logsCopied": "Логи скопированы в буфер обмена", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Подтвердить действие" }, + "profiles": { + "addPlezyProfile": "Добавить профиль Plezy", + "switchingProfile": "Переключение профиля…", + "deleteThisProfileTitle": "Удалить этот профиль?", + "deleteThisProfileMessage": "${displayName} будет удалён. Подключения не пострадают.", + "active": "Активный", + "manage": "Управление", + "delete": "Удалить", + "signOut": "Выйти", + "signOutPlexTitle": "Выйти из Plex?", + "signOutPlexMessage": "${displayName} и все пользователи Plex Home этой учётной записи будут удалены с этого устройства. Вы можете войти снова в любое время.", + "signedOutPlex": "Вы вышли из Plex.", + "signOutFailed": "Не удалось выйти.", + "sectionTitle": "Профили", + "summarySingle": "Добавьте профили, чтобы смешать управляемых пользователей и локальные идентификаторы", + "summaryMultipleWithActive": "${count} профилей · активный: ${activeName}", + "summaryMultiple": "${count} профилей", + "removeConnectionTitle": "Удалить соединение?", + "removeConnectionMessage": "${displayName} потеряет доступ к ${connectionLabel}. Само соединение останется доступным для других профилей.", + "deleteProfileTitle": "Удалить профиль?", + "deleteProfileMessage": "Это удалит ${displayName} и все его соединения с этого устройства. Сами серверы Plex/Jellyfin не будут затронуты.", + "profileNameLabel": "Имя профиля", + "pinProtectionLabel": "Защита PIN-кодом", + "pinManagedByPlex": "PIN управляется Plex. Редактируйте на plex.tv.", + "noPinSetEditOnPlex": "PIN не установлен. Чтобы требовать его, отредактируйте пользователя Home на plex.tv.", + "setPin": "Установить PIN", + "connectionsLabel": "Соединения", + "add": "Добавить", + "deleteProfileButton": "Удалить профиль", + "noConnectionsHint": "Нет соединений — добавьте одно, чтобы использовать этот профиль.", + "plexHomeAccount": "Аккаунт Plex Home", + "connectionDefault": "По умолчанию", + "makeDefault": "Сделать по умолчанию", + "removeConnection": "Удалить", + "borrowAddTo": "Добавить в ${displayName}", + "borrowExplain": "Заимствуйте соединение из другого профиля. Защищённые PIN-кодом исходные профили запросят PIN перед предоставлением доступа.", + "borrowEmpty": "Пока нечего заимствовать.", + "borrowEmptySubtitle": "Сначала подключите аккаунт Plex или сервер Jellyfin к другому профилю, а затем вернитесь сюда.", + "newProfile": "Новый профиль", + "profileNameHint": "например, Гости, Дети, Семейная комната", + "pinProtectionOptional": "Защита PIN-кодом (необязательно)", + "pinExplain": "Для переключения на этот профиль требуется 4-значный PIN. Мягкий барьер — любой, кто может очистить данные приложения, может его обойти.", + "continueButton": "Продолжить", + "pinsDontMatch": "PIN-коды не совпадают" + }, + "connections": { + "sectionTitle": "Подключения", + "addConnection": "Добавить подключение", + "addConnectionSubtitleNoProfile": "Войдите через Plex или подключите сервер Jellyfin", + "addConnectionSubtitleScoped": "Добавить к ${displayName} — учётная запись Plex, сервер Jellyfin или заимствовать из другого профиля", + "sessionExpiredOne": "Сессия истекла для ${name}", + "sessionExpiredMany": "Сессия истекла для ${count} серверов", + "signInAgain": "Войти снова" + }, "discover": { "title": "Обзор", "switchProfile": "Сменить профиль", "noContentAvailable": "Контент недоступен", "addMediaToLibraries": "Добавьте медиафайлы в ваши библиотеки", "continueWatching": "Продолжить просмотр", + "nextUp": "Далее", + "recentlyAdded": "Недавно добавленное", "playEpisode": "S${season}E${episode}", "overview": "Обзор", "cast": "В ролях", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Ошибка поиска: ${error}", "connectionTimeout": "Таймаут подключения при загрузке ${context}", - "connectionFailed": "Не удаётся подключиться к серверу Plex", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Не удалось загрузить ${context}: ${error}", "noClientAvailable": "Клиент недоступен", "authenticationFailed": "Ошибка аутентификации: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Введите токен", "invalidToken": "Недействительный токен", "failedToVerifyToken": "Не удалось проверить токен: ${error}", - "failedToSwitchProfile": "Не удалось переключиться на ${displayName}" + "failedToSwitchProfile": "Не удалось переключиться на ${displayName}", + "failedToDeleteProfile": "Не удалось удалить ${displayName}", + "failedToRate": "Не удалось обновить оценку" }, "libraries": { "title": "Библиотеки", @@ -595,13 +662,30 @@ "seasons": "Сезоны", "episodes": "Эпизоды", "folders": "Папки" + }, + "filterCategories": { + "genre": "Жанр", + "year": "Год", + "contentRating": "Возрастной рейтинг", + "tag": "Тег" + }, + "sortLabels": { + "title": "Название", + "dateAdded": "Дата добавления", + "releaseDate": "Дата выхода", + "rating": "Рейтинг", + "lastPlayed": "Последний просмотр", + "playCount": "Количество просмотров", + "random": "Случайно", + "dateShared": "Дата открытия доступа", + "latestEpisodeAirDate": "Дата выхода последнего эпизода" } }, "about": { "title": "О приложении", "openSourceLicenses": "Лицензии открытого ПО", "versionLabel": "Версия ${version}", - "appDescription": "Красивый клиент Plex на Flutter", + "appDescription": "Красивый клиент Plex и Jellyfin на Flutter", "viewLicensesDescription": "Просмотр лицензий сторонних библиотек" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Ожидание загрузки у других...", "recentRooms": "Недавние комнаты", "renameRoom": "Переименовать комнату", - "removeRoom": "Удалить" + "removeRoom": "Удалить", + "guestSwitchUnavailable": "Не удалось переключиться — сервер недоступен для синхронизации", + "guestSwitchFailed": "Не удалось переключиться — содержимое не найдено на этом сервере" }, "downloads": { "title": "Загрузки", @@ -817,6 +903,12 @@ "editSyncFilter": "Фильтр синхронизации", "syncAllItems": "Синхронизация всех элементов", "syncUnwatchedItems": "Синхронизация непросмотренных элементов", + "syncRuleServerContext": "Сервер: ${server} • ${status}", + "syncRuleAvailable": "Доступен", + "syncRuleOffline": "Офлайн", + "syncRuleSignInRequired": "Требуется вход", + "syncRuleNotAvailableForProfile": "Недоступно для текущего профиля", + "syncRuleUnknownServer": "Неизвестный сервер", "syncRuleListCreated": "Правило синхронизации создано" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Библиотеки", "noLibraries": "Библиотеки недоступны" } + }, + "addServer": { + "addJellyfinTitle": "Добавить сервер Jellyfin", + "jellyfinUrlIntro": "Введите URL вашего сервера Jellyfin — например, `https://jellyfin.example.com`. Войти можно после.", + "serverUrl": "URL сервера", + "findServer": "Найти сервер", + "username": "Имя пользователя", + "password": "Пароль", + "signIn": "Войти", + "change": "Изменить", + "required": "Обязательно", + "couldNotReachServer": "Не удалось связаться с сервером: ${error}", + "signInFailed": "Не удалось войти: ${error}", + "quickConnectFailed": "Quick Connect не удался: ${error}", + "addPlexTitle": "Войти через Plex", + "plexAuthIntro": "Выберите способ входа в Plex. Поток в браузере открывает plex.tv, где вы подтверждаете подключение; QR-вариант удобен для TV или удалённых устройств.", + "plexQRPrompt": "Отсканируйте этот QR-код, чтобы войти.", + "waitingForPlexConfirmation": "Ожидание подтверждения от plex.tv…", + "pinExpired": "Срок действия PIN истёк до входа. Попробуйте снова.", + "duplicatePlexAccount": "Это устройство уже подключено к учётной записи Plex. Выйдите в настройках, чтобы сменить учётную запись.", + "failedToRegisterAccount": "Не удалось зарегистрировать учётную запись: ${error}", + "enterJellyfinUrlError": "Введите URL вашего сервера Jellyfin", + "addConnectionTitle": "Добавить подключение", + "addConnectionTitleScoped": "Добавить в ${name}", + "addConnectionIntroGlobal": "Добавьте ещё один медиасервер. Можно сочетать учётные записи Plex и серверы Jellyfin — элементы со всех подключённых бэкендов появятся вместе на главном экране.", + "addConnectionIntroScoped": "Добавьте новый сервер или одолжите из другого профиля.", + "signInWithPlexCard": "Войти через Plex", + "signInWithPlexCardSubtitle": "Авторизуйте это устройство в вашей учётной записи Plex. Серверы, общие с учётной записью, добавятся автоматически.", + "signInWithPlexCardSubtitleScoped": "Авторизуйте новую учётную запись Plex. Её Home-пользователи появятся как профили.", + "connectToJellyfinCard": "Подключиться к Jellyfin", + "connectToJellyfinCardSubtitle": "Введите URL сервера Jellyfin и войдите с именем пользователя и паролем (Quick Connect — скоро).", + "connectToJellyfinCardSubtitleScoped": "Войдите на сервер Jellyfin. Привязывается к ${name}.", + "borrowFromAnotherProfile": "Одолжить из другого профиля", + "borrowFromAnotherProfileSubtitle": "Повторно используйте подключение, уже привязанное к другому профилю. PIN-защищённые исходные профили запрашивают PIN." } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 11db01f9..68e8d883 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 15 -/// Strings: 14625 (975 per locale) +/// Strings: 16365 (1091 per locale) /// -/// Built on 2026-04-27 at 13:13 UTC +/// Built on 2026-04-30 at 17:27 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 38024b0d..350056b2 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsDa with BaseTranslations implements Translations { +class TranslationsDa extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsDa({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsDa with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsDa with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsDa _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsDa with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingDa subtitlingStyling = _TranslationsSubtitlingStylingDa._(_root); @override late final _TranslationsMpvConfigDa mpvConfig = _TranslationsMpvConfigDa._(_root); @override late final _TranslationsDialogDa dialog = _TranslationsDialogDa._(_root); + @override late final _TranslationsProfilesDa profiles = _TranslationsProfilesDa._(_root); + @override late final _TranslationsConnectionsDa connections = _TranslationsConnectionsDa._(_root); @override late final _TranslationsDiscoverDa discover = _TranslationsDiscoverDa._(_root); @override late final _TranslationsErrorsDa errors = _TranslationsErrorsDa._(_root); @override late final _TranslationsLibrariesDa libraries = _TranslationsLibrariesDa._(_root); @@ -78,11 +82,12 @@ class TranslationsDa with BaseTranslations implements T @override late final _TranslationsServerTasksDa serverTasks = _TranslationsServerTasksDa._(_root); @override late final _TranslationsTraktDa trakt = _TranslationsTraktDa._(_root); @override late final _TranslationsTrackersDa trackers = _TranslationsTrackersDa._(_root); + @override late final _TranslationsAddServerDa addServer = _TranslationsAddServerDa._(_root); } // Path: app -class _TranslationsAppDa implements TranslationsAppEn { - _TranslationsAppDa._(this._root); +class _TranslationsAppDa extends TranslationsAppEn { + _TranslationsAppDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppDa implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthDa implements TranslationsAuthEn { - _TranslationsAuthDa._(this._root); +class _TranslationsAuthDa extends TranslationsAuthEn { + _TranslationsAuthDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field // Translations + @override String get signIn => 'Log ind'; @override String get signInWithPlex => 'Log ind med Plex'; @override String get showQRCode => 'Vis QR-kode'; @override String get authenticate => 'Godkend'; @@ -104,11 +110,19 @@ class _TranslationsAuthDa implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Scan denne QR-kode for at logge ind'; @override String get waitingForAuth => 'Venter på godkendelse...\nFærdiggør login i din browser.'; @override String get useBrowser => 'Brug browser'; + @override String get or => 'eller'; + @override String get connectToJellyfin => 'Forbind til Jellyfin'; + @override String get useQuickConnect => 'Brug Quick Connect'; + @override String get quickConnectCode => 'Quick Connect-kode'; + @override String get quickConnectInstructions => 'Åbn din Jellyfin-server i en webbrowser, log ind, og vælg Quick Connect i brugermenuen. Indtast denne kode for at godkende loginnet.'; + @override String get quickConnectWaiting => 'Venter på godkendelse…'; + @override String get quickConnectCancel => 'Annullér'; + @override String get quickConnectExpired => 'Quick Connect-koden udløb inden godkendelse. Prøv igen.'; } // Path: common -class _TranslationsCommonDa implements TranslationsCommonEn { - _TranslationsCommonDa._(this._root); +class _TranslationsCommonDa extends TranslationsCommonEn { + _TranslationsCommonDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonDa implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensDa implements TranslationsScreensEn { - _TranslationsScreensDa._(this._root); +class _TranslationsScreensDa extends TranslationsScreensEn { + _TranslationsScreensDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensDa implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateDa implements TranslationsUpdateEn { - _TranslationsUpdateDa._(this._root); +class _TranslationsUpdateDa extends TranslationsUpdateEn { + _TranslationsUpdateDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateDa implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsDa implements TranslationsSettingsEn { - _TranslationsSettingsDa._(this._root); +class _TranslationsSettingsDa extends TranslationsSettingsEn { + _TranslationsSettingsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsDa implements TranslationsSettingsEn { @override String get gridView => 'Gitter'; @override String get listView => 'Liste'; @override String get showHeroSection => 'Vis hero-sektion'; - @override String get useGlobalHubs => 'Brug Plex Home-layout'; - @override String get useGlobalHubsDescription => 'Vis startsidehubbe som den officielle Plex-klient. Når slået fra, vises anbefalinger per bibliotek.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Vis servernavn på hubbe'; @override String get showServerNameOnHubsDescription => 'Vis altid servernavnet i hubtitler. Når slået fra, vises kun ved duplikerede navne.'; @override String get groupLibrariesByServer => 'Grupper biblioteker efter server'; - @override String get groupLibrariesByServerDescription => 'Vis en overskrift for hver Plex-server i sidepanelet, når du er forbundet til flere servere.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Hold altid sidepanelet åbent'; @override String get alwaysKeepSidebarOpenDescription => 'Sidepanelet forbliver udvidet, og indholdsområdet tilpasser sig'; @override String get showUnwatchedCount => 'Vis antal usete'; @@ -385,8 +399,8 @@ class _TranslationsSettingsDa implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchDa implements TranslationsSearchEn { - _TranslationsSearchDa._(this._root); +class _TranslationsSearchDa extends TranslationsSearchEn { + _TranslationsSearchDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchDa implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysDa implements TranslationsHotkeysEn { - _TranslationsHotkeysDa._(this._root); +class _TranslationsHotkeysDa extends TranslationsHotkeysEn { + _TranslationsHotkeysDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysDa implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoDa implements TranslationsFileInfoEn { - _TranslationsFileInfoDa._(this._root); +class _TranslationsFileInfoDa extends TranslationsFileInfoEn { + _TranslationsFileInfoDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoDa implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuDa implements TranslationsMediaMenuEn { - _TranslationsMediaMenuDa._(this._root); +class _TranslationsMediaMenuDa extends TranslationsMediaMenuEn { + _TranslationsMediaMenuDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuDa implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityDa implements TranslationsAccessibilityEn { - _TranslationsAccessibilityDa._(this._root); +class _TranslationsAccessibilityDa extends TranslationsAccessibilityEn { + _TranslationsAccessibilityDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityDa implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsDa implements TranslationsTooltipsEn { - _TranslationsTooltipsDa._(this._root); +class _TranslationsTooltipsDa extends TranslationsTooltipsEn { + _TranslationsTooltipsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsDa implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsDa implements TranslationsVideoControlsEn { - _TranslationsVideoControlsDa._(this._root); +class _TranslationsVideoControlsDa extends TranslationsVideoControlsEn { + _TranslationsVideoControlsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsDa implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusDa implements TranslationsUserStatusEn { - _TranslationsUserStatusDa._(this._root); +class _TranslationsUserStatusDa extends TranslationsUserStatusEn { + _TranslationsUserStatusDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusDa implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesDa implements TranslationsMessagesEn { - _TranslationsMessagesDa._(this._root); +class _TranslationsMessagesDa extends TranslationsMessagesEn { + _TranslationsMessagesDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesDa implements TranslationsMessagesEn { @override String get musicNotSupported => 'Musikafspilning understøttes endnu ikke'; @override String get noDescriptionAvailable => 'Ingen beskrivelse tilgængelig'; @override String get noProfilesAvailable => 'Ingen profiler tilgængelige'; - @override String get contactAdminForProfiles => 'Kontakt din Plex-administrator for at tilføje profiler'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Kan ikke bestemme biblioteksafdeling for dette element'; @override String get logsCleared => 'Logs ryddet'; @override String get logsCopied => 'Logs kopieret til udklipsholder'; @@ -636,8 +650,8 @@ class _TranslationsMessagesDa implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingDa implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingDa._(this._root); +class _TranslationsSubtitlingStylingDa extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingDa implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigDa implements TranslationsMpvConfigEn { - _TranslationsMpvConfigDa._(this._root); +class _TranslationsMpvConfigDa extends TranslationsMpvConfigEn { + _TranslationsMpvConfigDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigDa implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogDa implements TranslationsDialogEn { - _TranslationsDialogDa._(this._root); +class _TranslationsDialogDa extends TranslationsDialogEn { + _TranslationsDialogDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogDa implements TranslationsDialogEn { @override String get confirmAction => 'Bekræft handling'; } +// Path: profiles +class _TranslationsProfilesDa extends TranslationsProfilesEn { + _TranslationsProfilesDa._(TranslationsDa root) : this._root = root, super.internal(root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Tilføj Plezy-profil'; + @override String get switchingProfile => 'Skifter profil…'; + @override String get deleteThisProfileTitle => 'Slet denne profil?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} fjernes. Forbindelser påvirkes ikke.'; + @override String get active => 'Aktiv'; + @override String get manage => 'Administrer'; + @override String get delete => 'Slet'; + @override String get signOut => 'Log ud'; + @override String get signOutPlexTitle => 'Log ud af Plex?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} og alle Plex Home-brugere på denne konto fjernes fra denne enhed. Du kan logge ind igen når som helst.'; + @override String get signedOutPlex => 'Logget ud af Plex.'; + @override String get signOutFailed => 'Log ud mislykkedes.'; + @override String get sectionTitle => 'Profiler'; + @override String get summarySingle => 'Tilføj profiler for at blande administrerede brugere og lokale identiteter'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} profiler · aktiv: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} profiler'; + @override String get removeConnectionTitle => 'Fjern forbindelse?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} mister adgang til ${connectionLabel}. Forbindelsen forbliver tilgængelig for andre profiler.'; + @override String get deleteProfileTitle => 'Slet profil?'; + @override String deleteProfileMessage({required Object displayName}) => 'Dette fjerner ${displayName} og alle dens forbindelser fra denne enhed. De underliggende Plex/Jellyfin-servere påvirkes ikke.'; + @override String get profileNameLabel => 'Profilnavn'; + @override String get pinProtectionLabel => 'PIN-beskyttelse'; + @override String get pinManagedByPlex => 'PIN administreres af Plex. Rediger på plex.tv.'; + @override String get noPinSetEditOnPlex => 'Ingen PIN-kode angivet. For at kræve en, redigér Home-brugeren på plex.tv.'; + @override String get setPin => 'Angiv PIN'; + @override String get connectionsLabel => 'Forbindelser'; + @override String get add => 'Tilføj'; + @override String get deleteProfileButton => 'Slet profil'; + @override String get noConnectionsHint => 'Ingen forbindelser — tilføj en for at bruge denne profil.'; + @override String get plexHomeAccount => 'Plex Home-konto'; + @override String get connectionDefault => 'Standard'; + @override String get makeDefault => 'Gør til standard'; + @override String get removeConnection => 'Fjern'; + @override String borrowAddTo({required Object displayName}) => 'Tilføj til ${displayName}'; + @override String get borrowExplain => 'Lån en forbindelse fra en anden profil. PIN-beskyttede kildeprofiler beder om PIN, før de deler.'; + @override String get borrowEmpty => 'Intet at låne endnu.'; + @override String get borrowEmptySubtitle => 'Tilslut først en Plex-konto eller Jellyfin-server til en anden profil, og kom så tilbage hertil.'; + @override String get newProfile => 'Ny profil'; + @override String get profileNameHint => 'fx. Gæster, Børn, Familiens stue'; + @override String get pinProtectionOptional => 'PIN-beskyttelse (valgfri)'; + @override String get pinExplain => '4-cifret PIN-kode kræves for at skifte til denne profil. Blød barriere — enhver der kan slette appdata, kan omgå den.'; + @override String get continueButton => 'Fortsæt'; + @override String get pinsDontMatch => 'PIN-koder matcher ikke'; +} + +// Path: connections +class _TranslationsConnectionsDa extends TranslationsConnectionsEn { + _TranslationsConnectionsDa._(TranslationsDa root) : this._root = root, super.internal(root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Forbindelser'; + @override String get addConnection => 'Tilføj forbindelse'; + @override String get addConnectionSubtitleNoProfile => 'Log ind med Plex eller forbind til en Jellyfin-server'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Tilføj til ${displayName} — Plex-konto, Jellyfin-server eller lån fra en anden profil'; + @override String sessionExpiredOne({required Object name}) => 'Sessionen er udløbet for ${name}'; + @override String sessionExpiredMany({required Object count}) => 'Sessionen er udløbet for ${count} servere'; + @override String get signInAgain => 'Log ind igen'; +} + // Path: discover -class _TranslationsDiscoverDa implements TranslationsDiscoverEn { - _TranslationsDiscoverDa._(this._root); +class _TranslationsDiscoverDa extends TranslationsDiscoverEn { + _TranslationsDiscoverDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverDa implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Intet indhold tilgængeligt'; @override String get addMediaToLibraries => 'Tilføj medier til dine biblioteker'; @override String get continueWatching => 'Fortsæt med at se'; + @override String get nextUp => 'Næste op'; + @override String get recentlyAdded => 'Nyligt tilføjet'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Oversigt'; @override String get cast => 'Rollebesætning'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverDa implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsDa implements TranslationsErrorsEn { - _TranslationsErrorsDa._(this._root); +class _TranslationsErrorsDa extends TranslationsErrorsEn { + _TranslationsErrorsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Søgning mislykkedes: ${error}'; @override String connectionTimeout({required Object context}) => 'Forbindelsestimeout ved indlæsning af ${context}'; - @override String get connectionFailed => 'Kunne ikke forbinde til Plex-server'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Kunne ikke indlæse ${context}: ${error}'; @override String get noClientAvailable => 'Ingen klient tilgængelig'; @override String authenticationFailed({required Object error}) => 'Godkendelse mislykkedes: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsDa implements TranslationsErrorsEn { @override String get invalidToken => 'Ugyldigt token'; @override String failedToVerifyToken({required Object error}) => 'Kunne ikke verificere token: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Kunne ikke skifte til ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Kunne ikke slette ${displayName}'; + @override String get failedToRate => 'Kunne ikke opdatere bedømmelsen'; } // Path: libraries -class _TranslationsLibrariesDa implements TranslationsLibrariesEn { - _TranslationsLibrariesDa._(this._root); +class _TranslationsLibrariesDa extends TranslationsLibrariesEn { + _TranslationsLibrariesDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesDa implements TranslationsLibrariesEn { @override String get folders => 'mapper'; @override late final _TranslationsLibrariesTabsDa tabs = _TranslationsLibrariesTabsDa._(_root); @override late final _TranslationsLibrariesGroupingsDa groupings = _TranslationsLibrariesGroupingsDa._(_root); + @override late final _TranslationsLibrariesFilterCategoriesDa filterCategories = _TranslationsLibrariesFilterCategoriesDa._(_root); + @override late final _TranslationsLibrariesSortLabelsDa sortLabels = _TranslationsLibrariesSortLabelsDa._(_root); } // Path: about -class _TranslationsAboutDa implements TranslationsAboutEn { - _TranslationsAboutDa._(this._root); +class _TranslationsAboutDa extends TranslationsAboutEn { + _TranslationsAboutDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutDa implements TranslationsAboutEn { @override String get title => 'Om'; @override String get openSourceLicenses => 'Open source-licenser'; @override String versionLabel({required Object version}) => 'Version ${version}'; - @override String get appDescription => 'En smuk Plex-klient til Flutter'; + @override String get appDescription => 'En smuk Plex- og Jellyfin-klient til Flutter'; @override String get viewLicensesDescription => 'Se licenser for tredjepartsbiblioteker'; } // Path: serverSelection -class _TranslationsServerSelectionDa implements TranslationsServerSelectionEn { - _TranslationsServerSelectionDa._(this._root); +class _TranslationsServerSelectionDa extends TranslationsServerSelectionEn { + _TranslationsServerSelectionDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionDa implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailDa implements TranslationsHubDetailEn { - _TranslationsHubDetailDa._(this._root); +class _TranslationsHubDetailDa extends TranslationsHubDetailEn { + _TranslationsHubDetailDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailDa implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsDa implements TranslationsLogsEn { - _TranslationsLogsDa._(this._root); +class _TranslationsLogsDa extends TranslationsLogsEn { + _TranslationsLogsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsDa implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesDa implements TranslationsLicensesEn { - _TranslationsLicensesDa._(this._root); +class _TranslationsLicensesDa extends TranslationsLicensesEn { + _TranslationsLicensesDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesDa implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationDa implements TranslationsNavigationEn { - _TranslationsNavigationDa._(this._root); +class _TranslationsNavigationDa extends TranslationsNavigationEn { + _TranslationsNavigationDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationDa implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvDa implements TranslationsLiveTvEn { - _TranslationsLiveTvDa._(this._root); +class _TranslationsLiveTvDa extends TranslationsLiveTvEn { + _TranslationsLiveTvDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvDa implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsDa implements TranslationsCollectionsEn { - _TranslationsCollectionsDa._(this._root); +class _TranslationsCollectionsDa extends TranslationsCollectionsEn { + _TranslationsCollectionsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsDa implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsDa implements TranslationsPlaylistsEn { - _TranslationsPlaylistsDa._(this._root); +class _TranslationsPlaylistsDa extends TranslationsPlaylistsEn { + _TranslationsPlaylistsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsDa implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherDa implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherDa._(this._root); +class _TranslationsWatchTogetherDa extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherDa implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Seneste rum'; @override String get renameRoom => 'Omdøb rum'; @override String get removeRoom => 'Fjern'; + @override String get guestSwitchUnavailable => 'Kunne ikke skifte — server ikke tilgængelig for synkronisering'; + @override String get guestSwitchFailed => 'Kunne ikke skifte — indhold blev ikke fundet på denne server'; } // Path: downloads -class _TranslationsDownloadsDa implements TranslationsDownloadsEn { - _TranslationsDownloadsDa._(this._root); +class _TranslationsDownloadsDa extends TranslationsDownloadsEn { + _TranslationsDownloadsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsDa implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Synkroniseringsfilter'; @override String get syncAllItems => 'Synkroniserer alle elementer'; @override String get syncUnwatchedItems => 'Synkroniserer usete elementer'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Server: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Tilgængelig'; + @override String get syncRuleOffline => 'Offline'; + @override String get syncRuleSignInRequired => 'Log ind påkrævet'; + @override String get syncRuleNotAvailableForProfile => 'Ikke tilgængelig for nuværende profil'; + @override String get syncRuleUnknownServer => 'Ukendt server'; @override String get syncRuleListCreated => 'Synkroniseringsregel oprettet'; } // Path: shaders -class _TranslationsShadersDa implements TranslationsShadersEn { - _TranslationsShadersDa._(this._root); +class _TranslationsShadersDa extends TranslationsShadersEn { + _TranslationsShadersDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersDa implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteDa implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteDa._(this._root); +class _TranslationsCompanionRemoteDa extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteDa implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsDa implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsDa._(this._root); +class _TranslationsVideoSettingsDa extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsDa implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerDa implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerDa._(this._root); +class _TranslationsExternalPlayerDa extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerDa implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditDa implements TranslationsMetadataEditEn { - _TranslationsMetadataEditDa._(this._root); +class _TranslationsMetadataEditDa extends TranslationsMetadataEditEn { + _TranslationsMetadataEditDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditDa implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenDa implements TranslationsMatchScreenEn { - _TranslationsMatchScreenDa._(this._root); +class _TranslationsMatchScreenDa extends TranslationsMatchScreenEn { + _TranslationsMatchScreenDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenDa implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksDa implements TranslationsServerTasksEn { - _TranslationsServerTasksDa._(this._root); +class _TranslationsServerTasksDa extends TranslationsServerTasksEn { + _TranslationsServerTasksDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksDa implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktDa implements TranslationsTraktEn { - _TranslationsTraktDa._(this._root); +class _TranslationsTraktDa extends TranslationsTraktEn { + _TranslationsTraktDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktDa implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersDa implements TranslationsTrackersEn { - _TranslationsTrackersDa._(this._root); +class _TranslationsTrackersDa extends TranslationsTrackersEn { + _TranslationsTrackersDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersDa implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterDa libraryFilter = _TranslationsTrackersLibraryFilterDa._(_root); } +// Path: addServer +class _TranslationsAddServerDa extends TranslationsAddServerEn { + _TranslationsAddServerDa._(TranslationsDa root) : this._root = root, super.internal(root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Tilføj Jellyfin-server'; + @override String get jellyfinUrlIntro => 'Angiv URL\'en til din Jellyfin-server — f.eks. `https://jellyfin.example.com`. Du kan logge ind bagefter.'; + @override String get serverUrl => 'Server-URL'; + @override String get findServer => 'Find server'; + @override String get username => 'Brugernavn'; + @override String get password => 'Adgangskode'; + @override String get signIn => 'Log ind'; + @override String get change => 'Ændr'; + @override String get required => 'Påkrævet'; + @override String couldNotReachServer({required Object error}) => 'Kunne ikke nå serveren: ${error}'; + @override String signInFailed({required Object error}) => 'Login mislykkedes: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect mislykkedes: ${error}'; + @override String get addPlexTitle => 'Log ind med Plex'; + @override String get plexAuthIntro => 'Vælg hvordan du vil logge ind på Plex. Browser-flowet åbner plex.tv, hvor du bekræfter forbindelsen; QR-koden er praktisk til TV / fjern-enheder.'; + @override String get plexQRPrompt => 'Scan denne QR-kode for at logge ind.'; + @override String get waitingForPlexConfirmation => 'Venter på at plex.tv bekræfter login…'; + @override String get pinExpired => 'PIN udløb før login. Prøv igen.'; + @override String get duplicatePlexAccount => 'Denne enhed er allerede logget ind på en Plex-konto. Log ud fra indstillingerne for at skifte konto.'; + @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 addConnectionIntroGlobal => 'Tilføj endnu en medieserver. Du kan blande Plex-konti og Jellyfin-servere — indhold fra alle tilkoblede backender vises samlet på startsiden.'; + @override String get addConnectionIntroScoped => 'Tilføj en ny server, eller lån en fra en anden profil.'; + @override String get signInWithPlexCard => 'Log ind med Plex'; + @override String get signInWithPlexCardSubtitle => 'Godkend denne enhed mod din Plex-konto. Servere delt med kontoen følger med automatisk.'; + @override String get signInWithPlexCardSubtitleScoped => 'Godkend en ny Plex-konto. Dens Home-brugere vises som profiler.'; + @override String get connectToJellyfinCard => 'Forbind til Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Angiv URL\'en til din Jellyfin-server og log ind med brugernavn + adgangskode (Quick Connect kommer snart).'; + @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Log ind på en Jellyfin-server. Tilknyttes ${name}.'; + @override String get borrowFromAnotherProfile => 'Lån fra en anden profil'; + @override String get borrowFromAnotherProfileSubtitle => 'Genbrug en forbindelse, der allerede er tilknyttet en anden profil. PIN-beskyttede kilde-profiler beder om PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsDa implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsDa._(this._root); +class _TranslationsHotkeysActionsDa extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsDa implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsDa implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsDa._(this._root); +class _TranslationsVideoControlsPipErrorsDa extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsDa implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsDa implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsDa._(this._root); +class _TranslationsLibrariesTabsDa extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsDa implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsDa implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsDa._(this._root); +class _TranslationsLibrariesGroupingsDa extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsDa implements TranslationsLibrariesGrouping @override String get folders => 'Mapper'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesDa extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesDa._(TranslationsDa root) : this._root = root, super.internal(root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String get genre => 'Genre'; + @override String get year => 'År'; + @override String get contentRating => 'Aldersvurdering'; + @override String get tag => 'Tag'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsDa extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsDa._(TranslationsDa root) : this._root = root, super.internal(root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String get title => 'Titel'; + @override String get dateAdded => 'Tilføjet dato'; + @override String get releaseDate => 'Udgivelsesdato'; + @override String get rating => 'Vurdering'; + @override String get lastPlayed => 'Sidst afspillet'; + @override String get playCount => 'Antal afspilninger'; + @override String get random => 'Tilfældig'; + @override String get dateShared => 'Delt dato'; + @override String get latestEpisodeAirDate => 'Seneste episodes premieredato'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionDa implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionDa._(this._root); +class _TranslationsCompanionRemoteSessionDa extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionDa implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingDa implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingDa._(this._root); +class _TranslationsCompanionRemotePairingDa extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingDa implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteDa implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteDa._(this._root); +class _TranslationsCompanionRemoteRemoteDa extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteDa implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesDa implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesDa._(this._root); +class _TranslationsTrackersServicesDa extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesDa implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeDa implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeDa._(this._root); +class _TranslationsTrackersDeviceCodeDa extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeDa implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyDa implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyDa._(this._root); +class _TranslationsTrackersOauthProxyDa extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyDa implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterDa implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterDa._(this._root); +class _TranslationsTrackersLibraryFilterDa extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterDa._(TranslationsDa root) : this._root = root, super.internal(root); final TranslationsDa _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsDa { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Log ind', 'auth.signInWithPlex' => 'Log ind med Plex', 'auth.showQRCode' => 'Vis QR-kode', 'auth.authenticate' => 'Godkend', @@ -1550,6 +1719,14 @@ extension on TranslationsDa { 'auth.scanQRToSignIn' => 'Scan denne QR-kode for at logge ind', 'auth.waitingForAuth' => 'Venter på godkendelse...\nFærdiggør login i din browser.', 'auth.useBrowser' => 'Brug browser', + 'auth.or' => 'eller', + 'auth.connectToJellyfin' => 'Forbind til Jellyfin', + 'auth.useQuickConnect' => 'Brug Quick Connect', + 'auth.quickConnectCode' => 'Quick Connect-kode', + 'auth.quickConnectInstructions' => 'Åbn din Jellyfin-server i en webbrowser, log ind, og vælg Quick Connect i brugermenuen. Indtast denne kode for at godkende loginnet.', + 'auth.quickConnectWaiting' => 'Venter på godkendelse…', + 'auth.quickConnectCancel' => 'Annullér', + 'auth.quickConnectExpired' => 'Quick Connect-koden udløb inden godkendelse. Prøv igen.', 'common.cancel' => 'Annuller', 'common.save' => 'Gem', 'common.close' => 'Luk', @@ -1636,12 +1813,12 @@ extension on TranslationsDa { 'settings.gridView' => 'Gitter', 'settings.listView' => 'Liste', 'settings.showHeroSection' => 'Vis hero-sektion', - 'settings.useGlobalHubs' => 'Brug Plex Home-layout', - 'settings.useGlobalHubsDescription' => 'Vis startsidehubbe som den officielle Plex-klient. Når slået fra, vises anbefalinger per bibliotek.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Vis servernavn på hubbe', 'settings.showServerNameOnHubsDescription' => 'Vis altid servernavnet i hubtitler. Når slået fra, vises kun ved duplikerede navne.', 'settings.groupLibrariesByServer' => 'Grupper biblioteker efter server', - 'settings.groupLibrariesByServerDescription' => 'Vis en overskrift for hver Plex-server i sidepanelet, når du er forbundet til flere servere.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Hold altid sidepanelet åbent', 'settings.alwaysKeepSidebarOpenDescription' => 'Sidepanelet forbliver udvidet, og indholdsområdet tilpasser sig', 'settings.showUnwatchedCount' => 'Vis antal usete', @@ -1962,7 +2139,7 @@ extension on TranslationsDa { 'messages.musicNotSupported' => 'Musikafspilning understøttes endnu ikke', 'messages.noDescriptionAvailable' => 'Ingen beskrivelse tilgængelig', 'messages.noProfilesAvailable' => 'Ingen profiler tilgængelige', - 'messages.contactAdminForProfiles' => 'Kontakt din Plex-administrator for at tilføje profiler', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Kan ikke bestemme biblioteksafdeling for dette element', 'messages.logsCleared' => 'Logs ryddet', 'messages.logsCopied' => 'Logs kopieret til udklipsholder', @@ -2016,11 +2193,65 @@ extension on TranslationsDa { 'mpvConfig.confirmDeletePreset' => 'Er du sikker på, at du vil slette denne forudindstilling?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Bekræft handling', + 'profiles.addPlezyProfile' => 'Tilføj Plezy-profil', + 'profiles.switchingProfile' => 'Skifter profil…', + 'profiles.deleteThisProfileTitle' => 'Slet denne profil?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} fjernes. Forbindelser påvirkes ikke.', + 'profiles.active' => 'Aktiv', + 'profiles.manage' => 'Administrer', + 'profiles.delete' => 'Slet', + 'profiles.signOut' => 'Log ud', + 'profiles.signOutPlexTitle' => 'Log ud af Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} og alle Plex Home-brugere på denne konto fjernes fra denne enhed. Du kan logge ind igen når som helst.', + 'profiles.signedOutPlex' => 'Logget ud af Plex.', + 'profiles.signOutFailed' => 'Log ud mislykkedes.', + 'profiles.sectionTitle' => 'Profiler', + 'profiles.summarySingle' => 'Tilføj profiler for at blande administrerede brugere og lokale identiteter', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} profiler · aktiv: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} profiler', + 'profiles.removeConnectionTitle' => 'Fjern forbindelse?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} mister adgang til ${connectionLabel}. Forbindelsen forbliver tilgængelig for andre profiler.', + 'profiles.deleteProfileTitle' => 'Slet profil?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Dette fjerner ${displayName} og alle dens forbindelser fra denne enhed. De underliggende Plex/Jellyfin-servere påvirkes ikke.', + 'profiles.profileNameLabel' => 'Profilnavn', + 'profiles.pinProtectionLabel' => 'PIN-beskyttelse', + 'profiles.pinManagedByPlex' => 'PIN administreres af Plex. Rediger på plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'Ingen PIN-kode angivet. For at kræve en, redigér Home-brugeren på plex.tv.', + 'profiles.setPin' => 'Angiv PIN', + 'profiles.connectionsLabel' => 'Forbindelser', + 'profiles.add' => 'Tilføj', + 'profiles.deleteProfileButton' => 'Slet profil', + 'profiles.noConnectionsHint' => 'Ingen forbindelser — tilføj en for at bruge denne profil.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Plex Home-konto', + 'profiles.connectionDefault' => 'Standard', + 'profiles.makeDefault' => 'Gør til standard', + 'profiles.removeConnection' => 'Fjern', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Tilføj til ${displayName}', + 'profiles.borrowExplain' => 'Lån en forbindelse fra en anden profil. PIN-beskyttede kildeprofiler beder om PIN, før de deler.', + 'profiles.borrowEmpty' => 'Intet at låne endnu.', + 'profiles.borrowEmptySubtitle' => 'Tilslut først en Plex-konto eller Jellyfin-server til en anden profil, og kom så tilbage hertil.', + 'profiles.newProfile' => 'Ny profil', + 'profiles.profileNameHint' => 'fx. Gæster, Børn, Familiens stue', + 'profiles.pinProtectionOptional' => 'PIN-beskyttelse (valgfri)', + 'profiles.pinExplain' => '4-cifret PIN-kode kræves for at skifte til denne profil. Blød barriere — enhver der kan slette appdata, kan omgå den.', + 'profiles.continueButton' => 'Fortsæt', + 'profiles.pinsDontMatch' => 'PIN-koder matcher ikke', + 'connections.sectionTitle' => 'Forbindelser', + 'connections.addConnection' => 'Tilføj forbindelse', + 'connections.addConnectionSubtitleNoProfile' => 'Log ind med Plex eller forbind til en Jellyfin-server', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Tilføj til ${displayName} — Plex-konto, Jellyfin-server eller lån fra en anden profil', + 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessionen er udløbet for ${name}', + 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessionen er udløbet for ${count} servere', + 'connections.signInAgain' => 'Log ind igen', 'discover.title' => 'Opdag', 'discover.switchProfile' => 'Skift profil', 'discover.noContentAvailable' => 'Intet indhold tilgængeligt', 'discover.addMediaToLibraries' => 'Tilføj medier til dine biblioteker', 'discover.continueWatching' => 'Fortsæt med at se', + 'discover.nextUp' => 'Næste op', + 'discover.recentlyAdded' => 'Nyligt tilføjet', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Oversigt', 'discover.cast' => 'Rollebesætning', @@ -2032,7 +2263,7 @@ extension on TranslationsDa { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min tilbage', 'errors.searchFailed' => ({required Object error}) => 'Søgning mislykkedes: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Forbindelsestimeout ved indlæsning af ${context}', - 'errors.connectionFailed' => 'Kunne ikke forbinde til Plex-server', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Kunne ikke indlæse ${context}: ${error}', 'errors.noClientAvailable' => 'Ingen klient tilgængelig', 'errors.authenticationFailed' => ({required Object error}) => 'Godkendelse mislykkedes: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsDa { 'errors.invalidToken' => 'Ugyldigt token', 'errors.failedToVerifyToken' => ({required Object error}) => 'Kunne ikke verificere token: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Kunne ikke skifte til ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Kunne ikke slette ${displayName}', + 'errors.failedToRate' => 'Kunne ikke opdatere bedømmelsen', 'libraries.title' => 'Biblioteker', 'libraries.scanLibraryFiles' => 'Scan biblioteksfiler', 'libraries.scanLibrary' => 'Scan bibliotek', @@ -2054,8 +2287,6 @@ extension on TranslationsDa { 'libraries.analyzing' => ({required Object title}) => 'Analyserer "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Analyse startet for "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Kunne ikke analysere bibliotek: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Ingen biblioteker fundet', 'libraries.allLibrariesHidden' => 'Alle biblioteker er skjult', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Skjulte biblioteker (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsDa { 'libraries.groupings.seasons' => 'Sæsoner', 'libraries.groupings.episodes' => 'Episoder', 'libraries.groupings.folders' => 'Mapper', + 'libraries.filterCategories.genre' => 'Genre', + 'libraries.filterCategories.year' => 'År', + 'libraries.filterCategories.contentRating' => 'Aldersvurdering', + 'libraries.filterCategories.tag' => 'Tag', + 'libraries.sortLabels.title' => 'Titel', + 'libraries.sortLabels.dateAdded' => 'Tilføjet dato', + 'libraries.sortLabels.releaseDate' => 'Udgivelsesdato', + 'libraries.sortLabels.rating' => 'Vurdering', + 'libraries.sortLabels.lastPlayed' => 'Sidst afspillet', + 'libraries.sortLabels.playCount' => 'Antal afspilninger', + 'libraries.sortLabels.random' => 'Tilfældig', + 'libraries.sortLabels.dateShared' => 'Delt dato', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Seneste episodes premieredato', 'about.title' => 'Om', 'about.openSourceLicenses' => 'Open source-licenser', 'about.versionLabel' => ({required Object version}) => 'Version ${version}', - 'about.appDescription' => 'En smuk Plex-klient til Flutter', + 'about.appDescription' => 'En smuk Plex- og Jellyfin-klient til Flutter', 'about.viewLicensesDescription' => 'Se licenser for tredjepartsbiblioteker', 'serverSelection.allServerConnectionsFailed' => 'Kunne ikke forbinde til nogen servere. Tjek dit netværk og prøv igen.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Ingen servere fundet for ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsDa { 'watchTogether.recentRooms' => 'Seneste rum', 'watchTogether.renameRoom' => 'Omdøb rum', 'watchTogether.removeRoom' => 'Fjern', + 'watchTogether.guestSwitchUnavailable' => 'Kunne ikke skifte — server ikke tilgængelig for synkronisering', + 'watchTogether.guestSwitchFailed' => 'Kunne ikke skifte — indhold blev ikke fundet på denne server', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Administrer', 'downloads.tvShows' => 'TV-serier', @@ -2291,6 +2537,12 @@ extension on TranslationsDa { 'downloads.editSyncFilter' => 'Synkroniseringsfilter', 'downloads.syncAllItems' => 'Synkroniserer alle elementer', 'downloads.syncUnwatchedItems' => 'Synkroniserer usete elementer', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Tilgængelig', + 'downloads.syncRuleOffline' => 'Offline', + 'downloads.syncRuleSignInRequired' => 'Log ind påkrævet', + 'downloads.syncRuleNotAvailableForProfile' => 'Ikke tilgængelig for nuværende profil', + 'downloads.syncRuleUnknownServer' => 'Ukendt server', 'downloads.syncRuleListCreated' => 'Synkroniseringsregel oprettet', 'shaders.title' => 'Shadere', 'shaders.noShaderDescription' => 'Ingen videoforbedring', @@ -2484,6 +2736,8 @@ extension on TranslationsDa { 'trakt.disconnectConfirmBody' => 'Plezy stopper med at sende afspilningsbegivenheder til Trakt. Du kan genoprette forbindelse når som helst.', 'trakt.scrobble' => 'Realtids-scrobbling', 'trakt.scrobbleDescription' => 'Send afspil-, pause- og stop-begivenheder til Trakt under afspilning.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Synkroniser sét-status', 'trakt.watchedSyncDescription' => 'Når du markerer ting som sét i Plezy, markeres de også på Trakt.', 'trackers.title' => 'Trackere', @@ -2519,6 +2773,38 @@ extension on TranslationsDa { 'trackers.libraryFilter.modeHintWhitelist' => 'Synkroniser kun de biblioteker du markerer nedenfor.', 'trackers.libraryFilter.libraries' => 'Biblioteker', 'trackers.libraryFilter.noLibraries' => 'Ingen biblioteker tilgængelige', + 'addServer.addJellyfinTitle' => 'Tilføj Jellyfin-server', + 'addServer.jellyfinUrlIntro' => 'Angiv URL\'en til din Jellyfin-server — f.eks. `https://jellyfin.example.com`. Du kan logge ind bagefter.', + 'addServer.serverUrl' => 'Server-URL', + 'addServer.findServer' => 'Find server', + 'addServer.username' => 'Brugernavn', + 'addServer.password' => 'Adgangskode', + 'addServer.signIn' => 'Log ind', + 'addServer.change' => 'Ændr', + 'addServer.required' => 'Påkrævet', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Kunne ikke nå serveren: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Login mislykkedes: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect mislykkedes: ${error}', + 'addServer.addPlexTitle' => 'Log ind med Plex', + 'addServer.plexAuthIntro' => 'Vælg hvordan du vil logge ind på Plex. Browser-flowet åbner plex.tv, hvor du bekræfter forbindelsen; QR-koden er praktisk til TV / fjern-enheder.', + 'addServer.plexQRPrompt' => 'Scan denne QR-kode for at logge ind.', + 'addServer.waitingForPlexConfirmation' => 'Venter på at plex.tv bekræfter login…', + 'addServer.pinExpired' => 'PIN udløb før login. Prøv igen.', + 'addServer.duplicatePlexAccount' => 'Denne enhed er allerede logget ind på en Plex-konto. Log ud fra indstillingerne for at skifte konto.', + '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.addConnectionIntroGlobal' => 'Tilføj endnu en medieserver. Du kan blande Plex-konti og Jellyfin-servere — indhold fra alle tilkoblede backender vises samlet på startsiden.', + 'addServer.addConnectionIntroScoped' => 'Tilføj en ny server, eller lån en fra en anden profil.', + 'addServer.signInWithPlexCard' => 'Log ind med Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Godkend denne enhed mod din Plex-konto. Servere delt med kontoen følger med automatisk.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Godkend en ny Plex-konto. Dens Home-brugere vises som profiler.', + 'addServer.connectToJellyfinCard' => 'Forbind til Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Angiv URL\'en til din Jellyfin-server og log ind med brugernavn + adgangskode (Quick Connect kommer snart).', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Log ind på en Jellyfin-server. Tilknyttes ${name}.', + 'addServer.borrowFromAnotherProfile' => 'Lån fra en anden profil', + 'addServer.borrowFromAnotherProfileSubtitle' => 'Genbrug en forbindelse, der allerede er tilknyttet en anden profil. PIN-beskyttede kilde-profiler beder om PIN.', _ => null, }; } diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index fbafa05c..ca0fc683 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsDe with BaseTranslations implements Translations { +class TranslationsDe extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsDe({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsDe with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsDe with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsDe _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsDe with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingDe subtitlingStyling = _TranslationsSubtitlingStylingDe._(_root); @override late final _TranslationsMpvConfigDe mpvConfig = _TranslationsMpvConfigDe._(_root); @override late final _TranslationsDialogDe dialog = _TranslationsDialogDe._(_root); + @override late final _TranslationsProfilesDe profiles = _TranslationsProfilesDe._(_root); + @override late final _TranslationsConnectionsDe connections = _TranslationsConnectionsDe._(_root); @override late final _TranslationsDiscoverDe discover = _TranslationsDiscoverDe._(_root); @override late final _TranslationsErrorsDe errors = _TranslationsErrorsDe._(_root); @override late final _TranslationsLibrariesDe libraries = _TranslationsLibrariesDe._(_root); @@ -78,11 +82,12 @@ class TranslationsDe with BaseTranslations implements T @override late final _TranslationsServerTasksDe serverTasks = _TranslationsServerTasksDe._(_root); @override late final _TranslationsTraktDe trakt = _TranslationsTraktDe._(_root); @override late final _TranslationsTrackersDe trackers = _TranslationsTrackersDe._(_root); + @override late final _TranslationsAddServerDe addServer = _TranslationsAddServerDe._(_root); } // Path: app -class _TranslationsAppDe implements TranslationsAppEn { - _TranslationsAppDe._(this._root); +class _TranslationsAppDe extends TranslationsAppEn { + _TranslationsAppDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppDe implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthDe implements TranslationsAuthEn { - _TranslationsAuthDe._(this._root); +class _TranslationsAuthDe extends TranslationsAuthEn { + _TranslationsAuthDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field // Translations + @override String get signIn => 'Anmelden'; @override String get signInWithPlex => 'Mit Plex anmelden'; @override String get showQRCode => 'QR-Code anzeigen'; @override String get authenticate => 'Authentifizieren'; @@ -104,11 +110,19 @@ class _TranslationsAuthDe implements TranslationsAuthEn { @override String get scanQRToSignIn => 'QR-Code scannen zum Anmelden'; @override String get waitingForAuth => 'Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.'; @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 quickConnectCode => 'Quick Connect-Code'; + @override String get quickConnectInstructions => 'Öffne deinen Jellyfin-Server im Browser, melde dich an und wähle Quick Connect im Benutzermenü. Gib diesen Code ein, um die Anmeldung zu bestätigen.'; + @override String get quickConnectWaiting => 'Warte auf Bestätigung…'; + @override String get quickConnectCancel => 'Abbrechen'; + @override String get quickConnectExpired => 'Quick Connect-Code ist vor der Bestätigung abgelaufen. Bitte erneut versuchen.'; } // Path: common -class _TranslationsCommonDe implements TranslationsCommonEn { - _TranslationsCommonDe._(this._root); +class _TranslationsCommonDe extends TranslationsCommonEn { + _TranslationsCommonDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonDe implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensDe implements TranslationsScreensEn { - _TranslationsScreensDe._(this._root); +class _TranslationsScreensDe extends TranslationsScreensEn { + _TranslationsScreensDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensDe implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateDe implements TranslationsUpdateEn { - _TranslationsUpdateDe._(this._root); +class _TranslationsUpdateDe extends TranslationsUpdateEn { + _TranslationsUpdateDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateDe implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsDe implements TranslationsSettingsEn { - _TranslationsSettingsDe._(this._root); +class _TranslationsSettingsDe extends TranslationsSettingsEn { + _TranslationsSettingsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn { @override String get gridView => 'Raster'; @override String get listView => 'Liste'; @override String get showHeroSection => 'Hero-Bereich anzeigen'; - @override String get useGlobalHubs => 'Plex-Startseiten-Layout verwenden'; - @override String get useGlobalHubsDescription => 'Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Servername bei Hubs anzeigen'; @override String get showServerNameOnHubsDescription => 'Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.'; @override String get groupLibrariesByServer => 'Mediatheken nach Server gruppieren'; - @override String get groupLibrariesByServerDescription => 'Zeigt eine Überschrift für jeden Plex-Server in der Seitenleiste an, wenn du mit mehreren Servern verbunden bist.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Seitenleiste immer geöffnet halten'; @override String get alwaysKeepSidebarOpenDescription => 'Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an'; @override String get showUnwatchedCount => 'Anzahl nicht gesehener Folgen anzeigen'; @@ -385,8 +399,8 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchDe implements TranslationsSearchEn { - _TranslationsSearchDe._(this._root); +class _TranslationsSearchDe extends TranslationsSearchEn { + _TranslationsSearchDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchDe implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysDe implements TranslationsHotkeysEn { - _TranslationsHotkeysDe._(this._root); +class _TranslationsHotkeysDe extends TranslationsHotkeysEn { + _TranslationsHotkeysDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysDe implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoDe implements TranslationsFileInfoEn { - _TranslationsFileInfoDe._(this._root); +class _TranslationsFileInfoDe extends TranslationsFileInfoEn { + _TranslationsFileInfoDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoDe implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuDe implements TranslationsMediaMenuEn { - _TranslationsMediaMenuDe._(this._root); +class _TranslationsMediaMenuDe extends TranslationsMediaMenuEn { + _TranslationsMediaMenuDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuDe implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityDe implements TranslationsAccessibilityEn { - _TranslationsAccessibilityDe._(this._root); +class _TranslationsAccessibilityDe extends TranslationsAccessibilityEn { + _TranslationsAccessibilityDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityDe implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsDe implements TranslationsTooltipsEn { - _TranslationsTooltipsDe._(this._root); +class _TranslationsTooltipsDe extends TranslationsTooltipsEn { + _TranslationsTooltipsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsDe implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn { - _TranslationsVideoControlsDe._(this._root); +class _TranslationsVideoControlsDe extends TranslationsVideoControlsEn { + _TranslationsVideoControlsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsDe implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusDe implements TranslationsUserStatusEn { - _TranslationsUserStatusDe._(this._root); +class _TranslationsUserStatusDe extends TranslationsUserStatusEn { + _TranslationsUserStatusDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusDe implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesDe implements TranslationsMessagesEn { - _TranslationsMessagesDe._(this._root); +class _TranslationsMessagesDe extends TranslationsMessagesEn { + _TranslationsMessagesDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesDe implements TranslationsMessagesEn { @override String get musicNotSupported => 'Musikwiedergabe wird noch nicht unterstützt'; @override String get noDescriptionAvailable => 'Keine Beschreibung verfügbar'; @override String get noProfilesAvailable => 'Keine Profile verfügbar'; - @override String get contactAdminForProfiles => 'Kontaktiere deinen Plex-Administrator, um Profile hinzuzufügen'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Bibliotheksbereich für dieses Element kann nicht ermittelt werden'; @override String get logsCleared => 'Protokolle gelöscht'; @override String get logsCopied => 'Protokolle in Zwischenablage kopiert'; @@ -636,8 +650,8 @@ class _TranslationsMessagesDe implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingDe implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingDe._(this._root); +class _TranslationsSubtitlingStylingDe extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingDe implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigDe implements TranslationsMpvConfigEn { - _TranslationsMpvConfigDe._(this._root); +class _TranslationsMpvConfigDe extends TranslationsMpvConfigEn { + _TranslationsMpvConfigDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigDe implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogDe implements TranslationsDialogEn { - _TranslationsDialogDe._(this._root); +class _TranslationsDialogDe extends TranslationsDialogEn { + _TranslationsDialogDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogDe implements TranslationsDialogEn { @override String get confirmAction => 'Aktion bestätigen'; } +// Path: profiles +class _TranslationsProfilesDe extends TranslationsProfilesEn { + _TranslationsProfilesDe._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Plezy-Profil hinzufügen'; + @override String get switchingProfile => 'Profil wird gewechselt…'; + @override String get deleteThisProfileTitle => 'Dieses Profil löschen?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} wird entfernt. Verbindungen sind davon nicht betroffen.'; + @override String get active => 'Aktiv'; + @override String get manage => 'Verwalten'; + @override String get delete => 'Löschen'; + @override String get signOut => 'Abmelden'; + @override String get signOutPlexTitle => 'Von Plex abmelden?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} und alle Plex Home-Benutzer dieses Kontos werden von diesem Gerät entfernt. Du kannst dich jederzeit wieder anmelden.'; + @override String get signedOutPlex => 'Von Plex abgemeldet.'; + @override String get signOutFailed => 'Abmeldung fehlgeschlagen.'; + @override String get sectionTitle => 'Profile'; + @override String get summarySingle => 'Profile hinzufügen, um verwaltete Benutzer und lokale Identitäten zu mischen'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} Profile · aktiv: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} Profile'; + @override String get removeConnectionTitle => 'Verbindung entfernen?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} verliert den Zugriff auf ${connectionLabel}. Die Verbindung bleibt für andere Profile verfügbar.'; + @override String get deleteProfileTitle => 'Profil löschen?'; + @override String deleteProfileMessage({required Object displayName}) => 'Dies entfernt ${displayName} und alle zugehörigen Verbindungen von diesem Gerät. Die zugrunde liegenden Plex-/Jellyfin-Server sind nicht betroffen.'; + @override String get profileNameLabel => 'Profilname'; + @override String get pinProtectionLabel => 'PIN-Schutz'; + @override String get pinManagedByPlex => 'PIN wird von Plex verwaltet. Auf plex.tv bearbeiten.'; + @override String get noPinSetEditOnPlex => 'Keine PIN festgelegt. Um eine zu verlangen, bearbeite den Home-Benutzer auf plex.tv.'; + @override String get setPin => 'PIN festlegen'; + @override String get connectionsLabel => 'Verbindungen'; + @override String get add => 'Hinzufügen'; + @override String get deleteProfileButton => 'Profil löschen'; + @override String get noConnectionsHint => 'Keine Verbindungen — füge eine hinzu, um dieses Profil zu nutzen.'; + @override String get plexHomeAccount => 'Plex Home-Konto'; + @override String get connectionDefault => 'Standard'; + @override String get makeDefault => 'Als Standard'; + @override String get removeConnection => 'Entfernen'; + @override String borrowAddTo({required Object displayName}) => 'Zu ${displayName} hinzufügen'; + @override String get borrowExplain => 'Eine Verbindung von einem anderen Profil ausleihen. PIN-geschützte Quellprofile fordern die PIN vor der Freigabe an.'; + @override String get borrowEmpty => 'Noch nichts zum Ausleihen.'; + @override String get borrowEmptySubtitle => 'Verbinde zuerst ein Plex-Konto oder einen Jellyfin-Server mit einem anderen Profil und komme dann hierher zurück.'; + @override String get newProfile => 'Neues Profil'; + @override String get profileNameHint => 'z. B. Gäste, Kinder, Wohnzimmer'; + @override String get pinProtectionOptional => 'PIN-Schutz (optional)'; + @override String get pinExplain => '4-stellige PIN erforderlich, um zu diesem Profil zu wechseln. Weiche Barriere — wer App-Daten löschen kann, kann sie umgehen.'; + @override String get continueButton => 'Weiter'; + @override String get pinsDontMatch => 'PINs stimmen nicht überein'; +} + +// Path: connections +class _TranslationsConnectionsDe extends TranslationsConnectionsEn { + _TranslationsConnectionsDe._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Verbindungen'; + @override String get addConnection => 'Verbindung hinzufügen'; + @override String get addConnectionSubtitleNoProfile => 'Mit Plex anmelden oder Jellyfin-Server verbinden'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Zu ${displayName} hinzufügen — Plex-Konto, Jellyfin-Server oder von einem anderen Profil ausleihen'; + @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'; +} + // Path: discover -class _TranslationsDiscoverDe implements TranslationsDiscoverEn { - _TranslationsDiscoverDe._(this._root); +class _TranslationsDiscoverDe extends TranslationsDiscoverEn { + _TranslationsDiscoverDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverDe implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Kein Inhalt verfügbar'; @override String get addMediaToLibraries => 'Medien zur Mediathek hinzufügen'; @override String get continueWatching => 'Weiterschauen'; + @override String get nextUp => 'Als Nächstes'; + @override String get recentlyAdded => 'Kürzlich hinzugefügt'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Übersicht'; @override String get cast => 'Besetzung'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverDe implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsDe implements TranslationsErrorsEn { - _TranslationsErrorsDe._(this._root); +class _TranslationsErrorsDe extends TranslationsErrorsEn { + _TranslationsErrorsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Suche fehlgeschlagen: ${error}'; @override String connectionTimeout({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}'; - @override String get connectionFailed => 'Verbindung zum Plex-Server fehlgeschlagen'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}'; @override String get noClientAvailable => 'Kein Client verfügbar'; @override String authenticationFailed({required Object error}) => 'Authentifizierung fehlgeschlagen: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsDe implements TranslationsErrorsEn { @override String get invalidToken => 'Ungültiges Token'; @override String failedToVerifyToken({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen'; + @override String failedToDeleteProfile({required Object displayName}) => 'Löschen von ${displayName} fehlgeschlagen'; + @override String get failedToRate => 'Bewertung konnte nicht aktualisiert werden'; } // Path: libraries -class _TranslationsLibrariesDe implements TranslationsLibrariesEn { - _TranslationsLibrariesDe._(this._root); +class _TranslationsLibrariesDe extends TranslationsLibrariesEn { + _TranslationsLibrariesDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesDe implements TranslationsLibrariesEn { @override String get folders => 'Ordner'; @override late final _TranslationsLibrariesTabsDe tabs = _TranslationsLibrariesTabsDe._(_root); @override late final _TranslationsLibrariesGroupingsDe groupings = _TranslationsLibrariesGroupingsDe._(_root); + @override late final _TranslationsLibrariesFilterCategoriesDe filterCategories = _TranslationsLibrariesFilterCategoriesDe._(_root); + @override late final _TranslationsLibrariesSortLabelsDe sortLabels = _TranslationsLibrariesSortLabelsDe._(_root); } // Path: about -class _TranslationsAboutDe implements TranslationsAboutEn { - _TranslationsAboutDe._(this._root); +class _TranslationsAboutDe extends TranslationsAboutEn { + _TranslationsAboutDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutDe implements TranslationsAboutEn { @override String get title => 'Über'; @override String get openSourceLicenses => 'Open-Source-Lizenzen'; @override String versionLabel({required Object version}) => 'Version ${version}'; - @override String get appDescription => 'Ein schöner Plex-Client für Flutter'; + @override String get appDescription => 'Ein schöner Plex- und Jellyfin-Client für Flutter'; @override String get viewLicensesDescription => 'Lizenzen von Drittanbieter-Bibliotheken anzeigen'; } // Path: serverSelection -class _TranslationsServerSelectionDe implements TranslationsServerSelectionEn { - _TranslationsServerSelectionDe._(this._root); +class _TranslationsServerSelectionDe extends TranslationsServerSelectionEn { + _TranslationsServerSelectionDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionDe implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailDe implements TranslationsHubDetailEn { - _TranslationsHubDetailDe._(this._root); +class _TranslationsHubDetailDe extends TranslationsHubDetailEn { + _TranslationsHubDetailDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailDe implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsDe implements TranslationsLogsEn { - _TranslationsLogsDe._(this._root); +class _TranslationsLogsDe extends TranslationsLogsEn { + _TranslationsLogsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsDe implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesDe implements TranslationsLicensesEn { - _TranslationsLicensesDe._(this._root); +class _TranslationsLicensesDe extends TranslationsLicensesEn { + _TranslationsLicensesDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesDe implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationDe implements TranslationsNavigationEn { - _TranslationsNavigationDe._(this._root); +class _TranslationsNavigationDe extends TranslationsNavigationEn { + _TranslationsNavigationDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationDe implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvDe implements TranslationsLiveTvEn { - _TranslationsLiveTvDe._(this._root); +class _TranslationsLiveTvDe extends TranslationsLiveTvEn { + _TranslationsLiveTvDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvDe implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsDe implements TranslationsCollectionsEn { - _TranslationsCollectionsDe._(this._root); +class _TranslationsCollectionsDe extends TranslationsCollectionsEn { + _TranslationsCollectionsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsDe implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsDe implements TranslationsPlaylistsEn { - _TranslationsPlaylistsDe._(this._root); +class _TranslationsPlaylistsDe extends TranslationsPlaylistsEn { + _TranslationsPlaylistsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsDe implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherDe implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherDe._(this._root); +class _TranslationsWatchTogetherDe extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherDe implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Letzte Räume'; @override String get renameRoom => 'Raum umbenennen'; @override String get removeRoom => 'Entfernen'; + @override String get guestSwitchUnavailable => 'Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar'; + @override String get guestSwitchFailed => 'Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden'; } // Path: downloads -class _TranslationsDownloadsDe implements TranslationsDownloadsEn { - _TranslationsDownloadsDe._(this._root); +class _TranslationsDownloadsDe extends TranslationsDownloadsEn { + _TranslationsDownloadsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsDe implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Synchronisierungsfilter'; @override String get syncAllItems => 'Alle Einträge synchronisieren'; @override String get syncUnwatchedItems => 'Ungesehene Einträge synchronisieren'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Server: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Verfügbar'; + @override String get syncRuleOffline => 'Offline'; + @override String get syncRuleSignInRequired => 'Anmeldung erforderlich'; + @override String get syncRuleNotAvailableForProfile => 'Für aktuelles Profil nicht verfügbar'; + @override String get syncRuleUnknownServer => 'Unbekannter Server'; @override String get syncRuleListCreated => 'Sync-Regel erstellt'; } // Path: shaders -class _TranslationsShadersDe implements TranslationsShadersEn { - _TranslationsShadersDe._(this._root); +class _TranslationsShadersDe extends TranslationsShadersEn { + _TranslationsShadersDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersDe implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteDe implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteDe._(this._root); +class _TranslationsCompanionRemoteDe extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteDe implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsDe implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsDe._(this._root); +class _TranslationsVideoSettingsDe extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsDe implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerDe implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerDe._(this._root); +class _TranslationsExternalPlayerDe extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerDe implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditDe implements TranslationsMetadataEditEn { - _TranslationsMetadataEditDe._(this._root); +class _TranslationsMetadataEditDe extends TranslationsMetadataEditEn { + _TranslationsMetadataEditDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditDe implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenDe implements TranslationsMatchScreenEn { - _TranslationsMatchScreenDe._(this._root); +class _TranslationsMatchScreenDe extends TranslationsMatchScreenEn { + _TranslationsMatchScreenDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenDe implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksDe implements TranslationsServerTasksEn { - _TranslationsServerTasksDe._(this._root); +class _TranslationsServerTasksDe extends TranslationsServerTasksEn { + _TranslationsServerTasksDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksDe implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktDe implements TranslationsTraktEn { - _TranslationsTraktDe._(this._root); +class _TranslationsTraktDe extends TranslationsTraktEn { + _TranslationsTraktDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktDe implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersDe implements TranslationsTrackersEn { - _TranslationsTrackersDe._(this._root); +class _TranslationsTrackersDe extends TranslationsTrackersEn { + _TranslationsTrackersDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersDe implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterDe libraryFilter = _TranslationsTrackersLibraryFilterDe._(_root); } +// Path: addServer +class _TranslationsAddServerDe extends TranslationsAddServerEn { + _TranslationsAddServerDe._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Jellyfin-Server hinzufügen'; + @override String get jellyfinUrlIntro => 'Gib die URL deines Jellyfin-Servers ein — z. B. `https://jellyfin.example.com`. Anmelden kannst du dich danach.'; + @override String get serverUrl => 'Server-URL'; + @override String get findServer => 'Server finden'; + @override String get username => 'Benutzername'; + @override String get password => 'Passwort'; + @override String get signIn => 'Anmelden'; + @override String get change => 'Ändern'; + @override String get required => 'Erforderlich'; + @override String couldNotReachServer({required Object error}) => 'Server nicht erreichbar: ${error}'; + @override String signInFailed({required Object error}) => 'Anmeldung fehlgeschlagen: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect fehlgeschlagen: ${error}'; + @override String get addPlexTitle => 'Mit Plex anmelden'; + @override String get plexAuthIntro => 'Wähle, wie du dich bei Plex anmelden möchtest. Der Browser-Ablauf öffnet plex.tv, wo du die Verbindung bestätigst; die QR-Option eignet sich für TV / Remote-Geräte.'; + @override String get plexQRPrompt => 'Scanne diesen QR-Code zum Anmelden.'; + @override String get waitingForPlexConfirmation => 'Warte auf Bestätigung durch plex.tv…'; + @override String get pinExpired => 'PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.'; + @override String get duplicatePlexAccount => 'Dieses Gerät ist bereits bei einem Plex-Konto angemeldet. Melde dich in den Einstellungen ab, um das Konto zu wechseln.'; + @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 addConnectionIntroGlobal => 'Füge einen weiteren Medienserver hinzu. Du kannst Plex-Konten und Jellyfin-Server kombinieren — Inhalte aller verbundenen Backends erscheinen gemeinsam auf dem Startbildschirm.'; + @override String get addConnectionIntroScoped => 'Füge einen neuen Server hinzu oder leihe einen von einem anderen Profil aus.'; + @override String get signInWithPlexCard => 'Mit Plex anmelden'; + @override String get signInWithPlexCardSubtitle => 'Autorisiere dieses Gerät mit deinem Plex-Konto. Mit dem Konto geteilte Server kommen automatisch mit.'; + @override String get signInWithPlexCardSubtitleScoped => 'Autorisiere ein neues Plex-Konto. Dessen Home-Benutzer erscheinen als Profile.'; + @override String get connectToJellyfinCard => 'Mit Jellyfin verbinden'; + @override String get connectToJellyfinCardSubtitle => 'Gib die URL deines Jellyfin-Servers ein und melde dich mit Benutzername + Passwort an (Quick Connect kommt bald).'; + @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 => 'Verwende eine Verbindung wieder, die bereits einem anderen Profil zugeordnet ist. Bei PIN-geschützten Quellprofilen wird die PIN abgefragt.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsDe implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsDe._(this._root); +class _TranslationsHotkeysActionsDe extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsDe implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsDe implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsDe._(this._root); +class _TranslationsVideoControlsPipErrorsDe extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsDe implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsDe implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsDe._(this._root); +class _TranslationsLibrariesTabsDe extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsDe implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsDe implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsDe._(this._root); +class _TranslationsLibrariesGroupingsDe extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsDe implements TranslationsLibrariesGrouping @override String get folders => 'Ordner'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesDe extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesDe._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get genre => 'Genre'; + @override String get year => 'Jahr'; + @override String get contentRating => 'Altersfreigabe'; + @override String get tag => 'Tag'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsDe extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsDe._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Titel'; + @override String get dateAdded => 'Hinzugefügt am'; + @override String get releaseDate => 'Erscheinungsdatum'; + @override String get rating => 'Bewertung'; + @override String get lastPlayed => 'Zuletzt abgespielt'; + @override String get playCount => 'Wiedergaben'; + @override String get random => 'Zufällig'; + @override String get dateShared => 'Datum geteilt'; + @override String get latestEpisodeAirDate => 'Letztes Folgenausstrahlungsdatum'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionDe implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionDe._(this._root); +class _TranslationsCompanionRemoteSessionDe extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionDe implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingDe implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingDe._(this._root); +class _TranslationsCompanionRemotePairingDe extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingDe implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteDe implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteDe._(this._root); +class _TranslationsCompanionRemoteRemoteDe extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteDe implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesDe implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesDe._(this._root); +class _TranslationsTrackersServicesDe extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesDe implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeDe implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeDe._(this._root); +class _TranslationsTrackersDeviceCodeDe extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeDe implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyDe implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyDe._(this._root); +class _TranslationsTrackersOauthProxyDe extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyDe implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterDe implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterDe._(this._root); +class _TranslationsTrackersLibraryFilterDe extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterDe._(TranslationsDe root) : this._root = root, super.internal(root); final TranslationsDe _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsDe { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Anmelden', 'auth.signInWithPlex' => 'Mit Plex anmelden', 'auth.showQRCode' => 'QR-Code anzeigen', 'auth.authenticate' => 'Authentifizieren', @@ -1550,6 +1719,14 @@ extension on TranslationsDe { 'auth.scanQRToSignIn' => 'QR-Code scannen zum Anmelden', 'auth.waitingForAuth' => 'Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.', 'auth.useBrowser' => 'Browser verwenden', + 'auth.or' => 'oder', + 'auth.connectToJellyfin' => 'Mit Jellyfin verbinden', + 'auth.useQuickConnect' => 'Quick Connect verwenden', + 'auth.quickConnectCode' => 'Quick Connect-Code', + 'auth.quickConnectInstructions' => 'Öffne deinen Jellyfin-Server im Browser, melde dich an und wähle Quick Connect im Benutzermenü. Gib diesen Code ein, um die Anmeldung zu bestätigen.', + 'auth.quickConnectWaiting' => 'Warte auf Bestätigung…', + 'auth.quickConnectCancel' => 'Abbrechen', + 'auth.quickConnectExpired' => 'Quick Connect-Code ist vor der Bestätigung abgelaufen. Bitte erneut versuchen.', 'common.cancel' => 'Abbrechen', 'common.save' => 'Speichern', 'common.close' => 'Schließen', @@ -1636,12 +1813,12 @@ extension on TranslationsDe { 'settings.gridView' => 'Raster', 'settings.listView' => 'Liste', 'settings.showHeroSection' => 'Hero-Bereich anzeigen', - 'settings.useGlobalHubs' => 'Plex-Startseiten-Layout verwenden', - 'settings.useGlobalHubsDescription' => 'Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Servername bei Hubs anzeigen', 'settings.showServerNameOnHubsDescription' => 'Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.', 'settings.groupLibrariesByServer' => 'Mediatheken nach Server gruppieren', - 'settings.groupLibrariesByServerDescription' => 'Zeigt eine Überschrift für jeden Plex-Server in der Seitenleiste an, wenn du mit mehreren Servern verbunden bist.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Seitenleiste immer geöffnet halten', 'settings.alwaysKeepSidebarOpenDescription' => 'Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an', 'settings.showUnwatchedCount' => 'Anzahl nicht gesehener Folgen anzeigen', @@ -1962,7 +2139,7 @@ extension on TranslationsDe { 'messages.musicNotSupported' => 'Musikwiedergabe wird noch nicht unterstützt', 'messages.noDescriptionAvailable' => 'Keine Beschreibung verfügbar', 'messages.noProfilesAvailable' => 'Keine Profile verfügbar', - 'messages.contactAdminForProfiles' => 'Kontaktiere deinen Plex-Administrator, um Profile hinzuzufügen', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Bibliotheksbereich für dieses Element kann nicht ermittelt werden', 'messages.logsCleared' => 'Protokolle gelöscht', 'messages.logsCopied' => 'Protokolle in Zwischenablage kopiert', @@ -2016,11 +2193,65 @@ extension on TranslationsDe { 'mpvConfig.confirmDeletePreset' => 'Möchten Sie diese Voreinstellung wirklich löschen?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Aktion bestätigen', + 'profiles.addPlezyProfile' => 'Plezy-Profil hinzufügen', + 'profiles.switchingProfile' => 'Profil wird gewechselt…', + 'profiles.deleteThisProfileTitle' => 'Dieses Profil löschen?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} wird entfernt. Verbindungen sind davon nicht betroffen.', + 'profiles.active' => 'Aktiv', + 'profiles.manage' => 'Verwalten', + 'profiles.delete' => 'Löschen', + 'profiles.signOut' => 'Abmelden', + 'profiles.signOutPlexTitle' => 'Von Plex abmelden?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} und alle Plex Home-Benutzer dieses Kontos werden von diesem Gerät entfernt. Du kannst dich jederzeit wieder anmelden.', + 'profiles.signedOutPlex' => 'Von Plex abgemeldet.', + 'profiles.signOutFailed' => 'Abmeldung fehlgeschlagen.', + 'profiles.sectionTitle' => 'Profile', + 'profiles.summarySingle' => 'Profile hinzufügen, um verwaltete Benutzer und lokale Identitäten zu mischen', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} Profile · aktiv: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} Profile', + 'profiles.removeConnectionTitle' => 'Verbindung entfernen?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} verliert den Zugriff auf ${connectionLabel}. Die Verbindung bleibt für andere Profile verfügbar.', + 'profiles.deleteProfileTitle' => 'Profil löschen?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Dies entfernt ${displayName} und alle zugehörigen Verbindungen von diesem Gerät. Die zugrunde liegenden Plex-/Jellyfin-Server sind nicht betroffen.', + 'profiles.profileNameLabel' => 'Profilname', + 'profiles.pinProtectionLabel' => 'PIN-Schutz', + 'profiles.pinManagedByPlex' => 'PIN wird von Plex verwaltet. Auf plex.tv bearbeiten.', + 'profiles.noPinSetEditOnPlex' => 'Keine PIN festgelegt. Um eine zu verlangen, bearbeite den Home-Benutzer auf plex.tv.', + 'profiles.setPin' => 'PIN festlegen', + 'profiles.connectionsLabel' => 'Verbindungen', + 'profiles.add' => 'Hinzufügen', + 'profiles.deleteProfileButton' => 'Profil löschen', + 'profiles.noConnectionsHint' => 'Keine Verbindungen — füge eine hinzu, um dieses Profil zu nutzen.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Plex Home-Konto', + 'profiles.connectionDefault' => 'Standard', + 'profiles.makeDefault' => 'Als Standard', + 'profiles.removeConnection' => 'Entfernen', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Zu ${displayName} hinzufügen', + 'profiles.borrowExplain' => 'Eine Verbindung von einem anderen Profil ausleihen. PIN-geschützte Quellprofile fordern die PIN vor der Freigabe an.', + 'profiles.borrowEmpty' => 'Noch nichts zum Ausleihen.', + 'profiles.borrowEmptySubtitle' => 'Verbinde zuerst ein Plex-Konto oder einen Jellyfin-Server mit einem anderen Profil und komme dann hierher zurück.', + 'profiles.newProfile' => 'Neues Profil', + 'profiles.profileNameHint' => 'z. B. Gäste, Kinder, Wohnzimmer', + 'profiles.pinProtectionOptional' => 'PIN-Schutz (optional)', + 'profiles.pinExplain' => '4-stellige PIN erforderlich, um zu diesem Profil zu wechseln. Weiche Barriere — wer App-Daten löschen kann, kann sie umgehen.', + 'profiles.continueButton' => 'Weiter', + 'profiles.pinsDontMatch' => 'PINs stimmen nicht überein', + 'connections.sectionTitle' => 'Verbindungen', + 'connections.addConnection' => 'Verbindung hinzufügen', + 'connections.addConnectionSubtitleNoProfile' => 'Mit Plex anmelden oder Jellyfin-Server verbinden', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Zu ${displayName} hinzufügen — Plex-Konto, Jellyfin-Server oder von einem anderen Profil ausleihen', + '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', 'discover.title' => 'Entdecken', 'discover.switchProfile' => 'Profil wechseln', 'discover.noContentAvailable' => 'Kein Inhalt verfügbar', 'discover.addMediaToLibraries' => 'Medien zur Mediathek hinzufügen', 'discover.continueWatching' => 'Weiterschauen', + 'discover.nextUp' => 'Als Nächstes', + 'discover.recentlyAdded' => 'Kürzlich hinzugefügt', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Übersicht', 'discover.cast' => 'Besetzung', @@ -2032,7 +2263,7 @@ extension on TranslationsDe { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} Min übrig', 'errors.searchFailed' => ({required Object error}) => 'Suche fehlgeschlagen: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}', - 'errors.connectionFailed' => 'Verbindung zum Plex-Server fehlgeschlagen', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}', 'errors.noClientAvailable' => 'Kein Client verfügbar', 'errors.authenticationFailed' => ({required Object error}) => 'Authentifizierung fehlgeschlagen: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsDe { 'errors.invalidToken' => 'Ungültiges Token', 'errors.failedToVerifyToken' => ({required Object error}) => 'Token-Verifizierung fehlgeschlagen: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Profilwechsel zu ${displayName} fehlgeschlagen', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Löschen von ${displayName} fehlgeschlagen', + 'errors.failedToRate' => 'Bewertung konnte nicht aktualisiert werden', 'libraries.title' => 'Mediatheken', 'libraries.scanLibraryFiles' => 'Mediatheksdateien scannen', 'libraries.scanLibrary' => 'Mediathek scannen', @@ -2054,8 +2287,6 @@ extension on TranslationsDe { 'libraries.analyzing' => ({required Object title}) => 'Analysiere „${title}“...', 'libraries.analysisStarted' => ({required Object title}) => 'Analyse gestartet für „${title}“', 'libraries.failedToAnalyze' => ({required Object error}) => 'Analyse der Mediathek fehlgeschlagen: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Keine Mediatheken gefunden', 'libraries.allLibrariesHidden' => 'Alle Mediatheken sind ausgeblendet', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Ausgeblendete Mediatheken (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsDe { 'libraries.groupings.seasons' => 'Staffeln', 'libraries.groupings.episodes' => 'Episoden', 'libraries.groupings.folders' => 'Ordner', + 'libraries.filterCategories.genre' => 'Genre', + 'libraries.filterCategories.year' => 'Jahr', + 'libraries.filterCategories.contentRating' => 'Altersfreigabe', + 'libraries.filterCategories.tag' => 'Tag', + 'libraries.sortLabels.title' => 'Titel', + 'libraries.sortLabels.dateAdded' => 'Hinzugefügt am', + 'libraries.sortLabels.releaseDate' => 'Erscheinungsdatum', + 'libraries.sortLabels.rating' => 'Bewertung', + 'libraries.sortLabels.lastPlayed' => 'Zuletzt abgespielt', + 'libraries.sortLabels.playCount' => 'Wiedergaben', + 'libraries.sortLabels.random' => 'Zufällig', + 'libraries.sortLabels.dateShared' => 'Datum geteilt', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Letztes Folgenausstrahlungsdatum', 'about.title' => 'Über', 'about.openSourceLicenses' => 'Open-Source-Lizenzen', 'about.versionLabel' => ({required Object version}) => 'Version ${version}', - 'about.appDescription' => 'Ein schöner Plex-Client für Flutter', + 'about.appDescription' => 'Ein schöner Plex- und Jellyfin-Client für Flutter', 'about.viewLicensesDescription' => 'Lizenzen von Drittanbieter-Bibliotheken anzeigen', 'serverSelection.allServerConnectionsFailed' => 'Verbindung zu allen Servern fehlgeschlagen. Bitte Netzwerk prüfen und erneut versuchen.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Keine Server gefunden für ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsDe { 'watchTogether.recentRooms' => 'Letzte Räume', 'watchTogether.renameRoom' => 'Raum umbenennen', 'watchTogether.removeRoom' => 'Entfernen', + 'watchTogether.guestSwitchUnavailable' => 'Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar', + 'watchTogether.guestSwitchFailed' => 'Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Verwalten', 'downloads.tvShows' => 'Serien', @@ -2291,6 +2537,12 @@ extension on TranslationsDe { 'downloads.editSyncFilter' => 'Synchronisierungsfilter', 'downloads.syncAllItems' => 'Alle Einträge synchronisieren', 'downloads.syncUnwatchedItems' => 'Ungesehene Einträge synchronisieren', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Verfügbar', + 'downloads.syncRuleOffline' => 'Offline', + 'downloads.syncRuleSignInRequired' => 'Anmeldung erforderlich', + 'downloads.syncRuleNotAvailableForProfile' => 'Für aktuelles Profil nicht verfügbar', + 'downloads.syncRuleUnknownServer' => 'Unbekannter Server', 'downloads.syncRuleListCreated' => 'Sync-Regel erstellt', 'shaders.title' => 'Shader', 'shaders.noShaderDescription' => 'Keine Videoverbesserung', @@ -2484,6 +2736,8 @@ extension on TranslationsDe { 'trakt.disconnectConfirmBody' => 'Plezy sendet keine Wiedergabe-Ereignisse mehr an Trakt. Du kannst dich jederzeit erneut verbinden.', 'trakt.scrobble' => 'Echtzeit-Scrobbling', 'trakt.scrobbleDescription' => 'Sende Play-, Pause- und Stopp-Ereignisse während der Wiedergabe an Trakt.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Gesehen-Status synchronisieren', 'trakt.watchedSyncDescription' => 'Wenn du Inhalte in Plezy als gesehen markierst, werden sie auch auf Trakt markiert.', 'trackers.title' => 'Tracker', @@ -2519,6 +2773,38 @@ extension on TranslationsDe { 'trackers.libraryFilter.modeHintWhitelist' => 'Nur die unten markierten Bibliotheken synchronisieren.', 'trackers.libraryFilter.libraries' => 'Bibliotheken', 'trackers.libraryFilter.noLibraries' => 'Keine Bibliotheken verfügbar', + 'addServer.addJellyfinTitle' => 'Jellyfin-Server hinzufügen', + 'addServer.jellyfinUrlIntro' => 'Gib die URL deines Jellyfin-Servers ein — z. B. `https://jellyfin.example.com`. Anmelden kannst du dich danach.', + 'addServer.serverUrl' => 'Server-URL', + 'addServer.findServer' => 'Server finden', + 'addServer.username' => 'Benutzername', + 'addServer.password' => 'Passwort', + 'addServer.signIn' => 'Anmelden', + 'addServer.change' => 'Ändern', + 'addServer.required' => 'Erforderlich', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Server nicht erreichbar: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Anmeldung fehlgeschlagen: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect fehlgeschlagen: ${error}', + 'addServer.addPlexTitle' => 'Mit Plex anmelden', + 'addServer.plexAuthIntro' => 'Wähle, wie du dich bei Plex anmelden möchtest. Der Browser-Ablauf öffnet plex.tv, wo du die Verbindung bestätigst; die QR-Option eignet sich für TV / Remote-Geräte.', + 'addServer.plexQRPrompt' => 'Scanne diesen QR-Code zum Anmelden.', + 'addServer.waitingForPlexConfirmation' => 'Warte auf Bestätigung durch plex.tv…', + 'addServer.pinExpired' => 'PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.', + 'addServer.duplicatePlexAccount' => 'Dieses Gerät ist bereits bei einem Plex-Konto angemeldet. Melde dich in den Einstellungen ab, um das Konto zu wechseln.', + '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.addConnectionIntroGlobal' => 'Füge einen weiteren Medienserver hinzu. Du kannst Plex-Konten und Jellyfin-Server kombinieren — Inhalte aller verbundenen Backends erscheinen gemeinsam auf dem Startbildschirm.', + 'addServer.addConnectionIntroScoped' => 'Füge einen neuen Server hinzu oder leihe einen von einem anderen Profil aus.', + 'addServer.signInWithPlexCard' => 'Mit Plex anmelden', + 'addServer.signInWithPlexCardSubtitle' => 'Autorisiere dieses Gerät mit deinem Plex-Konto. Mit dem Konto geteilte Server kommen automatisch mit.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Autorisiere ein neues Plex-Konto. Dessen Home-Benutzer erscheinen als Profile.', + 'addServer.connectToJellyfinCard' => 'Mit Jellyfin verbinden', + 'addServer.connectToJellyfinCardSubtitle' => 'Gib die URL deines Jellyfin-Servers ein und melde dich mit Benutzername + Passwort an (Quick Connect kommt bald).', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.', + 'addServer.borrowFromAnotherProfile' => 'Von einem anderen Profil ausleihen', + 'addServer.borrowFromAnotherProfileSubtitle' => 'Verwende eine Verbindung wieder, die bereits einem anderen Profil zugeordnet ist. Bei PIN-geschützten Quellprofilen wird die PIN abgefragt.', _ => null, }; } diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 8fc7b748..f661dc10 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -40,52 +40,55 @@ class Translations with BaseTranslations { Translations $copyWith({TranslationMetadata? meta}) => Translations(meta: meta ?? this.$meta); // Translations - late final TranslationsAppEn app = TranslationsAppEn._(_root); - late final TranslationsAuthEn auth = TranslationsAuthEn._(_root); - late final TranslationsCommonEn common = TranslationsCommonEn._(_root); - late final TranslationsScreensEn screens = TranslationsScreensEn._(_root); - late final TranslationsUpdateEn update = TranslationsUpdateEn._(_root); - late final TranslationsSettingsEn settings = TranslationsSettingsEn._(_root); - late final TranslationsSearchEn search = TranslationsSearchEn._(_root); - late final TranslationsHotkeysEn hotkeys = TranslationsHotkeysEn._(_root); - late final TranslationsFileInfoEn fileInfo = TranslationsFileInfoEn._(_root); - late final TranslationsMediaMenuEn mediaMenu = TranslationsMediaMenuEn._(_root); - late final TranslationsAccessibilityEn accessibility = TranslationsAccessibilityEn._(_root); - late final TranslationsTooltipsEn tooltips = TranslationsTooltipsEn._(_root); - late final TranslationsVideoControlsEn videoControls = TranslationsVideoControlsEn._(_root); - late final TranslationsUserStatusEn userStatus = TranslationsUserStatusEn._(_root); - late final TranslationsMessagesEn messages = TranslationsMessagesEn._(_root); - late final TranslationsSubtitlingStylingEn subtitlingStyling = TranslationsSubtitlingStylingEn._(_root); - late final TranslationsMpvConfigEn mpvConfig = TranslationsMpvConfigEn._(_root); - late final TranslationsDialogEn dialog = TranslationsDialogEn._(_root); - late final TranslationsDiscoverEn discover = TranslationsDiscoverEn._(_root); - late final TranslationsErrorsEn errors = TranslationsErrorsEn._(_root); - late final TranslationsLibrariesEn libraries = TranslationsLibrariesEn._(_root); - late final TranslationsAboutEn about = TranslationsAboutEn._(_root); - late final TranslationsServerSelectionEn serverSelection = TranslationsServerSelectionEn._(_root); - late final TranslationsHubDetailEn hubDetail = TranslationsHubDetailEn._(_root); - late final TranslationsLogsEn logs = TranslationsLogsEn._(_root); - late final TranslationsLicensesEn licenses = TranslationsLicensesEn._(_root); - late final TranslationsNavigationEn navigation = TranslationsNavigationEn._(_root); - late final TranslationsLiveTvEn liveTv = TranslationsLiveTvEn._(_root); - late final TranslationsCollectionsEn collections = TranslationsCollectionsEn._(_root); - late final TranslationsPlaylistsEn playlists = TranslationsPlaylistsEn._(_root); - late final TranslationsWatchTogetherEn watchTogether = TranslationsWatchTogetherEn._(_root); - late final TranslationsDownloadsEn downloads = TranslationsDownloadsEn._(_root); - late final TranslationsShadersEn shaders = TranslationsShadersEn._(_root); - late final TranslationsCompanionRemoteEn companionRemote = TranslationsCompanionRemoteEn._(_root); - late final TranslationsVideoSettingsEn videoSettings = TranslationsVideoSettingsEn._(_root); - late final TranslationsExternalPlayerEn externalPlayer = TranslationsExternalPlayerEn._(_root); - late final TranslationsMetadataEditEn metadataEdit = TranslationsMetadataEditEn._(_root); - late final TranslationsMatchScreenEn matchScreen = TranslationsMatchScreenEn._(_root); - late final TranslationsServerTasksEn serverTasks = TranslationsServerTasksEn._(_root); - late final TranslationsTraktEn trakt = TranslationsTraktEn._(_root); - late final TranslationsTrackersEn trackers = TranslationsTrackersEn._(_root); + late final TranslationsAppEn app = TranslationsAppEn.internal(_root); + late final TranslationsAuthEn auth = TranslationsAuthEn.internal(_root); + late final TranslationsCommonEn common = TranslationsCommonEn.internal(_root); + late final TranslationsScreensEn screens = TranslationsScreensEn.internal(_root); + late final TranslationsUpdateEn update = TranslationsUpdateEn.internal(_root); + late final TranslationsSettingsEn settings = TranslationsSettingsEn.internal(_root); + late final TranslationsSearchEn search = TranslationsSearchEn.internal(_root); + late final TranslationsHotkeysEn hotkeys = TranslationsHotkeysEn.internal(_root); + late final TranslationsFileInfoEn fileInfo = TranslationsFileInfoEn.internal(_root); + late final TranslationsMediaMenuEn mediaMenu = TranslationsMediaMenuEn.internal(_root); + late final TranslationsAccessibilityEn accessibility = TranslationsAccessibilityEn.internal(_root); + late final TranslationsTooltipsEn tooltips = TranslationsTooltipsEn.internal(_root); + late final TranslationsVideoControlsEn videoControls = TranslationsVideoControlsEn.internal(_root); + late final TranslationsUserStatusEn userStatus = TranslationsUserStatusEn.internal(_root); + late final TranslationsMessagesEn messages = TranslationsMessagesEn.internal(_root); + late final TranslationsSubtitlingStylingEn subtitlingStyling = TranslationsSubtitlingStylingEn.internal(_root); + late final TranslationsMpvConfigEn mpvConfig = TranslationsMpvConfigEn.internal(_root); + late final TranslationsDialogEn dialog = TranslationsDialogEn.internal(_root); + late final TranslationsProfilesEn profiles = TranslationsProfilesEn.internal(_root); + late final TranslationsConnectionsEn connections = TranslationsConnectionsEn.internal(_root); + late final TranslationsDiscoverEn discover = TranslationsDiscoverEn.internal(_root); + late final TranslationsErrorsEn errors = TranslationsErrorsEn.internal(_root); + late final TranslationsLibrariesEn libraries = TranslationsLibrariesEn.internal(_root); + late final TranslationsAboutEn about = TranslationsAboutEn.internal(_root); + late final TranslationsServerSelectionEn serverSelection = TranslationsServerSelectionEn.internal(_root); + late final TranslationsHubDetailEn hubDetail = TranslationsHubDetailEn.internal(_root); + late final TranslationsLogsEn logs = TranslationsLogsEn.internal(_root); + late final TranslationsLicensesEn licenses = TranslationsLicensesEn.internal(_root); + late final TranslationsNavigationEn navigation = TranslationsNavigationEn.internal(_root); + late final TranslationsLiveTvEn liveTv = TranslationsLiveTvEn.internal(_root); + late final TranslationsCollectionsEn collections = TranslationsCollectionsEn.internal(_root); + late final TranslationsPlaylistsEn playlists = TranslationsPlaylistsEn.internal(_root); + late final TranslationsWatchTogetherEn watchTogether = TranslationsWatchTogetherEn.internal(_root); + late final TranslationsDownloadsEn downloads = TranslationsDownloadsEn.internal(_root); + late final TranslationsShadersEn shaders = TranslationsShadersEn.internal(_root); + late final TranslationsCompanionRemoteEn companionRemote = TranslationsCompanionRemoteEn.internal(_root); + late final TranslationsVideoSettingsEn videoSettings = TranslationsVideoSettingsEn.internal(_root); + late final TranslationsExternalPlayerEn externalPlayer = TranslationsExternalPlayerEn.internal(_root); + late final TranslationsMetadataEditEn metadataEdit = TranslationsMetadataEditEn.internal(_root); + late final TranslationsMatchScreenEn matchScreen = TranslationsMatchScreenEn.internal(_root); + late final TranslationsServerTasksEn serverTasks = TranslationsServerTasksEn.internal(_root); + late final TranslationsTraktEn trakt = TranslationsTraktEn.internal(_root); + late final TranslationsTrackersEn trackers = TranslationsTrackersEn.internal(_root); + late final TranslationsAddServerEn addServer = TranslationsAddServerEn.internal(_root); } // Path: app class TranslationsAppEn { - TranslationsAppEn._(this._root); + TranslationsAppEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -97,12 +100,15 @@ class TranslationsAppEn { // Path: auth class TranslationsAuthEn { - TranslationsAuthEn._(this._root); + TranslationsAuthEn.internal(this._root); final Translations _root; // ignore: unused_field // Translations + /// en: 'Sign in' + String get signIn => 'Sign in'; + /// en: 'Sign in with Plex' String get signInWithPlex => 'Sign in with Plex'; @@ -123,11 +129,35 @@ class TranslationsAuthEn { /// en: 'Use browser' String get useBrowser => 'Use browser'; + + /// en: 'or' + String get or => 'or'; + + /// en: 'Connect to Jellyfin' + String get connectToJellyfin => 'Connect to Jellyfin'; + + /// en: 'Use Quick Connect' + String get useQuickConnect => 'Use Quick Connect'; + + /// en: 'Quick Connect code' + String get quickConnectCode => 'Quick Connect code'; + + /// en: 'Open your Jellyfin server in a web browser, sign in, and choose Quick Connect from the user menu. Enter this code to approve sign-in.' + String get quickConnectInstructions => 'Open your Jellyfin server in a web browser, sign in, and choose Quick Connect from the user menu. Enter this code to approve sign-in.'; + + /// en: 'Waiting for approval…' + String get quickConnectWaiting => 'Waiting for approval…'; + + /// en: 'Cancel' + String get quickConnectCancel => 'Cancel'; + + /// en: 'Quick Connect code expired before approval. Please try again.' + String get quickConnectExpired => 'Quick Connect code expired before approval. Please try again.'; } // Path: common class TranslationsCommonEn { - TranslationsCommonEn._(this._root); + TranslationsCommonEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -268,7 +298,7 @@ class TranslationsCommonEn { // Path: screens class TranslationsScreensEn { - TranslationsScreensEn._(this._root); + TranslationsScreensEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -292,7 +322,7 @@ class TranslationsScreensEn { // Path: update class TranslationsUpdateEn { - TranslationsUpdateEn._(this._root); + TranslationsUpdateEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -322,7 +352,7 @@ class TranslationsUpdateEn { // Path: settings class TranslationsSettingsEn { - TranslationsSettingsEn._(this._root); + TranslationsSettingsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -418,11 +448,11 @@ class TranslationsSettingsEn { /// en: 'Show Hero Section' String get showHeroSection => 'Show Hero Section'; - /// en: 'Use Plex Home Layout' - String get useGlobalHubs => 'Use Plex Home Layout'; + /// en: 'Use Home Layout' + String get useGlobalHubs => 'Use Home Layout'; - /// en: 'Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.' - String get useGlobalHubsDescription => 'Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.'; + /// en: 'Show home page hubs like the official client. When off, shows per-library recommendations instead.' + String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; /// en: 'Show Server Name on Hubs' String get showServerNameOnHubs => 'Show Server Name on Hubs'; @@ -433,8 +463,8 @@ class TranslationsSettingsEn { /// en: 'Group Libraries by Server' String get groupLibrariesByServer => 'Group Libraries by Server'; - /// en: 'Show a header for each Plex server in the sidebar when you're connected to multiple servers.' - String get groupLibrariesByServerDescription => 'Show a header for each Plex server in the sidebar when you\'re connected to multiple servers.'; + /// en: 'Show a header for each media server in the sidebar when you're connected to multiple servers.' + String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; /// en: 'Always Keep Sidebar Open' String get alwaysKeepSidebarOpen => 'Always Keep Sidebar Open'; @@ -889,7 +919,7 @@ class TranslationsSettingsEn { // Path: search class TranslationsSearchEn { - TranslationsSearchEn._(this._root); + TranslationsSearchEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -910,7 +940,7 @@ class TranslationsSearchEn { // Path: hotkeys class TranslationsHotkeysEn { - TranslationsHotkeysEn._(this._root); + TranslationsHotkeysEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -922,12 +952,12 @@ class TranslationsHotkeysEn { /// en: 'Clear shortcut' String get clearShortcut => 'Clear shortcut'; - late final TranslationsHotkeysActionsEn actions = TranslationsHotkeysActionsEn._(_root); + late final TranslationsHotkeysActionsEn actions = TranslationsHotkeysActionsEn.internal(_root); } // Path: fileInfo class TranslationsFileInfoEn { - TranslationsFileInfoEn._(this._root); + TranslationsFileInfoEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1011,7 +1041,7 @@ class TranslationsFileInfoEn { // Path: mediaMenu class TranslationsMediaMenuEn { - TranslationsMediaMenuEn._(this._root); + TranslationsMediaMenuEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1065,7 +1095,7 @@ class TranslationsMediaMenuEn { // Path: accessibility class TranslationsAccessibilityEn { - TranslationsAccessibilityEn._(this._root); + TranslationsAccessibilityEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1098,7 +1128,7 @@ class TranslationsAccessibilityEn { // Path: tooltips class TranslationsTooltipsEn { - TranslationsTooltipsEn._(this._root); + TranslationsTooltipsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1119,7 +1149,7 @@ class TranslationsTooltipsEn { // Path: videoControls class TranslationsVideoControlsEn { - TranslationsVideoControlsEn._(this._root); + TranslationsVideoControlsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1293,7 +1323,7 @@ class TranslationsVideoControlsEn { /// en: 'Picture-in-picture failed to start' String get pipFailed => 'Picture-in-picture failed to start'; - late final TranslationsVideoControlsPipErrorsEn pipErrors = TranslationsVideoControlsPipErrorsEn._(_root); + late final TranslationsVideoControlsPipErrorsEn pipErrors = TranslationsVideoControlsPipErrorsEn.internal(_root); /// en: 'Chapters' String get chapters => 'Chapters'; @@ -1328,7 +1358,7 @@ class TranslationsVideoControlsEn { // Path: userStatus class TranslationsUserStatusEn { - TranslationsUserStatusEn._(this._root); + TranslationsUserStatusEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1349,7 +1379,7 @@ class TranslationsUserStatusEn { // Path: messages class TranslationsMessagesEn { - TranslationsMessagesEn._(this._root); + TranslationsMessagesEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1397,8 +1427,8 @@ class TranslationsMessagesEn { /// en: 'No profiles available' String get noProfilesAvailable => 'No profiles available'; - /// en: 'Contact your Plex administrator to add profiles' - String get contactAdminForProfiles => 'Contact your Plex administrator to add profiles'; + /// en: 'Contact your server administrator to add profiles' + String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; /// en: 'Unable to determine library section for this item' String get unableToDetermineLibrarySection => 'Unable to determine library section for this item'; @@ -1478,7 +1508,7 @@ class TranslationsMessagesEn { // Path: subtitlingStyling class TranslationsSubtitlingStylingEn { - TranslationsSubtitlingStylingEn._(this._root); + TranslationsSubtitlingStylingEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1526,7 +1556,7 @@ class TranslationsSubtitlingStylingEn { // Path: mpvConfig class TranslationsMpvConfigEn { - TranslationsMpvConfigEn._(this._root); + TranslationsMpvConfigEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1577,7 +1607,7 @@ class TranslationsMpvConfigEn { // Path: dialog class TranslationsDialogEn { - TranslationsDialogEn._(this._root); + TranslationsDialogEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1587,9 +1617,177 @@ class TranslationsDialogEn { String get confirmAction => 'Confirm Action'; } +// Path: profiles +class TranslationsProfilesEn { + TranslationsProfilesEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Add Plezy profile' + String get addPlezyProfile => 'Add Plezy profile'; + + /// en: 'Switching profile…' + String get switchingProfile => 'Switching profile…'; + + /// en: 'Delete this profile?' + String get deleteThisProfileTitle => 'Delete this profile?'; + + /// en: '${displayName} will be removed. Connections themselves are not affected.' + String deleteThisProfileMessage({required Object displayName}) => '${displayName} will be removed. Connections themselves are not affected.'; + + /// en: 'Active' + String get active => 'Active'; + + /// en: 'Manage' + String get manage => 'Manage'; + + /// en: 'Delete' + String get delete => 'Delete'; + + /// en: 'Sign out' + String get signOut => 'Sign out'; + + /// en: 'Sign out of Plex?' + String get signOutPlexTitle => 'Sign out of Plex?'; + + /// en: '${displayName} and every Plex Home user on this account will be removed from this device. You can sign back in any time.' + String signOutPlexMessage({required Object displayName}) => '${displayName} and every Plex Home user on this account will be removed from this device. You can sign back in any time.'; + + /// en: 'Signed out of Plex.' + String get signedOutPlex => 'Signed out of Plex.'; + + /// en: 'Sign out failed.' + String get signOutFailed => 'Sign out failed.'; + + /// en: 'Profiles' + String get sectionTitle => 'Profiles'; + + /// en: 'Add profiles to mix managed users and local identities' + String get summarySingle => 'Add profiles to mix managed users and local identities'; + + /// en: '${count} profiles · active: ${activeName}' + String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} profiles · active: ${activeName}'; + + /// en: '${count} profiles' + String summaryMultiple({required Object count}) => '${count} profiles'; + + /// en: 'Remove connection?' + String get removeConnectionTitle => 'Remove connection?'; + + /// en: '${displayName} will lose access to ${connectionLabel}. The connection itself stays available to other profiles.' + String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} will lose access to ${connectionLabel}. The connection itself stays available to other profiles.'; + + /// en: 'Delete profile?' + String get deleteProfileTitle => 'Delete profile?'; + + /// en: 'This removes ${displayName} and all its connections from this device. The underlying Plex/Jellyfin servers aren't affected.' + String deleteProfileMessage({required Object displayName}) => 'This removes ${displayName} and all its connections from this device. The underlying Plex/Jellyfin servers aren\'t affected.'; + + /// en: 'Profile name' + String get profileNameLabel => 'Profile name'; + + /// en: 'PIN protection' + String get pinProtectionLabel => 'PIN protection'; + + /// en: 'PIN managed by Plex. Edit on plex.tv.' + String get pinManagedByPlex => 'PIN managed by Plex. Edit on plex.tv.'; + + /// en: 'No PIN set. To require one, edit the home user on plex.tv.' + String get noPinSetEditOnPlex => 'No PIN set. To require one, edit the home user on plex.tv.'; + + /// en: 'Set PIN' + String get setPin => 'Set PIN'; + + /// en: 'Connections' + String get connectionsLabel => 'Connections'; + + /// en: 'Add' + String get add => 'Add'; + + /// en: 'Delete profile' + String get deleteProfileButton => 'Delete profile'; + + /// en: 'No connections — add one to use this profile.' + String get noConnectionsHint => 'No connections — add one to use this profile.'; + + /// en: 'Plex Home account' + String get plexHomeAccount => 'Plex Home account'; + + /// en: 'Default' + String get connectionDefault => 'Default'; + + /// en: 'Make default' + String get makeDefault => 'Make default'; + + /// en: 'Remove' + String get removeConnection => 'Remove'; + + /// en: 'Add to ${displayName}' + String borrowAddTo({required Object displayName}) => 'Add to ${displayName}'; + + /// en: 'Borrow a connection from another profile. PIN-protected source profiles ask for the PIN before sharing.' + String get borrowExplain => 'Borrow a connection from another profile. PIN-protected source profiles ask for the PIN before sharing.'; + + /// en: 'Nothing to borrow yet.' + String get borrowEmpty => 'Nothing to borrow yet.'; + + /// en: 'Connect a Plex account or Jellyfin server to another profile first, then come back here.' + String get borrowEmptySubtitle => 'Connect a Plex account or Jellyfin server to another profile first, then come back here.'; + + /// en: 'New profile' + String get newProfile => 'New profile'; + + /// en: 'e.g. Guests, Kids, Family Room' + String get profileNameHint => 'e.g. Guests, Kids, Family Room'; + + /// en: 'PIN protection (optional)' + String get pinProtectionOptional => 'PIN protection (optional)'; + + /// en: '4-digit PIN required to switch into this profile. Soft barrier — anyone who can clear app data can bypass it.' + String get pinExplain => '4-digit PIN required to switch into this profile. Soft barrier — anyone who can clear app data can bypass it.'; + + /// en: 'Continue' + String get continueButton => 'Continue'; + + /// en: 'PINs don't match' + String get pinsDontMatch => 'PINs don\'t match'; +} + +// Path: connections +class TranslationsConnectionsEn { + TranslationsConnectionsEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Connections' + String get sectionTitle => 'Connections'; + + /// 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: 'Add to ${displayName} — Plex account, Jellyfin server, or borrow from another profile' + String addConnectionSubtitleScoped({required Object displayName}) => 'Add to ${displayName} — Plex account, Jellyfin server, or borrow from another profile'; + + /// en: 'Session expired for ${name}' + String sessionExpiredOne({required Object name}) => 'Session expired for ${name}'; + + /// en: 'Session expired for ${count} servers' + String sessionExpiredMany({required Object count}) => 'Session expired for ${count} servers'; + + /// en: 'Sign in again' + String get signInAgain => 'Sign in again'; +} + // Path: discover class TranslationsDiscoverEn { - TranslationsDiscoverEn._(this._root); + TranslationsDiscoverEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1610,6 +1808,12 @@ class TranslationsDiscoverEn { /// en: 'Continue Watching' String get continueWatching => 'Continue Watching'; + /// en: 'Next Up' + String get nextUp => 'Next Up'; + + /// en: 'Recently Added' + String get recentlyAdded => 'Recently Added'; + /// en: 'S${season}E${episode}' String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @@ -1640,7 +1844,7 @@ class TranslationsDiscoverEn { // Path: errors class TranslationsErrorsEn { - TranslationsErrorsEn._(this._root); + TranslationsErrorsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1652,8 +1856,8 @@ class TranslationsErrorsEn { /// en: 'Connection timeout while loading ${context}' String connectionTimeout({required Object context}) => 'Connection timeout while loading ${context}'; - /// en: 'Unable to connect to Plex server' - String get connectionFailed => 'Unable to connect to Plex server'; + /// en: 'Unable to connect to media server' + String get connectionFailed => 'Unable to connect to media server'; /// en: 'Failed to load ${context}: ${error}' String failedToLoad({required Object context, required Object error}) => 'Failed to load ${context}: ${error}'; @@ -1678,11 +1882,17 @@ class TranslationsErrorsEn { /// en: 'Failed to switch to ${displayName}' String failedToSwitchProfile({required Object displayName}) => 'Failed to switch to ${displayName}'; + + /// en: 'Failed to delete ${displayName}' + String failedToDeleteProfile({required Object displayName}) => 'Failed to delete ${displayName}'; + + /// en: 'Couldn't update rating' + String get failedToRate => 'Couldn\'t update rating'; } // Path: libraries class TranslationsLibrariesEn { - TranslationsLibrariesEn._(this._root); + TranslationsLibrariesEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1802,13 +2012,15 @@ class TranslationsLibrariesEn { /// en: 'folders' String get folders => 'folders'; - late final TranslationsLibrariesTabsEn tabs = TranslationsLibrariesTabsEn._(_root); - late final TranslationsLibrariesGroupingsEn groupings = TranslationsLibrariesGroupingsEn._(_root); + late final TranslationsLibrariesTabsEn tabs = TranslationsLibrariesTabsEn.internal(_root); + late final TranslationsLibrariesGroupingsEn groupings = TranslationsLibrariesGroupingsEn.internal(_root); + late final TranslationsLibrariesFilterCategoriesEn filterCategories = TranslationsLibrariesFilterCategoriesEn.internal(_root); + late final TranslationsLibrariesSortLabelsEn sortLabels = TranslationsLibrariesSortLabelsEn.internal(_root); } // Path: about class TranslationsAboutEn { - TranslationsAboutEn._(this._root); + TranslationsAboutEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1823,8 +2035,8 @@ class TranslationsAboutEn { /// en: 'Version ${version}' String versionLabel({required Object version}) => 'Version ${version}'; - /// en: 'A beautiful Plex client for Flutter' - String get appDescription => 'A beautiful Plex client for Flutter'; + /// en: 'A beautiful Plex and Jellyfin client for Flutter' + String get appDescription => 'A beautiful Plex and Jellyfin client for Flutter'; /// en: 'View licenses of third-party libraries' String get viewLicensesDescription => 'View licenses of third-party libraries'; @@ -1832,7 +2044,7 @@ class TranslationsAboutEn { // Path: serverSelection class TranslationsServerSelectionEn { - TranslationsServerSelectionEn._(this._root); + TranslationsServerSelectionEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1850,7 +2062,7 @@ class TranslationsServerSelectionEn { // Path: hubDetail class TranslationsHubDetailEn { - TranslationsHubDetailEn._(this._root); + TranslationsHubDetailEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1874,7 +2086,7 @@ class TranslationsHubDetailEn { // Path: logs class TranslationsLogsEn { - TranslationsLogsEn._(this._root); + TranslationsLogsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1892,7 +2104,7 @@ class TranslationsLogsEn { // Path: licenses class TranslationsLicensesEn { - TranslationsLicensesEn._(this._root); + TranslationsLicensesEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1913,7 +2125,7 @@ class TranslationsLicensesEn { // Path: navigation class TranslationsNavigationEn { - TranslationsNavigationEn._(this._root); + TranslationsNavigationEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -1931,7 +2143,7 @@ class TranslationsNavigationEn { // Path: liveTv class TranslationsLiveTvEn { - TranslationsLiveTvEn._(this._root); + TranslationsLiveTvEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2009,7 +2221,7 @@ class TranslationsLiveTvEn { // Path: collections class TranslationsCollectionsEn { - TranslationsCollectionsEn._(this._root); + TranslationsCollectionsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2084,7 +2296,7 @@ class TranslationsCollectionsEn { // Path: playlists class TranslationsPlaylistsEn { - TranslationsPlaylistsEn._(this._root); + TranslationsPlaylistsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2168,7 +2380,7 @@ class TranslationsPlaylistsEn { // Path: watchTogether class TranslationsWatchTogetherEn { - TranslationsWatchTogetherEn._(this._root); + TranslationsWatchTogetherEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2347,11 +2559,17 @@ class TranslationsWatchTogetherEn { /// en: 'Remove' String get removeRoom => 'Remove'; + + /// en: 'Couldn't switch — server unavailable for sync' + String get guestSwitchUnavailable => 'Couldn\'t switch — server unavailable for sync'; + + /// en: 'Couldn't switch — content not found on this server' + String get guestSwitchFailed => 'Couldn\'t switch — content not found on this server'; } // Path: downloads class TranslationsDownloadsEn { - TranslationsDownloadsEn._(this._root); + TranslationsDownloadsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2501,13 +2719,31 @@ class TranslationsDownloadsEn { /// en: 'Syncing unwatched items' String get syncUnwatchedItems => 'Syncing unwatched items'; + /// en: 'Server: ${server} • ${status}' + String syncRuleServerContext({required Object server, required Object status}) => 'Server: ${server} • ${status}'; + + /// en: 'Available' + String get syncRuleAvailable => 'Available'; + + /// en: 'Offline' + String get syncRuleOffline => 'Offline'; + + /// en: 'Sign in required' + String get syncRuleSignInRequired => 'Sign in required'; + + /// en: 'Not available for current profile' + String get syncRuleNotAvailableForProfile => 'Not available for current profile'; + + /// en: 'Unknown server' + String get syncRuleUnknownServer => 'Unknown server'; + /// en: 'Sync rule created' String get syncRuleListCreated => 'Sync rule created'; } // Path: shaders class TranslationsShadersEn { - TranslationsShadersEn._(this._root); + TranslationsShadersEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2561,7 +2797,7 @@ class TranslationsShadersEn { // Path: companionRemote class TranslationsCompanionRemoteEn { - TranslationsCompanionRemoteEn._(this._root); + TranslationsCompanionRemoteEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2573,14 +2809,14 @@ class TranslationsCompanionRemoteEn { /// en: 'Connected to ${name}' String connectedTo({required Object name}) => 'Connected to ${name}'; - late final TranslationsCompanionRemoteSessionEn session = TranslationsCompanionRemoteSessionEn._(_root); - late final TranslationsCompanionRemotePairingEn pairing = TranslationsCompanionRemotePairingEn._(_root); - late final TranslationsCompanionRemoteRemoteEn remote = TranslationsCompanionRemoteRemoteEn._(_root); + late final TranslationsCompanionRemoteSessionEn session = TranslationsCompanionRemoteSessionEn.internal(_root); + late final TranslationsCompanionRemotePairingEn pairing = TranslationsCompanionRemotePairingEn.internal(_root); + late final TranslationsCompanionRemoteRemoteEn remote = TranslationsCompanionRemoteRemoteEn.internal(_root); } // Path: videoSettings class TranslationsVideoSettingsEn { - TranslationsVideoSettingsEn._(this._root); + TranslationsVideoSettingsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2619,7 +2855,7 @@ class TranslationsVideoSettingsEn { // Path: externalPlayer class TranslationsExternalPlayerEn { - TranslationsExternalPlayerEn._(this._root); + TranslationsExternalPlayerEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2673,7 +2909,7 @@ class TranslationsExternalPlayerEn { // Path: metadataEdit class TranslationsMetadataEditEn { - TranslationsMetadataEditEn._(this._root); + TranslationsMetadataEditEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2904,7 +3140,7 @@ class TranslationsMetadataEditEn { // Path: matchScreen class TranslationsMatchScreenEn { - TranslationsMatchScreenEn._(this._root); + TranslationsMatchScreenEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2949,7 +3185,7 @@ class TranslationsMatchScreenEn { // Path: serverTasks class TranslationsServerTasksEn { - TranslationsServerTasksEn._(this._root); + TranslationsServerTasksEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -2967,7 +3203,7 @@ class TranslationsServerTasksEn { // Path: trakt class TranslationsTraktEn { - TranslationsTraktEn._(this._root); + TranslationsTraktEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3003,7 +3239,7 @@ class TranslationsTraktEn { // Path: trackers class TranslationsTrackersEn { - TranslationsTrackersEn._(this._root); + TranslationsTrackersEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3036,15 +3272,120 @@ class TranslationsTrackersEn { /// en: 'Couldn't connect to ${service}. Try again.' String connectFailed({required Object service}) => 'Couldn\'t connect to ${service}. Try again.'; - late final TranslationsTrackersServicesEn services = TranslationsTrackersServicesEn._(_root); - late final TranslationsTrackersDeviceCodeEn deviceCode = TranslationsTrackersDeviceCodeEn._(_root); - late final TranslationsTrackersOauthProxyEn oauthProxy = TranslationsTrackersOauthProxyEn._(_root); - late final TranslationsTrackersLibraryFilterEn libraryFilter = TranslationsTrackersLibraryFilterEn._(_root); + late final TranslationsTrackersServicesEn services = TranslationsTrackersServicesEn.internal(_root); + late final TranslationsTrackersDeviceCodeEn deviceCode = TranslationsTrackersDeviceCodeEn.internal(_root); + late final TranslationsTrackersOauthProxyEn oauthProxy = TranslationsTrackersOauthProxyEn.internal(_root); + late final TranslationsTrackersLibraryFilterEn libraryFilter = TranslationsTrackersLibraryFilterEn.internal(_root); +} + +// Path: addServer +class TranslationsAddServerEn { + TranslationsAddServerEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Add Jellyfin server' + String get addJellyfinTitle => 'Add Jellyfin server'; + + /// en: 'Enter your Jellyfin server URL — e.g. `https://jellyfin.example.com`. You can sign in afterwards.' + String get jellyfinUrlIntro => 'Enter your Jellyfin server URL — e.g. `https://jellyfin.example.com`. You can sign in afterwards.'; + + /// en: 'Server URL' + String get serverUrl => 'Server URL'; + + /// en: 'Find server' + String get findServer => 'Find server'; + + /// en: 'Username' + String get username => 'Username'; + + /// en: 'Password' + String get password => 'Password'; + + /// en: 'Sign in' + String get signIn => 'Sign in'; + + /// en: 'Change' + String get change => 'Change'; + + /// en: 'Required' + String get required => 'Required'; + + /// en: 'Could not reach the server: ${error}' + String couldNotReachServer({required Object error}) => 'Could not reach the server: ${error}'; + + /// en: 'Sign-in failed: ${error}' + String signInFailed({required Object error}) => 'Sign-in failed: ${error}'; + + /// en: 'Quick Connect failed: ${error}' + String quickConnectFailed({required Object error}) => 'Quick Connect failed: ${error}'; + + /// en: 'Sign in with Plex' + String get addPlexTitle => 'Sign in with Plex'; + + /// en: 'Choose how to sign in to Plex. The browser flow opens plex.tv where you confirm the connection; the QR option is handy for TV / remote devices.' + String get plexAuthIntro => 'Choose how to sign in to Plex. The browser flow opens plex.tv where you confirm the connection; the QR option is handy for TV / remote devices.'; + + /// en: 'Scan this QR code to sign in.' + String get plexQRPrompt => 'Scan this QR code to sign in.'; + + /// en: 'Waiting for plex.tv to confirm your sign-in…' + String get waitingForPlexConfirmation => 'Waiting for plex.tv to confirm your sign-in…'; + + /// en: 'PIN expired before sign-in. Please try again.' + String get pinExpired => 'PIN expired before sign-in. Please try again.'; + + /// en: 'This device is already signed in to a Plex account. Sign out from settings to switch accounts.' + String get duplicatePlexAccount => 'This device is already signed in to a Plex account. Sign out from settings to switch accounts.'; + + /// 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: 'Add connection' + String get addConnectionTitle => 'Add connection'; + + /// en: 'Add to ${name}' + String addConnectionTitleScoped({required Object name}) => 'Add to ${name}'; + + /// en: 'Add another media server. You can mix Plex accounts and Jellyfin servers — items from every connected backend appear together on the home screen.' + String get addConnectionIntroGlobal => 'Add another media server. You can mix Plex accounts and Jellyfin servers — items from every connected backend appear together on the home screen.'; + + /// en: 'Add a new server, or borrow one from another profile.' + String get addConnectionIntroScoped => 'Add a new server, or borrow one from another profile.'; + + /// en: 'Sign in with Plex' + String get signInWithPlexCard => 'Sign in with Plex'; + + /// en: 'Authorize this device against your Plex account. Servers shared with the account come along automatically.' + String get signInWithPlexCardSubtitle => 'Authorize this device against your Plex account. Servers shared with the account come along automatically.'; + + /// en: 'Authorize a new Plex account. Its Home users appear as profiles.' + String get signInWithPlexCardSubtitleScoped => 'Authorize a new Plex account. Its Home users appear as profiles.'; + + /// en: 'Connect to Jellyfin' + String get connectToJellyfinCard => 'Connect to Jellyfin'; + + /// en: 'Enter your Jellyfin server URL and sign in with username + password (Quick Connect coming soon).' + String get connectToJellyfinCardSubtitle => 'Enter your Jellyfin server URL and sign in with username + password (Quick Connect coming soon).'; + + /// 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: 'Borrow from another profile' + String get borrowFromAnotherProfile => 'Borrow from another profile'; + + /// en: 'Reuse a connection that's already attached to a different profile. PIN-protected source profiles ask for the PIN.' + String get borrowFromAnotherProfileSubtitle => 'Reuse a connection that\'s already attached to a different profile. PIN-protected source profiles ask for the PIN.'; } // Path: hotkeys.actions class TranslationsHotkeysActionsEn { - TranslationsHotkeysActionsEn._(this._root); + TranslationsHotkeysActionsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3116,7 +3457,7 @@ class TranslationsHotkeysActionsEn { // Path: videoControls.pipErrors class TranslationsVideoControlsPipErrorsEn { - TranslationsVideoControlsPipErrorsEn._(this._root); + TranslationsVideoControlsPipErrorsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3146,7 +3487,7 @@ class TranslationsVideoControlsPipErrorsEn { // Path: libraries.tabs class TranslationsLibrariesTabsEn { - TranslationsLibrariesTabsEn._(this._root); + TranslationsLibrariesTabsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3167,7 +3508,7 @@ class TranslationsLibrariesTabsEn { // Path: libraries.groupings class TranslationsLibrariesGroupingsEn { - TranslationsLibrariesGroupingsEn._(this._root); + TranslationsLibrariesGroupingsEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3195,9 +3536,66 @@ class TranslationsLibrariesGroupingsEn { String get folders => 'Folders'; } +// Path: libraries.filterCategories +class TranslationsLibrariesFilterCategoriesEn { + TranslationsLibrariesFilterCategoriesEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Genre' + String get genre => 'Genre'; + + /// en: 'Year' + String get year => 'Year'; + + /// en: 'Content Rating' + String get contentRating => 'Content Rating'; + + /// en: 'Tag' + String get tag => 'Tag'; +} + +// Path: libraries.sortLabels +class TranslationsLibrariesSortLabelsEn { + TranslationsLibrariesSortLabelsEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Title' + String get title => 'Title'; + + /// en: 'Date Added' + String get dateAdded => 'Date Added'; + + /// en: 'Release Date' + String get releaseDate => 'Release Date'; + + /// en: 'Rating' + String get rating => 'Rating'; + + /// en: 'Last Played' + String get lastPlayed => 'Last Played'; + + /// en: 'Play Count' + String get playCount => 'Play Count'; + + /// en: 'Random' + String get random => 'Random'; + + /// en: 'Date Shared' + String get dateShared => 'Date Shared'; + + /// en: 'Latest Episode Air Date' + String get latestEpisodeAirDate => 'Latest Episode Air Date'; +} + // Path: companionRemote.session class TranslationsCompanionRemoteSessionEn { - TranslationsCompanionRemoteSessionEn._(this._root); + TranslationsCompanionRemoteSessionEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3242,7 +3640,7 @@ class TranslationsCompanionRemoteSessionEn { // Path: companionRemote.pairing class TranslationsCompanionRemotePairingEn { - TranslationsCompanionRemotePairingEn._(this._root); + TranslationsCompanionRemotePairingEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3296,7 +3694,7 @@ class TranslationsCompanionRemotePairingEn { // Path: companionRemote.remote class TranslationsCompanionRemoteRemoteEn { - TranslationsCompanionRemoteRemoteEn._(this._root); + TranslationsCompanionRemoteRemoteEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3386,7 +3784,7 @@ class TranslationsCompanionRemoteRemoteEn { // Path: trackers.services class TranslationsTrackersServicesEn { - TranslationsTrackersServicesEn._(this._root); + TranslationsTrackersServicesEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3404,7 +3802,7 @@ class TranslationsTrackersServicesEn { // Path: trackers.deviceCode class TranslationsTrackersDeviceCodeEn { - TranslationsTrackersDeviceCodeEn._(this._root); + TranslationsTrackersDeviceCodeEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3428,7 +3826,7 @@ class TranslationsTrackersDeviceCodeEn { // Path: trackers.oauthProxy class TranslationsTrackersOauthProxyEn { - TranslationsTrackersOauthProxyEn._(this._root); + TranslationsTrackersOauthProxyEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3449,7 +3847,7 @@ class TranslationsTrackersOauthProxyEn { // Path: trackers.libraryFilter class TranslationsTrackersLibraryFilterEn { - TranslationsTrackersLibraryFilterEn._(this._root); + TranslationsTrackersLibraryFilterEn.internal(this._root); final Translations _root; // ignore: unused_field @@ -3501,6 +3899,7 @@ extension on Translations { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Sign in', 'auth.signInWithPlex' => 'Sign in with Plex', 'auth.showQRCode' => 'Show QR Code', 'auth.authenticate' => 'Authenticate', @@ -3508,6 +3907,14 @@ extension on Translations { 'auth.scanQRToSignIn' => 'Scan this QR code to sign in', 'auth.waitingForAuth' => 'Waiting for authentication...\nPlease complete sign-in in your browser.', 'auth.useBrowser' => 'Use browser', + 'auth.or' => 'or', + 'auth.connectToJellyfin' => 'Connect to Jellyfin', + 'auth.useQuickConnect' => 'Use Quick Connect', + 'auth.quickConnectCode' => 'Quick Connect code', + 'auth.quickConnectInstructions' => 'Open your Jellyfin server in a web browser, sign in, and choose Quick Connect from the user menu. Enter this code to approve sign-in.', + 'auth.quickConnectWaiting' => 'Waiting for approval…', + 'auth.quickConnectCancel' => 'Cancel', + 'auth.quickConnectExpired' => 'Quick Connect code expired before approval. Please try again.', 'common.cancel' => 'Cancel', 'common.save' => 'Save', 'common.close' => 'Close', @@ -3594,12 +4001,12 @@ extension on Translations { 'settings.gridView' => 'Grid', 'settings.listView' => 'List', 'settings.showHeroSection' => 'Show Hero Section', - 'settings.useGlobalHubs' => 'Use Plex Home Layout', - 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Show Server Name on Hubs', 'settings.showServerNameOnHubsDescription' => 'Always display the server name in hub titles. When off, only shows for duplicate hub names.', 'settings.groupLibrariesByServer' => 'Group Libraries by Server', - 'settings.groupLibrariesByServerDescription' => 'Show a header for each Plex server in the sidebar when you\'re connected to multiple servers.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Always Keep Sidebar Open', 'settings.alwaysKeepSidebarOpenDescription' => 'Sidebar stays expanded and content area adjusts to fit', 'settings.showUnwatchedCount' => 'Show Unwatched Count', @@ -3920,7 +4327,7 @@ extension on Translations { 'messages.musicNotSupported' => 'Music playback is not yet supported', 'messages.noDescriptionAvailable' => 'No description available', 'messages.noProfilesAvailable' => 'No profiles available', - 'messages.contactAdminForProfiles' => 'Contact your Plex administrator to add profiles', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Unable to determine library section for this item', 'messages.logsCleared' => 'Logs cleared', 'messages.logsCopied' => 'Logs copied to clipboard', @@ -3974,11 +4381,65 @@ extension on Translations { 'mpvConfig.confirmDeletePreset' => 'Are you sure you want to delete this preset?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Confirm Action', + 'profiles.addPlezyProfile' => 'Add Plezy profile', + 'profiles.switchingProfile' => 'Switching profile…', + 'profiles.deleteThisProfileTitle' => 'Delete this profile?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} will be removed. Connections themselves are not affected.', + 'profiles.active' => 'Active', + 'profiles.manage' => 'Manage', + 'profiles.delete' => 'Delete', + 'profiles.signOut' => 'Sign out', + 'profiles.signOutPlexTitle' => 'Sign out of Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} and every Plex Home user on this account will be removed from this device. You can sign back in any time.', + 'profiles.signedOutPlex' => 'Signed out of Plex.', + 'profiles.signOutFailed' => 'Sign out failed.', + 'profiles.sectionTitle' => 'Profiles', + 'profiles.summarySingle' => 'Add profiles to mix managed users and local identities', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} profiles · active: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} profiles', + 'profiles.removeConnectionTitle' => 'Remove connection?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} will lose access to ${connectionLabel}. The connection itself stays available to other profiles.', + 'profiles.deleteProfileTitle' => 'Delete profile?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'This removes ${displayName} and all its connections from this device. The underlying Plex/Jellyfin servers aren\'t affected.', + 'profiles.profileNameLabel' => 'Profile name', + 'profiles.pinProtectionLabel' => 'PIN protection', + 'profiles.pinManagedByPlex' => 'PIN managed by Plex. Edit on plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'No PIN set. To require one, edit the home user on plex.tv.', + 'profiles.setPin' => 'Set PIN', + 'profiles.connectionsLabel' => 'Connections', + 'profiles.add' => 'Add', + 'profiles.deleteProfileButton' => 'Delete profile', + 'profiles.noConnectionsHint' => 'No connections — add one to use this profile.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Plex Home account', + 'profiles.connectionDefault' => 'Default', + 'profiles.makeDefault' => 'Make default', + 'profiles.removeConnection' => 'Remove', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Add to ${displayName}', + 'profiles.borrowExplain' => 'Borrow a connection from another profile. PIN-protected source profiles ask for the PIN before sharing.', + 'profiles.borrowEmpty' => 'Nothing to borrow yet.', + 'profiles.borrowEmptySubtitle' => 'Connect a Plex account or Jellyfin server to another profile first, then come back here.', + 'profiles.newProfile' => 'New profile', + 'profiles.profileNameHint' => 'e.g. Guests, Kids, Family Room', + 'profiles.pinProtectionOptional' => 'PIN protection (optional)', + 'profiles.pinExplain' => '4-digit PIN required to switch into this profile. Soft barrier — anyone who can clear app data can bypass it.', + 'profiles.continueButton' => 'Continue', + '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 account, Jellyfin server, or borrow from another profile', + 'connections.sessionExpiredOne' => ({required Object name}) => 'Session expired for ${name}', + 'connections.sessionExpiredMany' => ({required Object count}) => 'Session expired for ${count} servers', + 'connections.signInAgain' => 'Sign in again', 'discover.title' => 'Discover', 'discover.switchProfile' => 'Switch Profile', 'discover.noContentAvailable' => 'No content available', 'discover.addMediaToLibraries' => 'Add some media to your libraries', 'discover.continueWatching' => 'Continue Watching', + 'discover.nextUp' => 'Next Up', + 'discover.recentlyAdded' => 'Recently Added', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Overview', 'discover.cast' => 'Cast', @@ -3990,7 +4451,7 @@ extension on Translations { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min left', 'errors.searchFailed' => ({required Object error}) => 'Search failed: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Connection timeout while loading ${context}', - 'errors.connectionFailed' => 'Unable to connect to Plex server', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Failed to load ${context}: ${error}', 'errors.noClientAvailable' => 'No client available', 'errors.authenticationFailed' => ({required Object error}) => 'Authentication failed: ${error}', @@ -3999,6 +4460,8 @@ extension on Translations { 'errors.invalidToken' => 'Invalid token', 'errors.failedToVerifyToken' => ({required Object error}) => 'Failed to verify token: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Failed to switch to ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Failed to delete ${displayName}', + 'errors.failedToRate' => 'Couldn\'t update rating', 'libraries.title' => 'Libraries', 'libraries.scanLibraryFiles' => 'Scan Library Files', 'libraries.scanLibrary' => 'Scan Library', @@ -4012,8 +4475,6 @@ extension on Translations { 'libraries.analyzing' => ({required Object title}) => 'Analyzing "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Analysis started for "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Failed to analyze library: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'No libraries found', 'libraries.allLibrariesHidden' => 'All libraries are hidden', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Hidden libraries (${count})', @@ -4050,10 +4511,23 @@ extension on Translations { 'libraries.groupings.seasons' => 'Seasons', 'libraries.groupings.episodes' => 'Episodes', 'libraries.groupings.folders' => 'Folders', + 'libraries.filterCategories.genre' => 'Genre', + 'libraries.filterCategories.year' => 'Year', + 'libraries.filterCategories.contentRating' => 'Content Rating', + 'libraries.filterCategories.tag' => 'Tag', + 'libraries.sortLabels.title' => 'Title', + 'libraries.sortLabels.dateAdded' => 'Date Added', + 'libraries.sortLabels.releaseDate' => 'Release Date', + 'libraries.sortLabels.rating' => 'Rating', + 'libraries.sortLabels.lastPlayed' => 'Last Played', + 'libraries.sortLabels.playCount' => 'Play Count', + 'libraries.sortLabels.random' => 'Random', + 'libraries.sortLabels.dateShared' => 'Date Shared', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Latest Episode Air Date', 'about.title' => 'About', 'about.openSourceLicenses' => 'Open Source Licenses', 'about.versionLabel' => ({required Object version}) => 'Version ${version}', - 'about.appDescription' => 'A beautiful Plex client for Flutter', + 'about.appDescription' => 'A beautiful Plex and Jellyfin client for Flutter', 'about.viewLicensesDescription' => 'View licenses of third-party libraries', 'serverSelection.allServerConnectionsFailed' => 'Failed to connect to any servers. Please check your network and try again.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'No servers found for ${username} (${email})', @@ -4201,6 +4675,8 @@ extension on Translations { 'watchTogether.recentRooms' => 'Recent Rooms', 'watchTogether.renameRoom' => 'Rename Room', 'watchTogether.removeRoom' => 'Remove', + 'watchTogether.guestSwitchUnavailable' => 'Couldn\'t switch — server unavailable for sync', + 'watchTogether.guestSwitchFailed' => 'Couldn\'t switch — content not found on this server', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Manage', 'downloads.tvShows' => 'TV Shows', @@ -4249,6 +4725,12 @@ extension on Translations { 'downloads.editSyncFilter' => 'Sync filter', 'downloads.syncAllItems' => 'Syncing all items', 'downloads.syncUnwatchedItems' => 'Syncing unwatched items', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Available', + 'downloads.syncRuleOffline' => 'Offline', + 'downloads.syncRuleSignInRequired' => 'Sign in required', + 'downloads.syncRuleNotAvailableForProfile' => 'Not available for current profile', + 'downloads.syncRuleUnknownServer' => 'Unknown server', 'downloads.syncRuleListCreated' => 'Sync rule created', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'No video enhancement', @@ -4442,6 +4924,8 @@ extension on Translations { 'trakt.disconnectConfirmBody' => 'Plezy will stop sending playback events to Trakt. You can reconnect at any time.', 'trakt.scrobble' => 'Real-time scrobbling', 'trakt.scrobbleDescription' => 'Send play, pause, and stop events to Trakt during playback.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Sync watched status', 'trakt.watchedSyncDescription' => 'When you mark items watched in Plezy, mark them on Trakt.', 'trackers.title' => 'Trackers', @@ -4477,6 +4961,38 @@ extension on Translations { 'trackers.libraryFilter.modeHintWhitelist' => 'Sync only the libraries checked below.', 'trackers.libraryFilter.libraries' => 'Libraries', 'trackers.libraryFilter.noLibraries' => 'No libraries available', + 'addServer.addJellyfinTitle' => 'Add Jellyfin server', + 'addServer.jellyfinUrlIntro' => 'Enter your Jellyfin server URL — e.g. `https://jellyfin.example.com`. You can sign in afterwards.', + 'addServer.serverUrl' => 'Server URL', + 'addServer.findServer' => 'Find server', + 'addServer.username' => 'Username', + 'addServer.password' => 'Password', + 'addServer.signIn' => 'Sign in', + 'addServer.change' => 'Change', + 'addServer.required' => 'Required', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Could not reach the server: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Sign-in failed: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect failed: ${error}', + 'addServer.addPlexTitle' => 'Sign in with Plex', + 'addServer.plexAuthIntro' => 'Choose how to sign in to Plex. The browser flow opens plex.tv where you confirm the connection; the QR option is handy for TV / remote devices.', + 'addServer.plexQRPrompt' => 'Scan this QR code to sign in.', + 'addServer.waitingForPlexConfirmation' => 'Waiting for plex.tv to confirm your sign-in…', + 'addServer.pinExpired' => 'PIN expired before sign-in. Please try again.', + 'addServer.duplicatePlexAccount' => 'This device is already signed in to a Plex account. Sign out from settings to switch accounts.', + 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Failed to register account: ${error}', + 'addServer.enterJellyfinUrlError' => 'Enter your Jellyfin server URL', + 'addServer.addConnectionTitle' => 'Add connection', + 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Add to ${name}', + 'addServer.addConnectionIntroGlobal' => 'Add another media server. You can mix Plex accounts and Jellyfin servers — items from every connected backend appear together on the home screen.', + 'addServer.addConnectionIntroScoped' => 'Add a new server, or borrow one from another profile.', + 'addServer.signInWithPlexCard' => 'Sign in with Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Authorize this device against your Plex account. Servers shared with the account come along automatically.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Authorize a new Plex account. Its Home users appear as profiles.', + 'addServer.connectToJellyfinCard' => 'Connect to Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Enter your Jellyfin server URL and sign in with username + password (Quick Connect coming soon).', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Sign in to a Jellyfin server. Binds to ${name}.', + 'addServer.borrowFromAnotherProfile' => 'Borrow from another profile', + 'addServer.borrowFromAnotherProfileSubtitle' => 'Reuse a connection that\'s already attached to a different profile. PIN-protected source profiles ask for the PIN.', _ => null, }; } diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index ed30d613..38204149 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsEs with BaseTranslations implements Translations { +class TranslationsEs extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsEs({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsEs with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsEs with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsEs _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsEs with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingEs subtitlingStyling = _TranslationsSubtitlingStylingEs._(_root); @override late final _TranslationsMpvConfigEs mpvConfig = _TranslationsMpvConfigEs._(_root); @override late final _TranslationsDialogEs dialog = _TranslationsDialogEs._(_root); + @override late final _TranslationsProfilesEs profiles = _TranslationsProfilesEs._(_root); + @override late final _TranslationsConnectionsEs connections = _TranslationsConnectionsEs._(_root); @override late final _TranslationsDiscoverEs discover = _TranslationsDiscoverEs._(_root); @override late final _TranslationsErrorsEs errors = _TranslationsErrorsEs._(_root); @override late final _TranslationsLibrariesEs libraries = _TranslationsLibrariesEs._(_root); @@ -78,11 +82,12 @@ class TranslationsEs with BaseTranslations implements T @override late final _TranslationsServerTasksEs serverTasks = _TranslationsServerTasksEs._(_root); @override late final _TranslationsTraktEs trakt = _TranslationsTraktEs._(_root); @override late final _TranslationsTrackersEs trackers = _TranslationsTrackersEs._(_root); + @override late final _TranslationsAddServerEs addServer = _TranslationsAddServerEs._(_root); } // Path: app -class _TranslationsAppEs implements TranslationsAppEn { - _TranslationsAppEs._(this._root); +class _TranslationsAppEs extends TranslationsAppEn { + _TranslationsAppEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppEs implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthEs implements TranslationsAuthEn { - _TranslationsAuthEs._(this._root); +class _TranslationsAuthEs extends TranslationsAuthEn { + _TranslationsAuthEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field // Translations + @override String get signIn => 'Iniciar sesión'; @override String get signInWithPlex => 'Inicia sesión con Plex'; @override String get showQRCode => 'Mostrar código QR'; @override String get authenticate => 'Autenticar'; @@ -104,11 +110,19 @@ class _TranslationsAuthEs implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Escanea este código QR para iniciar sesión'; @override String get waitingForAuth => 'Esperando autenticación...\nPor favor completa el inicio de sesión en 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 quickConnectCode => 'Código de Quick Connect'; + @override String get quickConnectInstructions => 'Abre tu servidor Jellyfin en un navegador web, inicia sesión y selecciona Quick Connect en el menú de usuario. Introduce este código para aprobar el inicio de sesión.'; + @override String get quickConnectWaiting => 'Esperando aprobación…'; + @override String get quickConnectCancel => 'Cancelar'; + @override String get quickConnectExpired => 'El código de Quick Connect caducó antes de ser aprobado. Inténtalo de nuevo.'; } // Path: common -class _TranslationsCommonEs implements TranslationsCommonEn { - _TranslationsCommonEs._(this._root); +class _TranslationsCommonEs extends TranslationsCommonEn { + _TranslationsCommonEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonEs implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensEs implements TranslationsScreensEn { - _TranslationsScreensEs._(this._root); +class _TranslationsScreensEs extends TranslationsScreensEn { + _TranslationsScreensEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensEs implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateEs implements TranslationsUpdateEn { - _TranslationsUpdateEs._(this._root); +class _TranslationsUpdateEs extends TranslationsUpdateEn { + _TranslationsUpdateEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateEs implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsEs implements TranslationsSettingsEn { - _TranslationsSettingsEs._(this._root); +class _TranslationsSettingsEs extends TranslationsSettingsEn { + _TranslationsSettingsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn { @override String get gridView => 'Cuadrícula'; @override String get listView => 'Lista'; @override String get showHeroSection => 'Mostrar Sección Destacada'; - @override String get useGlobalHubs => 'Usar Diseño de Inicio de Plex'; - @override String get useGlobalHubsDescription => 'Mostrar los hubs de la página de inicio como el cliente oficial de Plex. Cuando está desactivado, muestra recomendaciones por biblioteca en su lugar.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Mostrar Nombre del Servidor en los Hubs'; @override String get showServerNameOnHubsDescription => 'Mostrar siempre el nombre del servidor en los títulos de los hubs. Cuando está desactivado, solo se muestra para nombres de hubs duplicados.'; @override String get groupLibrariesByServer => 'Agrupar bibliotecas por servidor'; - @override String get groupLibrariesByServerDescription => 'Muestra un encabezado para cada servidor Plex en la barra lateral cuando estás conectado a varios servidores.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Mantener siempre la barra lateral abierta'; @override String get alwaysKeepSidebarOpenDescription => 'La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse'; @override String get showUnwatchedCount => 'Mostrar conteo de no vistos'; @@ -385,8 +399,8 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchEs implements TranslationsSearchEn { - _TranslationsSearchEs._(this._root); +class _TranslationsSearchEs extends TranslationsSearchEn { + _TranslationsSearchEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchEs implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysEs implements TranslationsHotkeysEn { - _TranslationsHotkeysEs._(this._root); +class _TranslationsHotkeysEs extends TranslationsHotkeysEn { + _TranslationsHotkeysEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysEs implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoEs implements TranslationsFileInfoEn { - _TranslationsFileInfoEs._(this._root); +class _TranslationsFileInfoEs extends TranslationsFileInfoEn { + _TranslationsFileInfoEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoEs implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuEs implements TranslationsMediaMenuEn { - _TranslationsMediaMenuEs._(this._root); +class _TranslationsMediaMenuEs extends TranslationsMediaMenuEn { + _TranslationsMediaMenuEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuEs implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityEs implements TranslationsAccessibilityEn { - _TranslationsAccessibilityEs._(this._root); +class _TranslationsAccessibilityEs extends TranslationsAccessibilityEn { + _TranslationsAccessibilityEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityEs implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsEs implements TranslationsTooltipsEn { - _TranslationsTooltipsEs._(this._root); +class _TranslationsTooltipsEs extends TranslationsTooltipsEn { + _TranslationsTooltipsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsEs implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn { - _TranslationsVideoControlsEs._(this._root); +class _TranslationsVideoControlsEs extends TranslationsVideoControlsEn { + _TranslationsVideoControlsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsEs implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusEs implements TranslationsUserStatusEn { - _TranslationsUserStatusEs._(this._root); +class _TranslationsUserStatusEs extends TranslationsUserStatusEn { + _TranslationsUserStatusEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusEs implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesEs implements TranslationsMessagesEn { - _TranslationsMessagesEs._(this._root); +class _TranslationsMessagesEs extends TranslationsMessagesEn { + _TranslationsMessagesEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesEs implements TranslationsMessagesEn { @override String get musicNotSupported => 'La reproducción de música aún no está soportada'; @override String get noDescriptionAvailable => 'No hay descripción disponible'; @override String get noProfilesAvailable => 'No hay perfiles disponibles'; - @override String get contactAdminForProfiles => 'Contacta con tu administrador de Plex para añadir perfiles'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'No se puede determinar la sección de biblioteca para este elemento'; @override String get logsCleared => 'Logs borrados'; @override String get logsCopied => 'Logs copiados al portapapeles'; @@ -636,8 +650,8 @@ class _TranslationsMessagesEs implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingEs implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingEs._(this._root); +class _TranslationsSubtitlingStylingEs extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingEs implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigEs implements TranslationsMpvConfigEn { - _TranslationsMpvConfigEs._(this._root); +class _TranslationsMpvConfigEs extends TranslationsMpvConfigEn { + _TranslationsMpvConfigEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigEs implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogEs implements TranslationsDialogEn { - _TranslationsDialogEs._(this._root); +class _TranslationsDialogEs extends TranslationsDialogEn { + _TranslationsDialogEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogEs implements TranslationsDialogEn { @override String get confirmAction => 'Confirmar Acción'; } +// Path: profiles +class _TranslationsProfilesEs extends TranslationsProfilesEn { + _TranslationsProfilesEs._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Añadir perfil de Plezy'; + @override String get switchingProfile => 'Cambiando de perfil…'; + @override String get deleteThisProfileTitle => '¿Eliminar este perfil?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} se eliminará. Las conexiones no se verán afectadas.'; + @override String get active => 'Activo'; + @override String get manage => 'Administrar'; + @override String get delete => 'Eliminar'; + @override String get signOut => 'Cerrar sesión'; + @override String get signOutPlexTitle => '¿Cerrar sesión de Plex?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} y todos los usuarios de Plex Home de esta cuenta se eliminarán de este dispositivo. Puedes volver a iniciar sesión en cualquier momento.'; + @override String get signedOutPlex => 'Sesión de Plex cerrada.'; + @override String get signOutFailed => 'Error al cerrar sesión.'; + @override String get sectionTitle => 'Perfiles'; + @override String get summarySingle => 'Añade perfiles para mezclar usuarios gestionados e identidades locales'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} perfiles · activo: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} perfiles'; + @override String get removeConnectionTitle => '¿Eliminar conexión?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} perderá acceso a ${connectionLabel}. La conexión seguirá disponible para otros perfiles.'; + @override String get deleteProfileTitle => '¿Eliminar perfil?'; + @override String deleteProfileMessage({required Object displayName}) => 'Esto elimina ${displayName} y todas sus conexiones de este dispositivo. Los servidores Plex/Jellyfin subyacentes no se ven afectados.'; + @override String get profileNameLabel => 'Nombre del perfil'; + @override String get pinProtectionLabel => 'Protección con PIN'; + @override String get pinManagedByPlex => 'PIN gestionado por Plex. Edita en plex.tv.'; + @override String get noPinSetEditOnPlex => 'Sin PIN establecido. Para requerir uno, edita el usuario Home en plex.tv.'; + @override String get setPin => 'Establecer PIN'; + @override String get connectionsLabel => 'Conexiones'; + @override String get add => 'Añadir'; + @override String get deleteProfileButton => 'Eliminar perfil'; + @override String get noConnectionsHint => 'Sin conexiones — añade una para usar este perfil.'; + @override String get plexHomeAccount => 'Cuenta Plex Home'; + @override String get connectionDefault => 'Predeterminada'; + @override String get makeDefault => 'Establecer como predeterminada'; + @override String get removeConnection => 'Eliminar'; + @override String borrowAddTo({required Object displayName}) => 'Añadir a ${displayName}'; + @override String get borrowExplain => 'Toma prestada una conexión de otro perfil. Los perfiles de origen protegidos con PIN piden el PIN antes de compartir.'; + @override String get borrowEmpty => 'Nada para tomar prestado todavía.'; + @override String get borrowEmptySubtitle => 'Conecta primero una cuenta Plex o un servidor Jellyfin a otro perfil y vuelve aquí.'; + @override String get newProfile => 'Nuevo perfil'; + @override String get profileNameHint => 'p. ej. Invitados, Niños, Sala familiar'; + @override String get pinProtectionOptional => 'Protección con PIN (opcional)'; + @override String get pinExplain => 'Se requiere un PIN de 4 dígitos para cambiar a este perfil. Barrera blanda — cualquiera que pueda borrar los datos de la app puede saltarla.'; + @override String get continueButton => 'Continuar'; + @override String get pinsDontMatch => 'Los PIN no coinciden'; +} + +// Path: connections +class _TranslationsConnectionsEs extends TranslationsConnectionsEn { + _TranslationsConnectionsEs._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Conexiones'; + @override String get addConnection => 'Añadir conexión'; + @override String get addConnectionSubtitleNoProfile => 'Inicia sesión con Plex o conecta un servidor de Jellyfin'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Añadir a ${displayName} — cuenta de Plex, servidor de Jellyfin o tomar prestado de otro perfil'; + @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'; +} + // Path: discover -class _TranslationsDiscoverEs implements TranslationsDiscoverEn { - _TranslationsDiscoverEs._(this._root); +class _TranslationsDiscoverEs extends TranslationsDiscoverEn { + _TranslationsDiscoverEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverEs implements TranslationsDiscoverEn { @override String get noContentAvailable => 'No hay contenido disponible'; @override String get addMediaToLibraries => 'Añade contenido a tus bibliotecas'; @override String get continueWatching => 'Seguir Viendo'; + @override String get nextUp => 'A continuación'; + @override String get recentlyAdded => 'Añadido recientemente'; @override String playEpisode({required Object season, required Object episode}) => 'T${season}E${episode}'; @override String get overview => 'Resumen'; @override String get cast => 'Reparto'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverEs implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsEs implements TranslationsErrorsEn { - _TranslationsErrorsEs._(this._root); +class _TranslationsErrorsEs extends TranslationsErrorsEn { + _TranslationsErrorsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Error en la búsqueda: ${error}'; @override String connectionTimeout({required Object context}) => 'Tiempo de conexión agotado al cargar ${context}'; - @override String get connectionFailed => 'No se pudo conectar con el servidor Plex'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Error al cargar ${context}: ${error}'; @override String get noClientAvailable => 'No hay cliente disponible'; @override String authenticationFailed({required Object error}) => 'Error de autenticación: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsEs implements TranslationsErrorsEn { @override String get invalidToken => 'Token no válido'; @override String failedToVerifyToken({required Object error}) => 'Error al verificar el token: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Error al cambiar al perfil ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Error al eliminar ${displayName}'; + @override String get failedToRate => 'No se pudo actualizar la calificación'; } // Path: libraries -class _TranslationsLibrariesEs implements TranslationsLibrariesEn { - _TranslationsLibrariesEs._(this._root); +class _TranslationsLibrariesEs extends TranslationsLibrariesEn { + _TranslationsLibrariesEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesEs implements TranslationsLibrariesEn { @override String get folders => 'carpetas'; @override late final _TranslationsLibrariesTabsEs tabs = _TranslationsLibrariesTabsEs._(_root); @override late final _TranslationsLibrariesGroupingsEs groupings = _TranslationsLibrariesGroupingsEs._(_root); + @override late final _TranslationsLibrariesFilterCategoriesEs filterCategories = _TranslationsLibrariesFilterCategoriesEs._(_root); + @override late final _TranslationsLibrariesSortLabelsEs sortLabels = _TranslationsLibrariesSortLabelsEs._(_root); } // Path: about -class _TranslationsAboutEs implements TranslationsAboutEn { - _TranslationsAboutEs._(this._root); +class _TranslationsAboutEs extends TranslationsAboutEn { + _TranslationsAboutEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutEs implements TranslationsAboutEn { @override String get title => 'Acerca de'; @override String get openSourceLicenses => 'Licencias de Código Abierto'; @override String versionLabel({required Object version}) => 'Versión ${version}'; - @override String get appDescription => 'Un cliente de Plex para Flutter'; + @override String get appDescription => 'Un cliente de Plex y Jellyfin para Flutter'; @override String get viewLicensesDescription => 'Ver licencias de librerías de terceros'; } // Path: serverSelection -class _TranslationsServerSelectionEs implements TranslationsServerSelectionEn { - _TranslationsServerSelectionEs._(this._root); +class _TranslationsServerSelectionEs extends TranslationsServerSelectionEn { + _TranslationsServerSelectionEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionEs implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailEs implements TranslationsHubDetailEn { - _TranslationsHubDetailEs._(this._root); +class _TranslationsHubDetailEs extends TranslationsHubDetailEn { + _TranslationsHubDetailEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailEs implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsEs implements TranslationsLogsEn { - _TranslationsLogsEs._(this._root); +class _TranslationsLogsEs extends TranslationsLogsEn { + _TranslationsLogsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsEs implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesEs implements TranslationsLicensesEn { - _TranslationsLicensesEs._(this._root); +class _TranslationsLicensesEs extends TranslationsLicensesEn { + _TranslationsLicensesEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesEs implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationEs implements TranslationsNavigationEn { - _TranslationsNavigationEs._(this._root); +class _TranslationsNavigationEs extends TranslationsNavigationEn { + _TranslationsNavigationEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationEs implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvEs implements TranslationsLiveTvEn { - _TranslationsLiveTvEs._(this._root); +class _TranslationsLiveTvEs extends TranslationsLiveTvEn { + _TranslationsLiveTvEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvEs implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsEs implements TranslationsCollectionsEn { - _TranslationsCollectionsEs._(this._root); +class _TranslationsCollectionsEs extends TranslationsCollectionsEn { + _TranslationsCollectionsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsEs implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsEs implements TranslationsPlaylistsEn { - _TranslationsPlaylistsEs._(this._root); +class _TranslationsPlaylistsEs extends TranslationsPlaylistsEn { + _TranslationsPlaylistsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsEs implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherEs implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherEs._(this._root); +class _TranslationsWatchTogetherEs extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherEs implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Salas recientes'; @override String get renameRoom => 'Renombrar sala'; @override String get removeRoom => 'Eliminar'; + @override String get guestSwitchUnavailable => 'No se pudo cambiar — servidor no disponible para sincronización'; + @override String get guestSwitchFailed => 'No se pudo cambiar — contenido no encontrado en este servidor'; } // Path: downloads -class _TranslationsDownloadsEs implements TranslationsDownloadsEn { - _TranslationsDownloadsEs._(this._root); +class _TranslationsDownloadsEs extends TranslationsDownloadsEn { + _TranslationsDownloadsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsEs implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Filtro de sincronización'; @override String get syncAllItems => 'Sincronizando todos los elementos'; @override String get syncUnwatchedItems => 'Sincronizando elementos no vistos'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Servidor: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Disponible'; + @override String get syncRuleOffline => 'Sin conexión'; + @override String get syncRuleSignInRequired => 'Se requiere iniciar sesión'; + @override String get syncRuleNotAvailableForProfile => 'No disponible para el perfil actual'; + @override String get syncRuleUnknownServer => 'Servidor desconocido'; @override String get syncRuleListCreated => 'Regla de sincronización creada'; } // Path: shaders -class _TranslationsShadersEs implements TranslationsShadersEn { - _TranslationsShadersEs._(this._root); +class _TranslationsShadersEs extends TranslationsShadersEn { + _TranslationsShadersEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersEs implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteEs implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteEs._(this._root); +class _TranslationsCompanionRemoteEs extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteEs implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsEs implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsEs._(this._root); +class _TranslationsVideoSettingsEs extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsEs implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerEs implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerEs._(this._root); +class _TranslationsExternalPlayerEs extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerEs implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditEs implements TranslationsMetadataEditEn { - _TranslationsMetadataEditEs._(this._root); +class _TranslationsMetadataEditEs extends TranslationsMetadataEditEn { + _TranslationsMetadataEditEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditEs implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenEs implements TranslationsMatchScreenEn { - _TranslationsMatchScreenEs._(this._root); +class _TranslationsMatchScreenEs extends TranslationsMatchScreenEn { + _TranslationsMatchScreenEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenEs implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksEs implements TranslationsServerTasksEn { - _TranslationsServerTasksEs._(this._root); +class _TranslationsServerTasksEs extends TranslationsServerTasksEn { + _TranslationsServerTasksEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksEs implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktEs implements TranslationsTraktEn { - _TranslationsTraktEs._(this._root); +class _TranslationsTraktEs extends TranslationsTraktEn { + _TranslationsTraktEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktEs implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersEs implements TranslationsTrackersEn { - _TranslationsTrackersEs._(this._root); +class _TranslationsTrackersEs extends TranslationsTrackersEn { + _TranslationsTrackersEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersEs implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterEs libraryFilter = _TranslationsTrackersLibraryFilterEs._(_root); } +// Path: addServer +class _TranslationsAddServerEs extends TranslationsAddServerEn { + _TranslationsAddServerEs._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Añadir servidor Jellyfin'; + @override String get jellyfinUrlIntro => 'Introduce la URL de tu servidor Jellyfin — p. ej. `https://jellyfin.example.com`. Podrás iniciar sesión después.'; + @override String get serverUrl => 'URL del servidor'; + @override String get findServer => 'Buscar servidor'; + @override String get username => 'Usuario'; + @override String get password => 'Contraseña'; + @override String get signIn => 'Iniciar sesión'; + @override String get change => 'Cambiar'; + @override String get required => 'Obligatorio'; + @override String couldNotReachServer({required Object error}) => 'No se pudo conectar con el servidor: ${error}'; + @override String signInFailed({required Object error}) => 'Error al iniciar sesión: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect ha fallado: ${error}'; + @override String get addPlexTitle => 'Iniciar sesión con Plex'; + @override String get plexAuthIntro => 'Elige cómo iniciar sesión en Plex. El flujo del navegador abre plex.tv, donde confirmas la conexión; la opción QR es práctica para TV o dispositivos remotos.'; + @override String get plexQRPrompt => 'Escanea este código QR para iniciar sesión.'; + @override String get waitingForPlexConfirmation => 'Esperando que plex.tv confirme tu inicio de sesión…'; + @override String get pinExpired => 'El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.'; + @override String get duplicatePlexAccount => 'Este dispositivo ya está conectado a una cuenta de Plex. Cierra sesión desde los ajustes para cambiar de cuenta.'; + @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 addConnectionIntroGlobal => 'Añade otro servidor multimedia. Puedes combinar cuentas de Plex y servidores Jellyfin — el contenido de cada backend conectado aparece junto en la pantalla de inicio.'; + @override String get addConnectionIntroScoped => 'Añade un servidor nuevo o toma prestado uno de otro perfil.'; + @override String get signInWithPlexCard => 'Iniciar sesión con Plex'; + @override String get signInWithPlexCardSubtitle => 'Autoriza este dispositivo en tu cuenta de Plex. Los servidores compartidos con la cuenta se añaden automáticamente.'; + @override String get signInWithPlexCardSubtitleScoped => 'Autoriza una nueva cuenta de Plex. Sus usuarios Home aparecen como perfiles.'; + @override String get connectToJellyfinCard => 'Conectar a Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Introduce la URL de tu servidor Jellyfin e inicia sesión con usuario + contraseña (Quick Connect próximamente).'; + @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 una conexión ya asociada a otro perfil. Los perfiles de origen protegidos con PIN piden el PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsEs implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsEs._(this._root); +class _TranslationsHotkeysActionsEs extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsEs implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsEs implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsEs._(this._root); +class _TranslationsVideoControlsPipErrorsEs extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsEs implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsEs implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsEs._(this._root); +class _TranslationsLibrariesTabsEs extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsEs implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsEs implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsEs._(this._root); +class _TranslationsLibrariesGroupingsEs extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsEs implements TranslationsLibrariesGrouping @override String get folders => 'Carpetas'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesEs extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesEs._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get genre => 'Género'; + @override String get year => 'Año'; + @override String get contentRating => 'Clasificación'; + @override String get tag => 'Etiqueta'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsEs extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsEs._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Título'; + @override String get dateAdded => 'Fecha de adición'; + @override String get releaseDate => 'Fecha de estreno'; + @override String get rating => 'Valoración'; + @override String get lastPlayed => 'Última reproducción'; + @override String get playCount => 'Reproducciones'; + @override String get random => 'Aleatorio'; + @override String get dateShared => 'Fecha de compartición'; + @override String get latestEpisodeAirDate => 'Última fecha de emisión del episodio'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionEs implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionEs._(this._root); +class _TranslationsCompanionRemoteSessionEs extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionEs implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingEs implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingEs._(this._root); +class _TranslationsCompanionRemotePairingEs extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingEs implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteEs implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteEs._(this._root); +class _TranslationsCompanionRemoteRemoteEs extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteEs implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesEs implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesEs._(this._root); +class _TranslationsTrackersServicesEs extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesEs implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeEs implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeEs._(this._root); +class _TranslationsTrackersDeviceCodeEs extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeEs implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyEs implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyEs._(this._root); +class _TranslationsTrackersOauthProxyEs extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyEs implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterEs implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterEs._(this._root); +class _TranslationsTrackersLibraryFilterEs extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterEs._(TranslationsEs root) : this._root = root, super.internal(root); final TranslationsEs _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsEs { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Iniciar sesión', 'auth.signInWithPlex' => 'Inicia sesión con Plex', 'auth.showQRCode' => 'Mostrar código QR', 'auth.authenticate' => 'Autenticar', @@ -1550,6 +1719,14 @@ extension on TranslationsEs { 'auth.scanQRToSignIn' => 'Escanea este código QR para iniciar sesión', 'auth.waitingForAuth' => 'Esperando autenticación...\nPor favor completa el inicio de sesión en tu navegador.', 'auth.useBrowser' => 'Usar navegador', + 'auth.or' => 'o', + 'auth.connectToJellyfin' => 'Conectar a Jellyfin', + 'auth.useQuickConnect' => 'Usar Quick Connect', + 'auth.quickConnectCode' => 'Código de Quick Connect', + 'auth.quickConnectInstructions' => 'Abre tu servidor Jellyfin en un navegador web, inicia sesión y selecciona Quick Connect en el menú de usuario. Introduce este código para aprobar el inicio de sesión.', + 'auth.quickConnectWaiting' => 'Esperando aprobación…', + 'auth.quickConnectCancel' => 'Cancelar', + 'auth.quickConnectExpired' => 'El código de Quick Connect caducó antes de ser aprobado. Inténtalo de nuevo.', 'common.cancel' => 'Cancelar', 'common.save' => 'Guardar', 'common.close' => 'Cerrar', @@ -1636,12 +1813,12 @@ extension on TranslationsEs { 'settings.gridView' => 'Cuadrícula', 'settings.listView' => 'Lista', 'settings.showHeroSection' => 'Mostrar Sección Destacada', - 'settings.useGlobalHubs' => 'Usar Diseño de Inicio de Plex', - 'settings.useGlobalHubsDescription' => 'Mostrar los hubs de la página de inicio como el cliente oficial de Plex. Cuando está desactivado, muestra recomendaciones por biblioteca en su lugar.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Mostrar Nombre del Servidor en los Hubs', 'settings.showServerNameOnHubsDescription' => 'Mostrar siempre el nombre del servidor en los títulos de los hubs. Cuando está desactivado, solo se muestra para nombres de hubs duplicados.', 'settings.groupLibrariesByServer' => 'Agrupar bibliotecas por servidor', - 'settings.groupLibrariesByServerDescription' => 'Muestra un encabezado para cada servidor Plex en la barra lateral cuando estás conectado a varios servidores.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Mantener siempre la barra lateral abierta', 'settings.alwaysKeepSidebarOpenDescription' => 'La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse', 'settings.showUnwatchedCount' => 'Mostrar conteo de no vistos', @@ -1962,7 +2139,7 @@ extension on TranslationsEs { 'messages.musicNotSupported' => 'La reproducción de música aún no está soportada', 'messages.noDescriptionAvailable' => 'No hay descripción disponible', 'messages.noProfilesAvailable' => 'No hay perfiles disponibles', - 'messages.contactAdminForProfiles' => 'Contacta con tu administrador de Plex para añadir perfiles', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'No se puede determinar la sección de biblioteca para este elemento', 'messages.logsCleared' => 'Logs borrados', 'messages.logsCopied' => 'Logs copiados al portapapeles', @@ -2016,11 +2193,65 @@ extension on TranslationsEs { 'mpvConfig.confirmDeletePreset' => '¿Estás seguro de que quieres eliminar este ajuste?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Confirmar Acción', + 'profiles.addPlezyProfile' => 'Añadir perfil de Plezy', + 'profiles.switchingProfile' => 'Cambiando de perfil…', + 'profiles.deleteThisProfileTitle' => '¿Eliminar este perfil?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} se eliminará. Las conexiones no se verán afectadas.', + 'profiles.active' => 'Activo', + 'profiles.manage' => 'Administrar', + 'profiles.delete' => 'Eliminar', + 'profiles.signOut' => 'Cerrar sesión', + 'profiles.signOutPlexTitle' => '¿Cerrar sesión de Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} y todos los usuarios de Plex Home de esta cuenta se eliminarán de este dispositivo. Puedes volver a iniciar sesión en cualquier momento.', + 'profiles.signedOutPlex' => 'Sesión de Plex cerrada.', + 'profiles.signOutFailed' => 'Error al cerrar sesión.', + 'profiles.sectionTitle' => 'Perfiles', + 'profiles.summarySingle' => 'Añade perfiles para mezclar usuarios gestionados e identidades locales', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} perfiles · activo: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} perfiles', + 'profiles.removeConnectionTitle' => '¿Eliminar conexión?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} perderá acceso a ${connectionLabel}. La conexión seguirá disponible para otros perfiles.', + 'profiles.deleteProfileTitle' => '¿Eliminar perfil?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Esto elimina ${displayName} y todas sus conexiones de este dispositivo. Los servidores Plex/Jellyfin subyacentes no se ven afectados.', + 'profiles.profileNameLabel' => 'Nombre del perfil', + 'profiles.pinProtectionLabel' => 'Protección con PIN', + 'profiles.pinManagedByPlex' => 'PIN gestionado por Plex. Edita en plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'Sin PIN establecido. Para requerir uno, edita el usuario Home en plex.tv.', + 'profiles.setPin' => 'Establecer PIN', + 'profiles.connectionsLabel' => 'Conexiones', + 'profiles.add' => 'Añadir', + 'profiles.deleteProfileButton' => 'Eliminar perfil', + 'profiles.noConnectionsHint' => 'Sin conexiones — añade una para usar este perfil.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Cuenta Plex Home', + 'profiles.connectionDefault' => 'Predeterminada', + 'profiles.makeDefault' => 'Establecer como predeterminada', + 'profiles.removeConnection' => 'Eliminar', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Añadir a ${displayName}', + 'profiles.borrowExplain' => 'Toma prestada una conexión de otro perfil. Los perfiles de origen protegidos con PIN piden el PIN antes de compartir.', + 'profiles.borrowEmpty' => 'Nada para tomar prestado todavía.', + 'profiles.borrowEmptySubtitle' => 'Conecta primero una cuenta Plex o un servidor Jellyfin a otro perfil y vuelve aquí.', + 'profiles.newProfile' => 'Nuevo perfil', + 'profiles.profileNameHint' => 'p. ej. Invitados, Niños, Sala familiar', + 'profiles.pinProtectionOptional' => 'Protección con PIN (opcional)', + 'profiles.pinExplain' => 'Se requiere un PIN de 4 dígitos para cambiar a este perfil. Barrera blanda — cualquiera que pueda borrar los datos de la app puede saltarla.', + 'profiles.continueButton' => 'Continuar', + 'profiles.pinsDontMatch' => 'Los PIN no coinciden', + 'connections.sectionTitle' => 'Conexiones', + 'connections.addConnection' => 'Añadir conexión', + 'connections.addConnectionSubtitleNoProfile' => 'Inicia sesión con Plex o conecta un servidor de Jellyfin', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Añadir a ${displayName} — cuenta de Plex, servidor de Jellyfin o tomar prestado de otro perfil', + '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', 'discover.title' => 'Descubrir', 'discover.switchProfile' => 'Cambiar Perfil', 'discover.noContentAvailable' => 'No hay contenido disponible', 'discover.addMediaToLibraries' => 'Añade contenido a tus bibliotecas', 'discover.continueWatching' => 'Seguir Viendo', + 'discover.nextUp' => 'A continuación', + 'discover.recentlyAdded' => 'Añadido recientemente', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'T${season}E${episode}', 'discover.overview' => 'Resumen', 'discover.cast' => 'Reparto', @@ -2032,7 +2263,7 @@ extension on TranslationsEs { 'discover.minutesLeft' => ({required Object minutes}) => 'quedan ${minutes} min', 'errors.searchFailed' => ({required Object error}) => 'Error en la búsqueda: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Tiempo de conexión agotado al cargar ${context}', - 'errors.connectionFailed' => 'No se pudo conectar con el servidor Plex', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Error al cargar ${context}: ${error}', 'errors.noClientAvailable' => 'No hay cliente disponible', 'errors.authenticationFailed' => ({required Object error}) => 'Error de autenticación: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsEs { 'errors.invalidToken' => 'Token no válido', 'errors.failedToVerifyToken' => ({required Object error}) => 'Error al verificar el token: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Error al cambiar al perfil ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Error al eliminar ${displayName}', + 'errors.failedToRate' => 'No se pudo actualizar la calificación', 'libraries.title' => 'Bibliotecas', 'libraries.scanLibraryFiles' => 'Escanear Archivos de la Biblioteca', 'libraries.scanLibrary' => 'Escanear Biblioteca', @@ -2054,8 +2287,6 @@ extension on TranslationsEs { 'libraries.analyzing' => ({required Object title}) => 'Analizando "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Análisis iniciado para "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Error al analizar la biblioteca: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'No se encontraron bibliotecas', 'libraries.allLibrariesHidden' => 'Todas las bibliotecas están ocultas', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Bibliotecas ocultas (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsEs { 'libraries.groupings.seasons' => 'Temporadas', 'libraries.groupings.episodes' => 'Episodios', 'libraries.groupings.folders' => 'Carpetas', + 'libraries.filterCategories.genre' => 'Género', + 'libraries.filterCategories.year' => 'Año', + 'libraries.filterCategories.contentRating' => 'Clasificación', + 'libraries.filterCategories.tag' => 'Etiqueta', + 'libraries.sortLabels.title' => 'Título', + 'libraries.sortLabels.dateAdded' => 'Fecha de adición', + 'libraries.sortLabels.releaseDate' => 'Fecha de estreno', + 'libraries.sortLabels.rating' => 'Valoración', + 'libraries.sortLabels.lastPlayed' => 'Última reproducción', + 'libraries.sortLabels.playCount' => 'Reproducciones', + 'libraries.sortLabels.random' => 'Aleatorio', + 'libraries.sortLabels.dateShared' => 'Fecha de compartición', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Última fecha de emisión del episodio', 'about.title' => 'Acerca de', 'about.openSourceLicenses' => 'Licencias de Código Abierto', 'about.versionLabel' => ({required Object version}) => 'Versión ${version}', - 'about.appDescription' => 'Un cliente de Plex para Flutter', + 'about.appDescription' => 'Un cliente de Plex y Jellyfin para Flutter', 'about.viewLicensesDescription' => 'Ver licencias de librerías de terceros', 'serverSelection.allServerConnectionsFailed' => 'No se pudo conectar con ningún servidor. Por favor, comprueba tu conexión e inténtalo de nuevo.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'No se encontraron servidores para ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsEs { 'watchTogether.recentRooms' => 'Salas recientes', 'watchTogether.renameRoom' => 'Renombrar sala', 'watchTogether.removeRoom' => 'Eliminar', + 'watchTogether.guestSwitchUnavailable' => 'No se pudo cambiar — servidor no disponible para sincronización', + 'watchTogether.guestSwitchFailed' => 'No se pudo cambiar — contenido no encontrado en este servidor', 'downloads.title' => 'Descargas', 'downloads.manage' => 'Gestionar', 'downloads.tvShows' => 'Series de TV', @@ -2291,6 +2537,12 @@ extension on TranslationsEs { 'downloads.editSyncFilter' => 'Filtro de sincronización', 'downloads.syncAllItems' => 'Sincronizando todos los elementos', 'downloads.syncUnwatchedItems' => 'Sincronizando elementos no vistos', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Servidor: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Disponible', + 'downloads.syncRuleOffline' => 'Sin conexión', + 'downloads.syncRuleSignInRequired' => 'Se requiere iniciar sesión', + 'downloads.syncRuleNotAvailableForProfile' => 'No disponible para el perfil actual', + 'downloads.syncRuleUnknownServer' => 'Servidor desconocido', 'downloads.syncRuleListCreated' => 'Regla de sincronización creada', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Sin mejora de video', @@ -2484,6 +2736,8 @@ extension on TranslationsEs { 'trakt.disconnectConfirmBody' => 'Plezy dejará de enviar eventos de reproducción a Trakt. Puedes volver a conectar cuando quieras.', 'trakt.scrobble' => 'Scrobbling en tiempo real', 'trakt.scrobbleDescription' => 'Enviar eventos de reproducción, pausa y parada a Trakt durante la reproducción.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Sincronizar estado de visto', 'trakt.watchedSyncDescription' => 'Cuando marques elementos como vistos en Plezy, también se marcarán en Trakt.', 'trackers.title' => 'Rastreadores', @@ -2519,6 +2773,38 @@ extension on TranslationsEs { 'trackers.libraryFilter.modeHintWhitelist' => 'Sincronizar solo las bibliotecas marcadas abajo.', 'trackers.libraryFilter.libraries' => 'Bibliotecas', 'trackers.libraryFilter.noLibraries' => 'No hay bibliotecas disponibles', + 'addServer.addJellyfinTitle' => 'Añadir servidor Jellyfin', + 'addServer.jellyfinUrlIntro' => 'Introduce la URL de tu servidor Jellyfin — p. ej. `https://jellyfin.example.com`. Podrás iniciar sesión después.', + 'addServer.serverUrl' => 'URL del servidor', + 'addServer.findServer' => 'Buscar servidor', + 'addServer.username' => 'Usuario', + 'addServer.password' => 'Contraseña', + 'addServer.signIn' => 'Iniciar sesión', + 'addServer.change' => 'Cambiar', + 'addServer.required' => 'Obligatorio', + 'addServer.couldNotReachServer' => ({required Object error}) => 'No se pudo conectar con el servidor: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Error al iniciar sesión: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect ha fallado: ${error}', + 'addServer.addPlexTitle' => 'Iniciar sesión con Plex', + 'addServer.plexAuthIntro' => 'Elige cómo iniciar sesión en Plex. El flujo del navegador abre plex.tv, donde confirmas la conexión; la opción QR es práctica para TV o dispositivos remotos.', + 'addServer.plexQRPrompt' => 'Escanea este código QR para iniciar sesión.', + 'addServer.waitingForPlexConfirmation' => 'Esperando que plex.tv confirme tu inicio de sesión…', + 'addServer.pinExpired' => 'El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.', + 'addServer.duplicatePlexAccount' => 'Este dispositivo ya está conectado a una cuenta de Plex. Cierra sesión desde los ajustes para cambiar de cuenta.', + '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.addConnectionIntroGlobal' => 'Añade otro servidor multimedia. Puedes combinar cuentas de Plex y servidores Jellyfin — el contenido de cada backend conectado aparece junto en la pantalla de inicio.', + 'addServer.addConnectionIntroScoped' => 'Añade un servidor nuevo o toma prestado uno de otro perfil.', + 'addServer.signInWithPlexCard' => 'Iniciar sesión con Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Autoriza este dispositivo en tu cuenta de Plex. Los servidores compartidos con la cuenta se añaden automáticamente.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Autoriza una nueva cuenta de Plex. Sus usuarios Home aparecen como perfiles.', + 'addServer.connectToJellyfinCard' => 'Conectar a Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Introduce la URL de tu servidor Jellyfin e inicia sesión con usuario + contraseña (Quick Connect próximamente).', + '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 una conexión ya asociada a otro perfil. Los perfiles de origen protegidos con PIN piden el PIN.', _ => null, }; } diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 0ff422b3..ac1a1f63 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsFr with BaseTranslations implements Translations { +class TranslationsFr extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsFr({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsFr with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsFr with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsFr _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsFr with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingFr subtitlingStyling = _TranslationsSubtitlingStylingFr._(_root); @override late final _TranslationsMpvConfigFr mpvConfig = _TranslationsMpvConfigFr._(_root); @override late final _TranslationsDialogFr dialog = _TranslationsDialogFr._(_root); + @override late final _TranslationsProfilesFr profiles = _TranslationsProfilesFr._(_root); + @override late final _TranslationsConnectionsFr connections = _TranslationsConnectionsFr._(_root); @override late final _TranslationsDiscoverFr discover = _TranslationsDiscoverFr._(_root); @override late final _TranslationsErrorsFr errors = _TranslationsErrorsFr._(_root); @override late final _TranslationsLibrariesFr libraries = _TranslationsLibrariesFr._(_root); @@ -78,11 +82,12 @@ class TranslationsFr with BaseTranslations implements T @override late final _TranslationsServerTasksFr serverTasks = _TranslationsServerTasksFr._(_root); @override late final _TranslationsTraktFr trakt = _TranslationsTraktFr._(_root); @override late final _TranslationsTrackersFr trackers = _TranslationsTrackersFr._(_root); + @override late final _TranslationsAddServerFr addServer = _TranslationsAddServerFr._(_root); } // Path: app -class _TranslationsAppFr implements TranslationsAppEn { - _TranslationsAppFr._(this._root); +class _TranslationsAppFr extends TranslationsAppEn { + _TranslationsAppFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppFr implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthFr implements TranslationsAuthEn { - _TranslationsAuthFr._(this._root); +class _TranslationsAuthFr extends TranslationsAuthEn { + _TranslationsAuthFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field // Translations + @override String get signIn => 'Se connecter'; @override String get signInWithPlex => 'S\'inscrire avec Plex'; @override String get showQRCode => 'Afficher le QR Code'; @override String get authenticate => 'S\'authentifier'; @@ -104,11 +110,19 @@ class _TranslationsAuthFr implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Scannez ce QR code pour vous connecter'; @override String get waitingForAuth => 'En attente d\'authentification...\nVeuillez vous connecter dans 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 quickConnectCode => 'Code Quick Connect'; + @override String get quickConnectInstructions => 'Ouvrez votre serveur Jellyfin dans un navigateur, connectez-vous et choisissez Quick Connect dans le menu utilisateur. Saisissez ce code pour valider la connexion.'; + @override String get quickConnectWaiting => 'En attente d\'approbation…'; + @override String get quickConnectCancel => 'Annuler'; + @override String get quickConnectExpired => 'Le code Quick Connect a expiré avant l\'approbation. Veuillez réessayer.'; } // Path: common -class _TranslationsCommonFr implements TranslationsCommonEn { - _TranslationsCommonFr._(this._root); +class _TranslationsCommonFr extends TranslationsCommonEn { + _TranslationsCommonFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonFr implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensFr implements TranslationsScreensEn { - _TranslationsScreensFr._(this._root); +class _TranslationsScreensFr extends TranslationsScreensEn { + _TranslationsScreensFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensFr implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateFr implements TranslationsUpdateEn { - _TranslationsUpdateFr._(this._root); +class _TranslationsUpdateFr extends TranslationsUpdateEn { + _TranslationsUpdateFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateFr implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsFr implements TranslationsSettingsEn { - _TranslationsSettingsFr._(this._root); +class _TranslationsSettingsFr extends TranslationsSettingsEn { + _TranslationsSettingsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn { @override String get gridView => 'Grille'; @override String get listView => 'Liste'; @override String get showHeroSection => 'Afficher la section Hero'; - @override String get useGlobalHubs => 'Utiliser la disposition Plex Home'; - @override String get useGlobalHubsDescription => 'Afficher les hubs de la page d\'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Afficher le nom du serveur sur les hubs'; @override String get showServerNameOnHubsDescription => 'Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s\'affichent.'; @override String get groupLibrariesByServer => 'Grouper les bibliothèques par serveur'; - @override String get groupLibrariesByServerDescription => 'Affiche un en-tête pour chaque serveur Plex dans la barre latérale lorsque vous êtes connecté à plusieurs serveurs.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Toujours garder la barre latérale ouverte'; @override String get alwaysKeepSidebarOpenDescription => 'La barre latérale reste étendue et la zone de contenu s\'adapte'; @override String get showUnwatchedCount => 'Afficher le nombre non visionné'; @@ -385,8 +399,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchFr implements TranslationsSearchEn { - _TranslationsSearchFr._(this._root); +class _TranslationsSearchFr extends TranslationsSearchEn { + _TranslationsSearchFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchFr implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysFr implements TranslationsHotkeysEn { - _TranslationsHotkeysFr._(this._root); +class _TranslationsHotkeysFr extends TranslationsHotkeysEn { + _TranslationsHotkeysFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysFr implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoFr implements TranslationsFileInfoEn { - _TranslationsFileInfoFr._(this._root); +class _TranslationsFileInfoFr extends TranslationsFileInfoEn { + _TranslationsFileInfoFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoFr implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuFr implements TranslationsMediaMenuEn { - _TranslationsMediaMenuFr._(this._root); +class _TranslationsMediaMenuFr extends TranslationsMediaMenuEn { + _TranslationsMediaMenuFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuFr implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityFr implements TranslationsAccessibilityEn { - _TranslationsAccessibilityFr._(this._root); +class _TranslationsAccessibilityFr extends TranslationsAccessibilityEn { + _TranslationsAccessibilityFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityFr implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsFr implements TranslationsTooltipsEn { - _TranslationsTooltipsFr._(this._root); +class _TranslationsTooltipsFr extends TranslationsTooltipsEn { + _TranslationsTooltipsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsFr implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn { - _TranslationsVideoControlsFr._(this._root); +class _TranslationsVideoControlsFr extends TranslationsVideoControlsEn { + _TranslationsVideoControlsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsFr implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusFr implements TranslationsUserStatusEn { - _TranslationsUserStatusFr._(this._root); +class _TranslationsUserStatusFr extends TranslationsUserStatusEn { + _TranslationsUserStatusFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusFr implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesFr implements TranslationsMessagesEn { - _TranslationsMessagesFr._(this._root); +class _TranslationsMessagesFr extends TranslationsMessagesEn { + _TranslationsMessagesFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesFr implements TranslationsMessagesEn { @override String get musicNotSupported => 'La lecture de musique n\'est pas encore prise en charge'; @override String get noDescriptionAvailable => 'Aucune description disponible'; @override String get noProfilesAvailable => 'Aucun profil disponible'; - @override String get contactAdminForProfiles => 'Contactez votre administrateur Plex pour ajouter des profils'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Impossible de déterminer la section de la bibliothèque pour cet élément'; @override String get logsCleared => 'Logs effacés'; @override String get logsCopied => 'Logs copiés dans le presse-papier'; @@ -636,8 +650,8 @@ class _TranslationsMessagesFr implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingFr implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingFr._(this._root); +class _TranslationsSubtitlingStylingFr extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingFr implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigFr implements TranslationsMpvConfigEn { - _TranslationsMpvConfigFr._(this._root); +class _TranslationsMpvConfigFr extends TranslationsMpvConfigEn { + _TranslationsMpvConfigFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigFr implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogFr implements TranslationsDialogEn { - _TranslationsDialogFr._(this._root); +class _TranslationsDialogFr extends TranslationsDialogEn { + _TranslationsDialogFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogFr implements TranslationsDialogEn { @override String get confirmAction => 'Confirmer l\'action'; } +// Path: profiles +class _TranslationsProfilesFr extends TranslationsProfilesEn { + _TranslationsProfilesFr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Ajouter un profil Plezy'; + @override String get switchingProfile => 'Changement de profil…'; + @override String get deleteThisProfileTitle => 'Supprimer ce profil ?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} sera supprimé. Les connexions ne seront pas affectées.'; + @override String get active => 'Actif'; + @override String get manage => 'Gérer'; + @override String get delete => 'Supprimer'; + @override String get signOut => 'Se déconnecter'; + @override String get signOutPlexTitle => 'Se déconnecter de Plex ?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} et tous les utilisateurs Plex Home de ce compte seront supprimés de cet appareil. Vous pouvez vous reconnecter à tout moment.'; + @override String get signedOutPlex => 'Déconnecté de Plex.'; + @override String get signOutFailed => 'Échec de la déconnexion.'; + @override String get sectionTitle => 'Profils'; + @override String get summarySingle => 'Ajoutez des profils pour mélanger utilisateurs gérés et identités locales'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} profils · actif : ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} profils'; + @override String get removeConnectionTitle => 'Retirer la connexion ?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} perdra l\'accès à ${connectionLabel}. La connexion reste disponible pour les autres profils.'; + @override String get deleteProfileTitle => 'Supprimer le profil ?'; + @override String deleteProfileMessage({required Object displayName}) => 'Cela supprime ${displayName} et toutes ses connexions de cet appareil. Les serveurs Plex/Jellyfin sous-jacents ne sont pas affectés.'; + @override String get profileNameLabel => 'Nom du profil'; + @override String get pinProtectionLabel => 'Protection par code PIN'; + @override String get pinManagedByPlex => 'PIN géré par Plex. Modifier sur plex.tv.'; + @override String get noPinSetEditOnPlex => 'Aucun PIN défini. Pour en exiger un, modifiez l\'utilisateur Home sur plex.tv.'; + @override String get setPin => 'Définir un PIN'; + @override String get connectionsLabel => 'Connexions'; + @override String get add => 'Ajouter'; + @override String get deleteProfileButton => 'Supprimer le profil'; + @override String get noConnectionsHint => 'Aucune connexion — ajoutez-en une pour utiliser ce profil.'; + @override String get plexHomeAccount => 'Compte Plex Home'; + @override String get connectionDefault => 'Par défaut'; + @override String get makeDefault => 'Définir par défaut'; + @override String get removeConnection => 'Retirer'; + @override String borrowAddTo({required Object displayName}) => 'Ajouter à ${displayName}'; + @override String get borrowExplain => 'Empruntez une connexion à un autre profil. Les profils sources protégés par PIN demandent le PIN avant de partager.'; + @override String get borrowEmpty => 'Rien à emprunter pour le moment.'; + @override String get borrowEmptySubtitle => 'Connectez d\'abord un compte Plex ou un serveur Jellyfin à un autre profil, puis revenez ici.'; + @override String get newProfile => 'Nouveau profil'; + @override String get profileNameHint => 'ex. Invités, Enfants, Salon familial'; + @override String get pinProtectionOptional => 'Protection par PIN (optionnelle)'; + @override String get pinExplain => 'PIN à 4 chiffres requis pour basculer sur ce profil. Barrière souple — quiconque peut effacer les données de l\'app peut la contourner.'; + @override String get continueButton => 'Continuer'; + @override String get pinsDontMatch => 'Les PIN ne correspondent pas'; +} + +// Path: connections +class _TranslationsConnectionsFr extends TranslationsConnectionsEn { + _TranslationsConnectionsFr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Connexions'; + @override String get addConnection => 'Ajouter une connexion'; + @override String get addConnectionSubtitleNoProfile => 'Connectez-vous avec Plex ou connectez un serveur Jellyfin'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Ajouter à ${displayName} — compte Plex, serveur Jellyfin ou emprunter à un autre profil'; + @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'; +} + // Path: discover -class _TranslationsDiscoverFr implements TranslationsDiscoverEn { - _TranslationsDiscoverFr._(this._root); +class _TranslationsDiscoverFr extends TranslationsDiscoverEn { + _TranslationsDiscoverFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverFr implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Aucun contenu disponible'; @override String get addMediaToLibraries => 'Ajoutez des médias à votre bibliothèque'; @override String get continueWatching => 'Continuer à regarder'; + @override String get nextUp => 'À suivre'; + @override String get recentlyAdded => 'Récemment ajouté'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Aperçu'; @override String get cast => 'Cast'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverFr implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsFr implements TranslationsErrorsEn { - _TranslationsErrorsFr._(this._root); +class _TranslationsErrorsFr extends TranslationsErrorsEn { + _TranslationsErrorsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Recherche échouée: ${error}'; @override String connectionTimeout({required Object context}) => 'Délai d\'attente de connexion dépassé pendant le chargement ${context}'; - @override String get connectionFailed => 'Impossible de se connecter au serveur Plex'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Échec du chargement ${context}: ${error}'; @override String get noClientAvailable => 'Aucun client disponible'; @override String authenticationFailed({required Object error}) => 'Échec de l\'authentification: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsFr implements TranslationsErrorsEn { @override String get invalidToken => 'Token invalide'; @override String failedToVerifyToken({required Object error}) => 'Échec de la vérification du token: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Impossible de changer de profil vers ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Impossible de supprimer ${displayName}'; + @override String get failedToRate => 'Impossible de mettre à jour la note'; } // Path: libraries -class _TranslationsLibrariesFr implements TranslationsLibrariesEn { - _TranslationsLibrariesFr._(this._root); +class _TranslationsLibrariesFr extends TranslationsLibrariesEn { + _TranslationsLibrariesFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesFr implements TranslationsLibrariesEn { @override String get folders => 'dossiers'; @override late final _TranslationsLibrariesTabsFr tabs = _TranslationsLibrariesTabsFr._(_root); @override late final _TranslationsLibrariesGroupingsFr groupings = _TranslationsLibrariesGroupingsFr._(_root); + @override late final _TranslationsLibrariesFilterCategoriesFr filterCategories = _TranslationsLibrariesFilterCategoriesFr._(_root); + @override late final _TranslationsLibrariesSortLabelsFr sortLabels = _TranslationsLibrariesSortLabelsFr._(_root); } // Path: about -class _TranslationsAboutFr implements TranslationsAboutEn { - _TranslationsAboutFr._(this._root); +class _TranslationsAboutFr extends TranslationsAboutEn { + _TranslationsAboutFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutFr implements TranslationsAboutEn { @override String get title => 'À propos'; @override String get openSourceLicenses => 'Licences Open Source'; @override String versionLabel({required Object version}) => 'Version ${version}'; - @override String get appDescription => 'Un magnifique client Plex pour Flutter'; + @override String get appDescription => 'Un magnifique client Plex et Jellyfin pour Flutter'; @override String get viewLicensesDescription => 'Afficher les licences des bibliothèques tierces'; } // Path: serverSelection -class _TranslationsServerSelectionFr implements TranslationsServerSelectionEn { - _TranslationsServerSelectionFr._(this._root); +class _TranslationsServerSelectionFr extends TranslationsServerSelectionEn { + _TranslationsServerSelectionFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionFr implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailFr implements TranslationsHubDetailEn { - _TranslationsHubDetailFr._(this._root); +class _TranslationsHubDetailFr extends TranslationsHubDetailEn { + _TranslationsHubDetailFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailFr implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsFr implements TranslationsLogsEn { - _TranslationsLogsFr._(this._root); +class _TranslationsLogsFr extends TranslationsLogsEn { + _TranslationsLogsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsFr implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesFr implements TranslationsLicensesEn { - _TranslationsLicensesFr._(this._root); +class _TranslationsLicensesFr extends TranslationsLicensesEn { + _TranslationsLicensesFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesFr implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationFr implements TranslationsNavigationEn { - _TranslationsNavigationFr._(this._root); +class _TranslationsNavigationFr extends TranslationsNavigationEn { + _TranslationsNavigationFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationFr implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvFr implements TranslationsLiveTvEn { - _TranslationsLiveTvFr._(this._root); +class _TranslationsLiveTvFr extends TranslationsLiveTvEn { + _TranslationsLiveTvFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvFr implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsFr implements TranslationsCollectionsEn { - _TranslationsCollectionsFr._(this._root); +class _TranslationsCollectionsFr extends TranslationsCollectionsEn { + _TranslationsCollectionsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsFr implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsFr implements TranslationsPlaylistsEn { - _TranslationsPlaylistsFr._(this._root); +class _TranslationsPlaylistsFr extends TranslationsPlaylistsEn { + _TranslationsPlaylistsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsFr implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherFr implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherFr._(this._root); +class _TranslationsWatchTogetherFr extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherFr implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Salons récents'; @override String get renameRoom => 'Renommer le salon'; @override String get removeRoom => 'Supprimer'; + @override String get guestSwitchUnavailable => 'Impossible de changer — serveur indisponible pour la synchronisation'; + @override String get guestSwitchFailed => 'Impossible de changer — contenu introuvable sur ce serveur'; } // Path: downloads -class _TranslationsDownloadsFr implements TranslationsDownloadsEn { - _TranslationsDownloadsFr._(this._root); +class _TranslationsDownloadsFr extends TranslationsDownloadsEn { + _TranslationsDownloadsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsFr implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Filtre de synchronisation'; @override String get syncAllItems => 'Synchronisation de tous les éléments'; @override String get syncUnwatchedItems => 'Synchronisation des éléments non vus'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Serveur : ${server} • ${status}'; + @override String get syncRuleAvailable => 'Disponible'; + @override String get syncRuleOffline => 'Hors ligne'; + @override String get syncRuleSignInRequired => 'Connexion requise'; + @override String get syncRuleNotAvailableForProfile => 'Non disponible pour le profil actuel'; + @override String get syncRuleUnknownServer => 'Serveur inconnu'; @override String get syncRuleListCreated => 'Règle de synchronisation créée'; } // Path: shaders -class _TranslationsShadersFr implements TranslationsShadersEn { - _TranslationsShadersFr._(this._root); +class _TranslationsShadersFr extends TranslationsShadersEn { + _TranslationsShadersFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersFr implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteFr implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteFr._(this._root); +class _TranslationsCompanionRemoteFr extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteFr implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsFr implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsFr._(this._root); +class _TranslationsVideoSettingsFr extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsFr implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerFr implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerFr._(this._root); +class _TranslationsExternalPlayerFr extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerFr implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditFr implements TranslationsMetadataEditEn { - _TranslationsMetadataEditFr._(this._root); +class _TranslationsMetadataEditFr extends TranslationsMetadataEditEn { + _TranslationsMetadataEditFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditFr implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenFr implements TranslationsMatchScreenEn { - _TranslationsMatchScreenFr._(this._root); +class _TranslationsMatchScreenFr extends TranslationsMatchScreenEn { + _TranslationsMatchScreenFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenFr implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksFr implements TranslationsServerTasksEn { - _TranslationsServerTasksFr._(this._root); +class _TranslationsServerTasksFr extends TranslationsServerTasksEn { + _TranslationsServerTasksFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksFr implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktFr implements TranslationsTraktEn { - _TranslationsTraktFr._(this._root); +class _TranslationsTraktFr extends TranslationsTraktEn { + _TranslationsTraktFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktFr implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersFr implements TranslationsTrackersEn { - _TranslationsTrackersFr._(this._root); +class _TranslationsTrackersFr extends TranslationsTrackersEn { + _TranslationsTrackersFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersFr implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterFr libraryFilter = _TranslationsTrackersLibraryFilterFr._(_root); } +// Path: addServer +class _TranslationsAddServerFr extends TranslationsAddServerEn { + _TranslationsAddServerFr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Ajouter un serveur Jellyfin'; + @override String get jellyfinUrlIntro => 'Saisissez l\'URL de votre serveur Jellyfin — p. ex. `https://jellyfin.example.com`. Vous pourrez vous connecter ensuite.'; + @override String get serverUrl => 'URL du serveur'; + @override String get findServer => 'Rechercher un serveur'; + @override String get username => 'Nom d\'utilisateur'; + @override String get password => 'Mot de passe'; + @override String get signIn => 'Se connecter'; + @override String get change => 'Modifier'; + @override String get required => 'Requis'; + @override String couldNotReachServer({required Object error}) => 'Impossible de joindre le serveur : ${error}'; + @override String signInFailed({required Object error}) => 'Échec de la connexion : ${error}'; + @override String quickConnectFailed({required Object error}) => 'Échec de Quick Connect : ${error}'; + @override String get addPlexTitle => 'Se connecter avec Plex'; + @override String get plexAuthIntro => 'Choisissez comment vous connecter à Plex. Le flux navigateur ouvre plex.tv où vous confirmez la connexion ; l\'option QR est pratique pour la TV ou les appareils distants.'; + @override String get plexQRPrompt => 'Scannez ce QR code pour vous connecter.'; + @override String get waitingForPlexConfirmation => 'En attente de la confirmation de plex.tv…'; + @override String get pinExpired => 'Le PIN a expiré avant la connexion. Veuillez réessayer.'; + @override String get duplicatePlexAccount => 'Cet appareil est déjà connecté à un compte Plex. Déconnectez-vous depuis les paramètres pour changer de compte.'; + @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 addConnectionIntroGlobal => 'Ajoutez un autre serveur média. Vous pouvez mélanger comptes Plex et serveurs Jellyfin — les contenus de tous les backends connectés apparaissent ensemble sur l\'écran d\'accueil.'; + @override String get addConnectionIntroScoped => 'Ajoutez un nouveau serveur, ou empruntez-en un à un autre profil.'; + @override String get signInWithPlexCard => 'Se connecter avec Plex'; + @override String get signInWithPlexCardSubtitle => 'Autorisez cet appareil avec votre compte Plex. Les serveurs partagés avec le compte suivent automatiquement.'; + @override String get signInWithPlexCardSubtitleScoped => 'Autorisez un nouveau compte Plex. Ses utilisateurs Home apparaissent comme profils.'; + @override String get connectToJellyfinCard => 'Se connecter à Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Saisissez l\'URL de votre serveur Jellyfin et connectez-vous avec nom d\'utilisateur + mot de passe (Quick Connect bientôt disponible).'; + @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Connectez-vous à un serveur Jellyfin. Lié à ${name}.'; + @override String get borrowFromAnotherProfile => 'Emprunter à un autre profil'; + @override String get borrowFromAnotherProfileSubtitle => 'Réutilisez une connexion déjà associée à un autre profil. Les profils source protégés par PIN demandent le PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsFr implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsFr._(this._root); +class _TranslationsHotkeysActionsFr extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsFr implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsFr implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsFr._(this._root); +class _TranslationsVideoControlsPipErrorsFr extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsFr implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsFr implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsFr._(this._root); +class _TranslationsLibrariesTabsFr extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsFr implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsFr implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsFr._(this._root); +class _TranslationsLibrariesGroupingsFr extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsFr implements TranslationsLibrariesGrouping @override String get folders => 'Dossiers'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesFr extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesFr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get genre => 'Genre'; + @override String get year => 'Année'; + @override String get contentRating => 'Classification'; + @override String get tag => 'Tag'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsFr extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsFr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Titre'; + @override String get dateAdded => 'Date d\'ajout'; + @override String get releaseDate => 'Date de sortie'; + @override String get rating => 'Note'; + @override String get lastPlayed => 'Dernière lecture'; + @override String get playCount => 'Lectures'; + @override String get random => 'Aléatoire'; + @override String get dateShared => 'Date de partage'; + @override String get latestEpisodeAirDate => 'Dernière date de diffusion'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionFr implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionFr._(this._root); +class _TranslationsCompanionRemoteSessionFr extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionFr implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingFr implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingFr._(this._root); +class _TranslationsCompanionRemotePairingFr extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingFr implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteFr implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteFr._(this._root); +class _TranslationsCompanionRemoteRemoteFr extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteFr implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesFr implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesFr._(this._root); +class _TranslationsTrackersServicesFr extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesFr implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeFr implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeFr._(this._root); +class _TranslationsTrackersDeviceCodeFr extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeFr implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyFr implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyFr._(this._root); +class _TranslationsTrackersOauthProxyFr extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyFr implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterFr implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterFr._(this._root); +class _TranslationsTrackersLibraryFilterFr extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterFr._(TranslationsFr root) : this._root = root, super.internal(root); final TranslationsFr _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsFr { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Se connecter', 'auth.signInWithPlex' => 'S\'inscrire avec Plex', 'auth.showQRCode' => 'Afficher le QR Code', 'auth.authenticate' => 'S\'authentifier', @@ -1550,6 +1719,14 @@ extension on TranslationsFr { 'auth.scanQRToSignIn' => 'Scannez ce QR code pour vous connecter', 'auth.waitingForAuth' => 'En attente d\'authentification...\nVeuillez vous connecter dans votre navigateur.', 'auth.useBrowser' => 'Utiliser le navigateur', + 'auth.or' => 'ou', + 'auth.connectToJellyfin' => 'Se connecter à Jellyfin', + 'auth.useQuickConnect' => 'Utiliser Quick Connect', + 'auth.quickConnectCode' => 'Code Quick Connect', + 'auth.quickConnectInstructions' => 'Ouvrez votre serveur Jellyfin dans un navigateur, connectez-vous et choisissez Quick Connect dans le menu utilisateur. Saisissez ce code pour valider la connexion.', + 'auth.quickConnectWaiting' => 'En attente d\'approbation…', + 'auth.quickConnectCancel' => 'Annuler', + 'auth.quickConnectExpired' => 'Le code Quick Connect a expiré avant l\'approbation. Veuillez réessayer.', 'common.cancel' => 'Annuler', 'common.save' => 'Sauvegarder', 'common.close' => 'Fermer', @@ -1636,12 +1813,12 @@ extension on TranslationsFr { 'settings.gridView' => 'Grille', 'settings.listView' => 'Liste', 'settings.showHeroSection' => 'Afficher la section Hero', - 'settings.useGlobalHubs' => 'Utiliser la disposition Plex Home', - 'settings.useGlobalHubsDescription' => 'Afficher les hubs de la page d\'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Afficher le nom du serveur sur les hubs', 'settings.showServerNameOnHubsDescription' => 'Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s\'affichent.', 'settings.groupLibrariesByServer' => 'Grouper les bibliothèques par serveur', - 'settings.groupLibrariesByServerDescription' => 'Affiche un en-tête pour chaque serveur Plex dans la barre latérale lorsque vous êtes connecté à plusieurs serveurs.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Toujours garder la barre latérale ouverte', 'settings.alwaysKeepSidebarOpenDescription' => 'La barre latérale reste étendue et la zone de contenu s\'adapte', 'settings.showUnwatchedCount' => 'Afficher le nombre non visionné', @@ -1962,7 +2139,7 @@ extension on TranslationsFr { 'messages.musicNotSupported' => 'La lecture de musique n\'est pas encore prise en charge', 'messages.noDescriptionAvailable' => 'Aucune description disponible', 'messages.noProfilesAvailable' => 'Aucun profil disponible', - 'messages.contactAdminForProfiles' => 'Contactez votre administrateur Plex pour ajouter des profils', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Impossible de déterminer la section de la bibliothèque pour cet élément', 'messages.logsCleared' => 'Logs effacés', 'messages.logsCopied' => 'Logs copiés dans le presse-papier', @@ -2016,11 +2193,65 @@ extension on TranslationsFr { 'mpvConfig.confirmDeletePreset' => 'Êtes-vous sûr de vouloir supprimer ce préréglage ?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Confirmer l\'action', + 'profiles.addPlezyProfile' => 'Ajouter un profil Plezy', + 'profiles.switchingProfile' => 'Changement de profil…', + 'profiles.deleteThisProfileTitle' => 'Supprimer ce profil ?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} sera supprimé. Les connexions ne seront pas affectées.', + 'profiles.active' => 'Actif', + 'profiles.manage' => 'Gérer', + 'profiles.delete' => 'Supprimer', + 'profiles.signOut' => 'Se déconnecter', + 'profiles.signOutPlexTitle' => 'Se déconnecter de Plex ?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} et tous les utilisateurs Plex Home de ce compte seront supprimés de cet appareil. Vous pouvez vous reconnecter à tout moment.', + 'profiles.signedOutPlex' => 'Déconnecté de Plex.', + 'profiles.signOutFailed' => 'Échec de la déconnexion.', + 'profiles.sectionTitle' => 'Profils', + 'profiles.summarySingle' => 'Ajoutez des profils pour mélanger utilisateurs gérés et identités locales', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} profils · actif : ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} profils', + 'profiles.removeConnectionTitle' => 'Retirer la connexion ?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} perdra l\'accès à ${connectionLabel}. La connexion reste disponible pour les autres profils.', + 'profiles.deleteProfileTitle' => 'Supprimer le profil ?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Cela supprime ${displayName} et toutes ses connexions de cet appareil. Les serveurs Plex/Jellyfin sous-jacents ne sont pas affectés.', + 'profiles.profileNameLabel' => 'Nom du profil', + 'profiles.pinProtectionLabel' => 'Protection par code PIN', + 'profiles.pinManagedByPlex' => 'PIN géré par Plex. Modifier sur plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'Aucun PIN défini. Pour en exiger un, modifiez l\'utilisateur Home sur plex.tv.', + 'profiles.setPin' => 'Définir un PIN', + 'profiles.connectionsLabel' => 'Connexions', + 'profiles.add' => 'Ajouter', + 'profiles.deleteProfileButton' => 'Supprimer le profil', + 'profiles.noConnectionsHint' => 'Aucune connexion — ajoutez-en une pour utiliser ce profil.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Compte Plex Home', + 'profiles.connectionDefault' => 'Par défaut', + 'profiles.makeDefault' => 'Définir par défaut', + 'profiles.removeConnection' => 'Retirer', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Ajouter à ${displayName}', + 'profiles.borrowExplain' => 'Empruntez une connexion à un autre profil. Les profils sources protégés par PIN demandent le PIN avant de partager.', + 'profiles.borrowEmpty' => 'Rien à emprunter pour le moment.', + 'profiles.borrowEmptySubtitle' => 'Connectez d\'abord un compte Plex ou un serveur Jellyfin à un autre profil, puis revenez ici.', + 'profiles.newProfile' => 'Nouveau profil', + 'profiles.profileNameHint' => 'ex. Invités, Enfants, Salon familial', + 'profiles.pinProtectionOptional' => 'Protection par PIN (optionnelle)', + 'profiles.pinExplain' => 'PIN à 4 chiffres requis pour basculer sur ce profil. Barrière souple — quiconque peut effacer les données de l\'app peut la contourner.', + 'profiles.continueButton' => 'Continuer', + 'profiles.pinsDontMatch' => 'Les PIN ne correspondent pas', + 'connections.sectionTitle' => 'Connexions', + 'connections.addConnection' => 'Ajouter une connexion', + 'connections.addConnectionSubtitleNoProfile' => 'Connectez-vous avec Plex ou connectez un serveur Jellyfin', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Ajouter à ${displayName} — compte Plex, serveur Jellyfin ou emprunter à un autre profil', + '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', 'discover.title' => 'Découvrez', 'discover.switchProfile' => 'Changer de profil', 'discover.noContentAvailable' => 'Aucun contenu disponible', 'discover.addMediaToLibraries' => 'Ajoutez des médias à votre bibliothèque', 'discover.continueWatching' => 'Continuer à regarder', + 'discover.nextUp' => 'À suivre', + 'discover.recentlyAdded' => 'Récemment ajouté', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Aperçu', 'discover.cast' => 'Cast', @@ -2032,7 +2263,7 @@ extension on TranslationsFr { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min restantes', 'errors.searchFailed' => ({required Object error}) => 'Recherche échouée: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Délai d\'attente de connexion dépassé pendant le chargement ${context}', - 'errors.connectionFailed' => 'Impossible de se connecter au serveur Plex', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Échec du chargement ${context}: ${error}', 'errors.noClientAvailable' => 'Aucun client disponible', 'errors.authenticationFailed' => ({required Object error}) => 'Échec de l\'authentification: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsFr { 'errors.invalidToken' => 'Token invalide', 'errors.failedToVerifyToken' => ({required Object error}) => 'Échec de la vérification du token: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Impossible de changer de profil vers ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Impossible de supprimer ${displayName}', + 'errors.failedToRate' => 'Impossible de mettre à jour la note', 'libraries.title' => 'Bibliothèques', 'libraries.scanLibraryFiles' => 'Scanner les fichiers de la bibliothèque', 'libraries.scanLibrary' => 'Scanner la bibliothèque', @@ -2054,8 +2287,6 @@ extension on TranslationsFr { 'libraries.analyzing' => ({required Object title}) => 'Analyse de "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'L\'analyse a commencé pour "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Échec de l\'analyse de la bibliothèque: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Aucune bibliothèque trouvée', 'libraries.allLibrariesHidden' => 'Toutes les bibliothèques sont masquées', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Bibliothèques masquées (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsFr { 'libraries.groupings.seasons' => 'Saisons', 'libraries.groupings.episodes' => 'Épisodes', 'libraries.groupings.folders' => 'Dossiers', + 'libraries.filterCategories.genre' => 'Genre', + 'libraries.filterCategories.year' => 'Année', + 'libraries.filterCategories.contentRating' => 'Classification', + 'libraries.filterCategories.tag' => 'Tag', + 'libraries.sortLabels.title' => 'Titre', + 'libraries.sortLabels.dateAdded' => 'Date d\'ajout', + 'libraries.sortLabels.releaseDate' => 'Date de sortie', + 'libraries.sortLabels.rating' => 'Note', + 'libraries.sortLabels.lastPlayed' => 'Dernière lecture', + 'libraries.sortLabels.playCount' => 'Lectures', + 'libraries.sortLabels.random' => 'Aléatoire', + 'libraries.sortLabels.dateShared' => 'Date de partage', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Dernière date de diffusion', 'about.title' => 'À propos', 'about.openSourceLicenses' => 'Licences Open Source', 'about.versionLabel' => ({required Object version}) => 'Version ${version}', - 'about.appDescription' => 'Un magnifique client Plex pour Flutter', + 'about.appDescription' => 'Un magnifique client Plex et Jellyfin pour Flutter', 'about.viewLicensesDescription' => 'Afficher les licences des bibliothèques tierces', 'serverSelection.allServerConnectionsFailed' => 'Impossible de se connecter à un serveur. Veuillez vérifier votre connexion réseau et réessayer.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Aucun serveur trouvé pour ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsFr { 'watchTogether.recentRooms' => 'Salons récents', 'watchTogether.renameRoom' => 'Renommer le salon', 'watchTogether.removeRoom' => 'Supprimer', + 'watchTogether.guestSwitchUnavailable' => 'Impossible de changer — serveur indisponible pour la synchronisation', + 'watchTogether.guestSwitchFailed' => 'Impossible de changer — contenu introuvable sur ce serveur', 'downloads.title' => 'Téléchargements', 'downloads.manage' => 'Gérer', 'downloads.tvShows' => 'Show TV', @@ -2291,6 +2537,12 @@ extension on TranslationsFr { 'downloads.editSyncFilter' => 'Filtre de synchronisation', 'downloads.syncAllItems' => 'Synchronisation de tous les éléments', 'downloads.syncUnwatchedItems' => 'Synchronisation des éléments non vus', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Serveur : ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Disponible', + 'downloads.syncRuleOffline' => 'Hors ligne', + 'downloads.syncRuleSignInRequired' => 'Connexion requise', + 'downloads.syncRuleNotAvailableForProfile' => 'Non disponible pour le profil actuel', + 'downloads.syncRuleUnknownServer' => 'Serveur inconnu', 'downloads.syncRuleListCreated' => 'Règle de synchronisation créée', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Aucune amélioration vidéo', @@ -2484,6 +2736,8 @@ extension on TranslationsFr { 'trakt.disconnectConfirmBody' => 'Plezy n\'enverra plus d\'événements de lecture à Trakt. Vous pouvez vous reconnecter à tout moment.', 'trakt.scrobble' => 'Scrobbling en temps réel', 'trakt.scrobbleDescription' => 'Envoyer les événements de lecture, pause et arrêt à Trakt pendant la lecture.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Synchroniser le statut « vu »', 'trakt.watchedSyncDescription' => 'Lorsque vous marquez un élément comme vu dans Plezy, il l\'est aussi sur Trakt.', 'trackers.title' => 'Trackers', @@ -2519,6 +2773,38 @@ extension on TranslationsFr { 'trackers.libraryFilter.modeHintWhitelist' => 'Synchroniser uniquement les bibliothèques cochées ci-dessous.', 'trackers.libraryFilter.libraries' => 'Bibliothèques', 'trackers.libraryFilter.noLibraries' => 'Aucune bibliothèque disponible', + 'addServer.addJellyfinTitle' => 'Ajouter un serveur Jellyfin', + 'addServer.jellyfinUrlIntro' => 'Saisissez l\'URL de votre serveur Jellyfin — p. ex. `https://jellyfin.example.com`. Vous pourrez vous connecter ensuite.', + 'addServer.serverUrl' => 'URL du serveur', + 'addServer.findServer' => 'Rechercher un serveur', + 'addServer.username' => 'Nom d\'utilisateur', + 'addServer.password' => 'Mot de passe', + 'addServer.signIn' => 'Se connecter', + 'addServer.change' => 'Modifier', + 'addServer.required' => 'Requis', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Impossible de joindre le serveur : ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Échec de la connexion : ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Échec de Quick Connect : ${error}', + 'addServer.addPlexTitle' => 'Se connecter avec Plex', + 'addServer.plexAuthIntro' => 'Choisissez comment vous connecter à Plex. Le flux navigateur ouvre plex.tv où vous confirmez la connexion ; l\'option QR est pratique pour la TV ou les appareils distants.', + 'addServer.plexQRPrompt' => 'Scannez ce QR code pour vous connecter.', + 'addServer.waitingForPlexConfirmation' => 'En attente de la confirmation de plex.tv…', + 'addServer.pinExpired' => 'Le PIN a expiré avant la connexion. Veuillez réessayer.', + 'addServer.duplicatePlexAccount' => 'Cet appareil est déjà connecté à un compte Plex. Déconnectez-vous depuis les paramètres pour changer de compte.', + '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.addConnectionIntroGlobal' => 'Ajoutez un autre serveur média. Vous pouvez mélanger comptes Plex et serveurs Jellyfin — les contenus de tous les backends connectés apparaissent ensemble sur l\'écran d\'accueil.', + 'addServer.addConnectionIntroScoped' => 'Ajoutez un nouveau serveur, ou empruntez-en un à un autre profil.', + 'addServer.signInWithPlexCard' => 'Se connecter avec Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Autorisez cet appareil avec votre compte Plex. Les serveurs partagés avec le compte suivent automatiquement.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Autorisez un nouveau compte Plex. Ses utilisateurs Home apparaissent comme profils.', + 'addServer.connectToJellyfinCard' => 'Se connecter à Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Saisissez l\'URL de votre serveur Jellyfin et connectez-vous avec nom d\'utilisateur + mot de passe (Quick Connect bientôt disponible).', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Connectez-vous à un serveur Jellyfin. Lié à ${name}.', + 'addServer.borrowFromAnotherProfile' => 'Emprunter à un autre profil', + 'addServer.borrowFromAnotherProfileSubtitle' => 'Réutilisez une connexion déjà associée à un autre profil. Les profils source protégés par PIN demandent le PIN.', _ => null, }; } diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 628eaa29..64a2f16f 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsIt with BaseTranslations implements Translations { +class TranslationsIt extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsIt({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsIt with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsIt with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsIt _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsIt with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingIt subtitlingStyling = _TranslationsSubtitlingStylingIt._(_root); @override late final _TranslationsMpvConfigIt mpvConfig = _TranslationsMpvConfigIt._(_root); @override late final _TranslationsDialogIt dialog = _TranslationsDialogIt._(_root); + @override late final _TranslationsProfilesIt profiles = _TranslationsProfilesIt._(_root); + @override late final _TranslationsConnectionsIt connections = _TranslationsConnectionsIt._(_root); @override late final _TranslationsDiscoverIt discover = _TranslationsDiscoverIt._(_root); @override late final _TranslationsErrorsIt errors = _TranslationsErrorsIt._(_root); @override late final _TranslationsLibrariesIt libraries = _TranslationsLibrariesIt._(_root); @@ -78,11 +82,12 @@ class TranslationsIt with BaseTranslations implements T @override late final _TranslationsServerTasksIt serverTasks = _TranslationsServerTasksIt._(_root); @override late final _TranslationsTraktIt trakt = _TranslationsTraktIt._(_root); @override late final _TranslationsTrackersIt trackers = _TranslationsTrackersIt._(_root); + @override late final _TranslationsAddServerIt addServer = _TranslationsAddServerIt._(_root); } // Path: app -class _TranslationsAppIt implements TranslationsAppEn { - _TranslationsAppIt._(this._root); +class _TranslationsAppIt extends TranslationsAppEn { + _TranslationsAppIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppIt implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthIt implements TranslationsAuthEn { - _TranslationsAuthIt._(this._root); +class _TranslationsAuthIt extends TranslationsAuthEn { + _TranslationsAuthIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field // Translations + @override String get signIn => 'Accedi'; @override String get signInWithPlex => 'Accedi con Plex'; @override String get showQRCode => 'Mostra QR Code'; @override String get authenticate => 'Autenticazione'; @@ -104,11 +110,19 @@ class _TranslationsAuthIt implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Scansiona il QR code per accedere'; @override String get waitingForAuth => 'In attesa di autenticazione...\nCompleta l\'accesso dal tuo browser.'; @override String get useBrowser => 'Usa browser'; + @override String get or => 'o'; + @override String get connectToJellyfin => 'Connetti a Jellyfin'; + @override String get useQuickConnect => 'Usa Quick Connect'; + @override String get quickConnectCode => 'Codice Quick Connect'; + @override String get quickConnectInstructions => 'Apri il tuo server Jellyfin in un browser, accedi e scegli Quick Connect dal menu utente. Inserisci questo codice per approvare l\'accesso.'; + @override String get quickConnectWaiting => 'In attesa di approvazione…'; + @override String get quickConnectCancel => 'Annulla'; + @override String get quickConnectExpired => 'Il codice Quick Connect è scaduto prima dell\'approvazione. Riprova.'; } // Path: common -class _TranslationsCommonIt implements TranslationsCommonEn { - _TranslationsCommonIt._(this._root); +class _TranslationsCommonIt extends TranslationsCommonEn { + _TranslationsCommonIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonIt implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensIt implements TranslationsScreensEn { - _TranslationsScreensIt._(this._root); +class _TranslationsScreensIt extends TranslationsScreensEn { + _TranslationsScreensIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensIt implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateIt implements TranslationsUpdateEn { - _TranslationsUpdateIt._(this._root); +class _TranslationsUpdateIt extends TranslationsUpdateEn { + _TranslationsUpdateIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateIt implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsIt implements TranslationsSettingsEn { - _TranslationsSettingsIt._(this._root); +class _TranslationsSettingsIt extends TranslationsSettingsEn { + _TranslationsSettingsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn { @override String get gridView => 'Griglia'; @override String get listView => 'Elenco'; @override String get showHeroSection => 'Mostra sezione principale'; - @override String get useGlobalHubs => 'Usa layout Home di Plex'; - @override String get useGlobalHubsDescription => 'Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Mostra nome server sugli hub'; @override String get showServerNameOnHubsDescription => 'Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.'; @override String get groupLibrariesByServer => 'Raggruppa librerie per server'; - @override String get groupLibrariesByServerDescription => 'Mostra un\'intestazione per ogni server Plex nella barra laterale quando sei connesso a più server.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Mantieni sempre aperta la barra laterale'; @override String get alwaysKeepSidebarOpenDescription => 'La barra laterale rimane espansa e l\'area del contenuto si adatta'; @override String get showUnwatchedCount => 'Mostra conteggio non visti'; @@ -385,8 +399,8 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchIt implements TranslationsSearchEn { - _TranslationsSearchIt._(this._root); +class _TranslationsSearchIt extends TranslationsSearchEn { + _TranslationsSearchIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchIt implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysIt implements TranslationsHotkeysEn { - _TranslationsHotkeysIt._(this._root); +class _TranslationsHotkeysIt extends TranslationsHotkeysEn { + _TranslationsHotkeysIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysIt implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoIt implements TranslationsFileInfoEn { - _TranslationsFileInfoIt._(this._root); +class _TranslationsFileInfoIt extends TranslationsFileInfoEn { + _TranslationsFileInfoIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoIt implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuIt implements TranslationsMediaMenuEn { - _TranslationsMediaMenuIt._(this._root); +class _TranslationsMediaMenuIt extends TranslationsMediaMenuEn { + _TranslationsMediaMenuIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuIt implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityIt implements TranslationsAccessibilityEn { - _TranslationsAccessibilityIt._(this._root); +class _TranslationsAccessibilityIt extends TranslationsAccessibilityEn { + _TranslationsAccessibilityIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityIt implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsIt implements TranslationsTooltipsEn { - _TranslationsTooltipsIt._(this._root); +class _TranslationsTooltipsIt extends TranslationsTooltipsEn { + _TranslationsTooltipsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsIt implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn { - _TranslationsVideoControlsIt._(this._root); +class _TranslationsVideoControlsIt extends TranslationsVideoControlsEn { + _TranslationsVideoControlsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsIt implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusIt implements TranslationsUserStatusEn { - _TranslationsUserStatusIt._(this._root); +class _TranslationsUserStatusIt extends TranslationsUserStatusEn { + _TranslationsUserStatusIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusIt implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesIt implements TranslationsMessagesEn { - _TranslationsMessagesIt._(this._root); +class _TranslationsMessagesIt extends TranslationsMessagesEn { + _TranslationsMessagesIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesIt implements TranslationsMessagesEn { @override String get musicNotSupported => 'La riproduzione musicale non è ancora supportata'; @override String get noDescriptionAvailable => 'Nessuna descrizione disponibile'; @override String get noProfilesAvailable => 'Nessun profilo disponibile'; - @override String get contactAdminForProfiles => 'Contatta il tuo amministratore Plex per aggiungere profili'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Impossibile determinare la sezione della libreria per questo elemento'; @override String get logsCleared => 'Log eliminati'; @override String get logsCopied => 'Log copiati negli appunti'; @@ -636,8 +650,8 @@ class _TranslationsMessagesIt implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingIt implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingIt._(this._root); +class _TranslationsSubtitlingStylingIt extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingIt implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigIt implements TranslationsMpvConfigEn { - _TranslationsMpvConfigIt._(this._root); +class _TranslationsMpvConfigIt extends TranslationsMpvConfigEn { + _TranslationsMpvConfigIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigIt implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogIt implements TranslationsDialogEn { - _TranslationsDialogIt._(this._root); +class _TranslationsDialogIt extends TranslationsDialogEn { + _TranslationsDialogIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogIt implements TranslationsDialogEn { @override String get confirmAction => 'Conferma azione'; } +// Path: profiles +class _TranslationsProfilesIt extends TranslationsProfilesEn { + _TranslationsProfilesIt._(TranslationsIt root) : this._root = root, super.internal(root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Aggiungi profilo Plezy'; + @override String get switchingProfile => 'Cambio profilo…'; + @override String get deleteThisProfileTitle => 'Eliminare questo profilo?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} verrà rimosso. Le connessioni non saranno influenzate.'; + @override String get active => 'Attivo'; + @override String get manage => 'Gestisci'; + @override String get delete => 'Elimina'; + @override String get signOut => 'Esci'; + @override String get signOutPlexTitle => 'Uscire da Plex?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} e tutti gli utenti Plex Home di questo account verranno rimossi da questo dispositivo. Puoi accedere di nuovo in qualsiasi momento.'; + @override String get signedOutPlex => 'Uscito da Plex.'; + @override String get signOutFailed => 'Uscita non riuscita.'; + @override String get sectionTitle => 'Profili'; + @override String get summarySingle => 'Aggiungi profili per combinare utenti gestiti e identità locali'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} profili · attivo: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} profili'; + @override String get removeConnectionTitle => 'Rimuovere la connessione?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} perderà l\'accesso a ${connectionLabel}. La connessione resta disponibile per gli altri profili.'; + @override String get deleteProfileTitle => 'Eliminare il profilo?'; + @override String deleteProfileMessage({required Object displayName}) => 'Questo rimuove ${displayName} e tutte le sue connessioni da questo dispositivo. I server Plex/Jellyfin sottostanti non sono interessati.'; + @override String get profileNameLabel => 'Nome profilo'; + @override String get pinProtectionLabel => 'Protezione PIN'; + @override String get pinManagedByPlex => 'PIN gestito da Plex. Modifica su plex.tv.'; + @override String get noPinSetEditOnPlex => 'Nessun PIN impostato. Per richiederne uno, modifica l\'utente Home su plex.tv.'; + @override String get setPin => 'Imposta PIN'; + @override String get connectionsLabel => 'Connessioni'; + @override String get add => 'Aggiungi'; + @override String get deleteProfileButton => 'Elimina profilo'; + @override String get noConnectionsHint => 'Nessuna connessione — aggiungine una per usare questo profilo.'; + @override String get plexHomeAccount => 'Account Plex Home'; + @override String get connectionDefault => 'Predefinita'; + @override String get makeDefault => 'Imposta come predefinita'; + @override String get removeConnection => 'Rimuovi'; + @override String borrowAddTo({required Object displayName}) => 'Aggiungi a ${displayName}'; + @override String get borrowExplain => 'Prendi in prestito una connessione da un altro profilo. I profili sorgente protetti da PIN richiedono il PIN prima di condividere.'; + @override String get borrowEmpty => 'Nulla da prendere in prestito al momento.'; + @override String get borrowEmptySubtitle => 'Collega prima un account Plex o un server Jellyfin a un altro profilo, poi torna qui.'; + @override String get newProfile => 'Nuovo profilo'; + @override String get profileNameHint => 'es. Ospiti, Bambini, Soggiorno'; + @override String get pinProtectionOptional => 'Protezione PIN (opzionale)'; + @override String get pinExplain => 'PIN a 4 cifre richiesto per passare a questo profilo. Barriera leggera — chiunque può cancellare i dati dell\'app per aggirarla.'; + @override String get continueButton => 'Continua'; + @override String get pinsDontMatch => 'I PIN non corrispondono'; +} + +// Path: connections +class _TranslationsConnectionsIt extends TranslationsConnectionsEn { + _TranslationsConnectionsIt._(TranslationsIt root) : this._root = root, super.internal(root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Connessioni'; + @override String get addConnection => 'Aggiungi connessione'; + @override String get addConnectionSubtitleNoProfile => 'Accedi con Plex o collega un server Jellyfin'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Aggiungi a ${displayName} — account Plex, server Jellyfin o prendi in prestito da un altro profilo'; + @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'; +} + // Path: discover -class _TranslationsDiscoverIt implements TranslationsDiscoverEn { - _TranslationsDiscoverIt._(this._root); +class _TranslationsDiscoverIt extends TranslationsDiscoverEn { + _TranslationsDiscoverIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverIt implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Nessun contenuto disponibile'; @override String get addMediaToLibraries => 'Aggiungi alcuni file multimediali alle tue librerie'; @override String get continueWatching => 'Continua a guardare'; + @override String get nextUp => 'Prossimi'; + @override String get recentlyAdded => 'Aggiunti di recente'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Panoramica'; @override String get cast => 'Attori'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverIt implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsIt implements TranslationsErrorsEn { - _TranslationsErrorsIt._(this._root); +class _TranslationsErrorsIt extends TranslationsErrorsEn { + _TranslationsErrorsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Ricerca fallita: ${error}'; @override String connectionTimeout({required Object context}) => 'Timeout connessione durante caricamento di ${context}'; - @override String get connectionFailed => 'Impossibile connettersi al server Plex.'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Impossibile caricare ${context}: ${error}'; @override String get noClientAvailable => 'Nessun client disponibile'; @override String authenticationFailed({required Object error}) => 'Autenticazione fallita: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsIt implements TranslationsErrorsEn { @override String get invalidToken => 'Token non valido'; @override String failedToVerifyToken({required Object error}) => 'Verifica token fallita: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Impossibile passare a ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Impossibile eliminare ${displayName}'; + @override String get failedToRate => 'Impossibile aggiornare la valutazione'; } // Path: libraries -class _TranslationsLibrariesIt implements TranslationsLibrariesEn { - _TranslationsLibrariesIt._(this._root); +class _TranslationsLibrariesIt extends TranslationsLibrariesEn { + _TranslationsLibrariesIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesIt implements TranslationsLibrariesEn { @override String get folders => 'cartelle'; @override late final _TranslationsLibrariesTabsIt tabs = _TranslationsLibrariesTabsIt._(_root); @override late final _TranslationsLibrariesGroupingsIt groupings = _TranslationsLibrariesGroupingsIt._(_root); + @override late final _TranslationsLibrariesFilterCategoriesIt filterCategories = _TranslationsLibrariesFilterCategoriesIt._(_root); + @override late final _TranslationsLibrariesSortLabelsIt sortLabels = _TranslationsLibrariesSortLabelsIt._(_root); } // Path: about -class _TranslationsAboutIt implements TranslationsAboutEn { - _TranslationsAboutIt._(this._root); +class _TranslationsAboutIt extends TranslationsAboutEn { + _TranslationsAboutIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutIt implements TranslationsAboutEn { @override String get title => 'Informazioni'; @override String get openSourceLicenses => 'Licenze Open Source'; @override String versionLabel({required Object version}) => 'Versione ${version}'; - @override String get appDescription => 'Un bellissimo client Plex per Flutter'; + @override String get appDescription => 'Un bellissimo client Plex e Jellyfin per Flutter'; @override String get viewLicensesDescription => 'Visualizza le licenze delle librerie di terze parti'; } // Path: serverSelection -class _TranslationsServerSelectionIt implements TranslationsServerSelectionEn { - _TranslationsServerSelectionIt._(this._root); +class _TranslationsServerSelectionIt extends TranslationsServerSelectionEn { + _TranslationsServerSelectionIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionIt implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailIt implements TranslationsHubDetailEn { - _TranslationsHubDetailIt._(this._root); +class _TranslationsHubDetailIt extends TranslationsHubDetailEn { + _TranslationsHubDetailIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailIt implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsIt implements TranslationsLogsEn { - _TranslationsLogsIt._(this._root); +class _TranslationsLogsIt extends TranslationsLogsEn { + _TranslationsLogsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsIt implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesIt implements TranslationsLicensesEn { - _TranslationsLicensesIt._(this._root); +class _TranslationsLicensesIt extends TranslationsLicensesEn { + _TranslationsLicensesIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesIt implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationIt implements TranslationsNavigationEn { - _TranslationsNavigationIt._(this._root); +class _TranslationsNavigationIt extends TranslationsNavigationEn { + _TranslationsNavigationIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationIt implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvIt implements TranslationsLiveTvEn { - _TranslationsLiveTvIt._(this._root); +class _TranslationsLiveTvIt extends TranslationsLiveTvEn { + _TranslationsLiveTvIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvIt implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsIt implements TranslationsCollectionsEn { - _TranslationsCollectionsIt._(this._root); +class _TranslationsCollectionsIt extends TranslationsCollectionsEn { + _TranslationsCollectionsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsIt implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsIt implements TranslationsPlaylistsEn { - _TranslationsPlaylistsIt._(this._root); +class _TranslationsPlaylistsIt extends TranslationsPlaylistsEn { + _TranslationsPlaylistsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsIt implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherIt implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherIt._(this._root); +class _TranslationsWatchTogetherIt extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherIt implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Stanze recenti'; @override String get renameRoom => 'Rinomina stanza'; @override String get removeRoom => 'Rimuovi'; + @override String get guestSwitchUnavailable => 'Impossibile cambiare — server non disponibile per la sincronizzazione'; + @override String get guestSwitchFailed => 'Impossibile cambiare — contenuto non trovato su questo server'; } // Path: downloads -class _TranslationsDownloadsIt implements TranslationsDownloadsEn { - _TranslationsDownloadsIt._(this._root); +class _TranslationsDownloadsIt extends TranslationsDownloadsEn { + _TranslationsDownloadsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsIt implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Filtro di sincronizzazione'; @override String get syncAllItems => 'Sincronizzazione di tutti gli elementi'; @override String get syncUnwatchedItems => 'Sincronizzazione degli elementi non visti'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Server: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Disponibile'; + @override String get syncRuleOffline => 'Offline'; + @override String get syncRuleSignInRequired => 'Accesso richiesto'; + @override String get syncRuleNotAvailableForProfile => 'Non disponibile per il profilo attuale'; + @override String get syncRuleUnknownServer => 'Server sconosciuto'; @override String get syncRuleListCreated => 'Regola di sincronizzazione creata'; } // Path: shaders -class _TranslationsShadersIt implements TranslationsShadersEn { - _TranslationsShadersIt._(this._root); +class _TranslationsShadersIt extends TranslationsShadersEn { + _TranslationsShadersIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersIt implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteIt implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteIt._(this._root); +class _TranslationsCompanionRemoteIt extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteIt implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsIt implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsIt._(this._root); +class _TranslationsVideoSettingsIt extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsIt implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerIt implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerIt._(this._root); +class _TranslationsExternalPlayerIt extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerIt implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditIt implements TranslationsMetadataEditEn { - _TranslationsMetadataEditIt._(this._root); +class _TranslationsMetadataEditIt extends TranslationsMetadataEditEn { + _TranslationsMetadataEditIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditIt implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenIt implements TranslationsMatchScreenEn { - _TranslationsMatchScreenIt._(this._root); +class _TranslationsMatchScreenIt extends TranslationsMatchScreenEn { + _TranslationsMatchScreenIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenIt implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksIt implements TranslationsServerTasksEn { - _TranslationsServerTasksIt._(this._root); +class _TranslationsServerTasksIt extends TranslationsServerTasksEn { + _TranslationsServerTasksIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksIt implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktIt implements TranslationsTraktEn { - _TranslationsTraktIt._(this._root); +class _TranslationsTraktIt extends TranslationsTraktEn { + _TranslationsTraktIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktIt implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersIt implements TranslationsTrackersEn { - _TranslationsTrackersIt._(this._root); +class _TranslationsTrackersIt extends TranslationsTrackersEn { + _TranslationsTrackersIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersIt implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterIt libraryFilter = _TranslationsTrackersLibraryFilterIt._(_root); } +// Path: addServer +class _TranslationsAddServerIt extends TranslationsAddServerEn { + _TranslationsAddServerIt._(TranslationsIt root) : this._root = root, super.internal(root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Aggiungi server Jellyfin'; + @override String get jellyfinUrlIntro => 'Inserisci l\'URL del tuo server Jellyfin — es. `https://jellyfin.example.com`. Potrai accedere subito dopo.'; + @override String get serverUrl => 'URL del server'; + @override String get findServer => 'Trova server'; + @override String get username => 'Nome utente'; + @override String get password => 'Password'; + @override String get signIn => 'Accedi'; + @override String get change => 'Modifica'; + @override String get required => 'Obbligatorio'; + @override String couldNotReachServer({required Object error}) => 'Impossibile raggiungere il server: ${error}'; + @override String signInFailed({required Object error}) => 'Accesso non riuscito: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect non riuscito: ${error}'; + @override String get addPlexTitle => 'Accedi con Plex'; + @override String get plexAuthIntro => 'Scegli come accedere a Plex. Il flusso browser apre plex.tv dove confermi la connessione; l\'opzione QR è comoda per TV / dispositivi remoti.'; + @override String get plexQRPrompt => 'Scansiona questo QR code per accedere.'; + @override String get waitingForPlexConfirmation => 'In attesa della conferma da plex.tv…'; + @override String get pinExpired => 'PIN scaduto prima dell\'accesso. Riprova.'; + @override String get duplicatePlexAccount => 'Questo dispositivo è già connesso a un account Plex. Disconnettiti dalle impostazioni per cambiare account.'; + @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 addConnectionIntroGlobal => 'Aggiungi un altro server media. Puoi combinare account Plex e server Jellyfin — i contenuti di ogni backend connesso compaiono insieme nella schermata principale.'; + @override String get addConnectionIntroScoped => 'Aggiungi un nuovo server, o prendine in prestito uno da un altro profilo.'; + @override String get signInWithPlexCard => 'Accedi con Plex'; + @override String get signInWithPlexCardSubtitle => 'Autorizza questo dispositivo con il tuo account Plex. I server condivisi con l\'account vengono inclusi automaticamente.'; + @override String get signInWithPlexCardSubtitleScoped => 'Autorizza un nuovo account Plex. I suoi utenti Home appaiono come profili.'; + @override String get connectToJellyfinCard => 'Connetti a Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Inserisci l\'URL del tuo server Jellyfin e accedi con nome utente + password (Quick Connect in arrivo).'; + @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Accedi a un server Jellyfin. Collegato a ${name}.'; + @override String get borrowFromAnotherProfile => 'Prendi in prestito da un altro profilo'; + @override String get borrowFromAnotherProfileSubtitle => 'Riutilizza una connessione già associata a un altro profilo. I profili sorgente protetti da PIN richiedono il PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsIt implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsIt._(this._root); +class _TranslationsHotkeysActionsIt extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsIt implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsIt implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsIt._(this._root); +class _TranslationsVideoControlsPipErrorsIt extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsIt implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsIt implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsIt._(this._root); +class _TranslationsLibrariesTabsIt extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsIt implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsIt implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsIt._(this._root); +class _TranslationsLibrariesGroupingsIt extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsIt implements TranslationsLibrariesGrouping @override String get folders => 'Cartelle'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesIt extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesIt._(TranslationsIt root) : this._root = root, super.internal(root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String get genre => 'Genere'; + @override String get year => 'Anno'; + @override String get contentRating => 'Classificazione'; + @override String get tag => 'Tag'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsIt extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsIt._(TranslationsIt root) : this._root = root, super.internal(root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String get title => 'Titolo'; + @override String get dateAdded => 'Data di aggiunta'; + @override String get releaseDate => 'Data di uscita'; + @override String get rating => 'Valutazione'; + @override String get lastPlayed => 'Ultima riproduzione'; + @override String get playCount => 'Riproduzioni'; + @override String get random => 'Casuale'; + @override String get dateShared => 'Data di condivisione'; + @override String get latestEpisodeAirDate => 'Data ultima messa in onda'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionIt implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionIt._(this._root); +class _TranslationsCompanionRemoteSessionIt extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionIt implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingIt implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingIt._(this._root); +class _TranslationsCompanionRemotePairingIt extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingIt implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteIt implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteIt._(this._root); +class _TranslationsCompanionRemoteRemoteIt extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteIt implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesIt implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesIt._(this._root); +class _TranslationsTrackersServicesIt extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesIt implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeIt implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeIt._(this._root); +class _TranslationsTrackersDeviceCodeIt extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeIt implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyIt implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyIt._(this._root); +class _TranslationsTrackersOauthProxyIt extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyIt implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterIt implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterIt._(this._root); +class _TranslationsTrackersLibraryFilterIt extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterIt._(TranslationsIt root) : this._root = root, super.internal(root); final TranslationsIt _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsIt { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Accedi', 'auth.signInWithPlex' => 'Accedi con Plex', 'auth.showQRCode' => 'Mostra QR Code', 'auth.authenticate' => 'Autenticazione', @@ -1550,6 +1719,14 @@ extension on TranslationsIt { 'auth.scanQRToSignIn' => 'Scansiona il QR code per accedere', 'auth.waitingForAuth' => 'In attesa di autenticazione...\nCompleta l\'accesso dal tuo browser.', 'auth.useBrowser' => 'Usa browser', + 'auth.or' => 'o', + 'auth.connectToJellyfin' => 'Connetti a Jellyfin', + 'auth.useQuickConnect' => 'Usa Quick Connect', + 'auth.quickConnectCode' => 'Codice Quick Connect', + 'auth.quickConnectInstructions' => 'Apri il tuo server Jellyfin in un browser, accedi e scegli Quick Connect dal menu utente. Inserisci questo codice per approvare l\'accesso.', + 'auth.quickConnectWaiting' => 'In attesa di approvazione…', + 'auth.quickConnectCancel' => 'Annulla', + 'auth.quickConnectExpired' => 'Il codice Quick Connect è scaduto prima dell\'approvazione. Riprova.', 'common.cancel' => 'Cancella', 'common.save' => 'Salva', 'common.close' => 'Chiudi', @@ -1636,12 +1813,12 @@ extension on TranslationsIt { 'settings.gridView' => 'Griglia', 'settings.listView' => 'Elenco', 'settings.showHeroSection' => 'Mostra sezione principale', - 'settings.useGlobalHubs' => 'Usa layout Home di Plex', - 'settings.useGlobalHubsDescription' => 'Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Mostra nome server sugli hub', 'settings.showServerNameOnHubsDescription' => 'Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.', 'settings.groupLibrariesByServer' => 'Raggruppa librerie per server', - 'settings.groupLibrariesByServerDescription' => 'Mostra un\'intestazione per ogni server Plex nella barra laterale quando sei connesso a più server.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Mantieni sempre aperta la barra laterale', 'settings.alwaysKeepSidebarOpenDescription' => 'La barra laterale rimane espansa e l\'area del contenuto si adatta', 'settings.showUnwatchedCount' => 'Mostra conteggio non visti', @@ -1962,7 +2139,7 @@ extension on TranslationsIt { 'messages.musicNotSupported' => 'La riproduzione musicale non è ancora supportata', 'messages.noDescriptionAvailable' => 'Nessuna descrizione disponibile', 'messages.noProfilesAvailable' => 'Nessun profilo disponibile', - 'messages.contactAdminForProfiles' => 'Contatta il tuo amministratore Plex per aggiungere profili', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Impossibile determinare la sezione della libreria per questo elemento', 'messages.logsCleared' => 'Log eliminati', 'messages.logsCopied' => 'Log copiati negli appunti', @@ -2016,11 +2193,65 @@ extension on TranslationsIt { 'mpvConfig.confirmDeletePreset' => 'Sei sicuro di voler eliminare questo preset?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Conferma azione', + 'profiles.addPlezyProfile' => 'Aggiungi profilo Plezy', + 'profiles.switchingProfile' => 'Cambio profilo…', + 'profiles.deleteThisProfileTitle' => 'Eliminare questo profilo?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} verrà rimosso. Le connessioni non saranno influenzate.', + 'profiles.active' => 'Attivo', + 'profiles.manage' => 'Gestisci', + 'profiles.delete' => 'Elimina', + 'profiles.signOut' => 'Esci', + 'profiles.signOutPlexTitle' => 'Uscire da Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} e tutti gli utenti Plex Home di questo account verranno rimossi da questo dispositivo. Puoi accedere di nuovo in qualsiasi momento.', + 'profiles.signedOutPlex' => 'Uscito da Plex.', + 'profiles.signOutFailed' => 'Uscita non riuscita.', + 'profiles.sectionTitle' => 'Profili', + 'profiles.summarySingle' => 'Aggiungi profili per combinare utenti gestiti e identità locali', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} profili · attivo: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} profili', + 'profiles.removeConnectionTitle' => 'Rimuovere la connessione?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} perderà l\'accesso a ${connectionLabel}. La connessione resta disponibile per gli altri profili.', + 'profiles.deleteProfileTitle' => 'Eliminare il profilo?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Questo rimuove ${displayName} e tutte le sue connessioni da questo dispositivo. I server Plex/Jellyfin sottostanti non sono interessati.', + 'profiles.profileNameLabel' => 'Nome profilo', + 'profiles.pinProtectionLabel' => 'Protezione PIN', + 'profiles.pinManagedByPlex' => 'PIN gestito da Plex. Modifica su plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'Nessun PIN impostato. Per richiederne uno, modifica l\'utente Home su plex.tv.', + 'profiles.setPin' => 'Imposta PIN', + 'profiles.connectionsLabel' => 'Connessioni', + 'profiles.add' => 'Aggiungi', + 'profiles.deleteProfileButton' => 'Elimina profilo', + 'profiles.noConnectionsHint' => 'Nessuna connessione — aggiungine una per usare questo profilo.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Account Plex Home', + 'profiles.connectionDefault' => 'Predefinita', + 'profiles.makeDefault' => 'Imposta come predefinita', + 'profiles.removeConnection' => 'Rimuovi', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Aggiungi a ${displayName}', + 'profiles.borrowExplain' => 'Prendi in prestito una connessione da un altro profilo. I profili sorgente protetti da PIN richiedono il PIN prima di condividere.', + 'profiles.borrowEmpty' => 'Nulla da prendere in prestito al momento.', + 'profiles.borrowEmptySubtitle' => 'Collega prima un account Plex o un server Jellyfin a un altro profilo, poi torna qui.', + 'profiles.newProfile' => 'Nuovo profilo', + 'profiles.profileNameHint' => 'es. Ospiti, Bambini, Soggiorno', + 'profiles.pinProtectionOptional' => 'Protezione PIN (opzionale)', + 'profiles.pinExplain' => 'PIN a 4 cifre richiesto per passare a questo profilo. Barriera leggera — chiunque può cancellare i dati dell\'app per aggirarla.', + 'profiles.continueButton' => 'Continua', + 'profiles.pinsDontMatch' => 'I PIN non corrispondono', + 'connections.sectionTitle' => 'Connessioni', + 'connections.addConnection' => 'Aggiungi connessione', + 'connections.addConnectionSubtitleNoProfile' => 'Accedi con Plex o collega un server Jellyfin', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Aggiungi a ${displayName} — account Plex, server Jellyfin o prendi in prestito da un altro profilo', + 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessione scaduta per ${name}', + 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessione scaduta per ${count} server', + 'connections.signInAgain' => 'Accedi di nuovo', 'discover.title' => 'Esplora', 'discover.switchProfile' => 'Cambia profilo', 'discover.noContentAvailable' => 'Nessun contenuto disponibile', 'discover.addMediaToLibraries' => 'Aggiungi alcuni file multimediali alle tue librerie', 'discover.continueWatching' => 'Continua a guardare', + 'discover.nextUp' => 'Prossimi', + 'discover.recentlyAdded' => 'Aggiunti di recente', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Panoramica', 'discover.cast' => 'Attori', @@ -2032,7 +2263,7 @@ extension on TranslationsIt { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} minuti rimanenti', 'errors.searchFailed' => ({required Object error}) => 'Ricerca fallita: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Timeout connessione durante caricamento di ${context}', - 'errors.connectionFailed' => 'Impossibile connettersi al server Plex.', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Impossibile caricare ${context}: ${error}', 'errors.noClientAvailable' => 'Nessun client disponibile', 'errors.authenticationFailed' => ({required Object error}) => 'Autenticazione fallita: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsIt { 'errors.invalidToken' => 'Token non valido', 'errors.failedToVerifyToken' => ({required Object error}) => 'Verifica token fallita: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Impossibile passare a ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Impossibile eliminare ${displayName}', + 'errors.failedToRate' => 'Impossibile aggiornare la valutazione', 'libraries.title' => 'Librerie', 'libraries.scanLibraryFiles' => 'Scansiona file libreria', 'libraries.scanLibrary' => 'Scansiona libreria', @@ -2054,8 +2287,6 @@ extension on TranslationsIt { 'libraries.analyzing' => ({required Object title}) => 'Analisi "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Analisi iniziata per "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Impossibile analizzare libreria: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Nessuna libreria trovata', 'libraries.allLibrariesHidden' => 'Tutte le librerie sono nascoste', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Librerie nascoste (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsIt { 'libraries.groupings.seasons' => 'Stagioni', 'libraries.groupings.episodes' => 'Episodi', 'libraries.groupings.folders' => 'Cartelle', + 'libraries.filterCategories.genre' => 'Genere', + 'libraries.filterCategories.year' => 'Anno', + 'libraries.filterCategories.contentRating' => 'Classificazione', + 'libraries.filterCategories.tag' => 'Tag', + 'libraries.sortLabels.title' => 'Titolo', + 'libraries.sortLabels.dateAdded' => 'Data di aggiunta', + 'libraries.sortLabels.releaseDate' => 'Data di uscita', + 'libraries.sortLabels.rating' => 'Valutazione', + 'libraries.sortLabels.lastPlayed' => 'Ultima riproduzione', + 'libraries.sortLabels.playCount' => 'Riproduzioni', + 'libraries.sortLabels.random' => 'Casuale', + 'libraries.sortLabels.dateShared' => 'Data di condivisione', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Data ultima messa in onda', 'about.title' => 'Informazioni', 'about.openSourceLicenses' => 'Licenze Open Source', 'about.versionLabel' => ({required Object version}) => 'Versione ${version}', - 'about.appDescription' => 'Un bellissimo client Plex per Flutter', + 'about.appDescription' => 'Un bellissimo client Plex e Jellyfin per Flutter', 'about.viewLicensesDescription' => 'Visualizza le licenze delle librerie di terze parti', 'serverSelection.allServerConnectionsFailed' => 'Impossibile connettersi a nessun server. Controlla la tua rete e riprova.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Nessun server trovato per ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsIt { 'watchTogether.recentRooms' => 'Stanze recenti', 'watchTogether.renameRoom' => 'Rinomina stanza', 'watchTogether.removeRoom' => 'Rimuovi', + 'watchTogether.guestSwitchUnavailable' => 'Impossibile cambiare — server non disponibile per la sincronizzazione', + 'watchTogether.guestSwitchFailed' => 'Impossibile cambiare — contenuto non trovato su questo server', 'downloads.title' => 'Download', 'downloads.manage' => 'Gestisci', 'downloads.tvShows' => 'Serie TV', @@ -2291,6 +2537,12 @@ extension on TranslationsIt { 'downloads.editSyncFilter' => 'Filtro di sincronizzazione', 'downloads.syncAllItems' => 'Sincronizzazione di tutti gli elementi', 'downloads.syncUnwatchedItems' => 'Sincronizzazione degli elementi non visti', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Disponibile', + 'downloads.syncRuleOffline' => 'Offline', + 'downloads.syncRuleSignInRequired' => 'Accesso richiesto', + 'downloads.syncRuleNotAvailableForProfile' => 'Non disponibile per il profilo attuale', + 'downloads.syncRuleUnknownServer' => 'Server sconosciuto', 'downloads.syncRuleListCreated' => 'Regola di sincronizzazione creata', 'shaders.title' => 'Shader', 'shaders.noShaderDescription' => 'Nessun miglioramento video', @@ -2484,6 +2736,8 @@ extension on TranslationsIt { 'trakt.disconnectConfirmBody' => 'Plezy smetterà di inviare eventi di riproduzione a Trakt. Puoi riconnetterti in qualsiasi momento.', 'trakt.scrobble' => 'Scrobbling in tempo reale', 'trakt.scrobbleDescription' => 'Invia eventi di riproduzione, pausa e arresto a Trakt durante la riproduzione.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Sincronizza stato visualizzato', 'trakt.watchedSyncDescription' => 'Quando segni elementi come visti in Plezy, vengono segnati anche su Trakt.', 'trackers.title' => 'Tracker', @@ -2519,6 +2773,38 @@ extension on TranslationsIt { 'trackers.libraryFilter.modeHintWhitelist' => 'Sincronizza solo le librerie selezionate sotto.', 'trackers.libraryFilter.libraries' => 'Librerie', 'trackers.libraryFilter.noLibraries' => 'Nessuna libreria disponibile', + 'addServer.addJellyfinTitle' => 'Aggiungi server Jellyfin', + 'addServer.jellyfinUrlIntro' => 'Inserisci l\'URL del tuo server Jellyfin — es. `https://jellyfin.example.com`. Potrai accedere subito dopo.', + 'addServer.serverUrl' => 'URL del server', + 'addServer.findServer' => 'Trova server', + 'addServer.username' => 'Nome utente', + 'addServer.password' => 'Password', + 'addServer.signIn' => 'Accedi', + 'addServer.change' => 'Modifica', + 'addServer.required' => 'Obbligatorio', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Impossibile raggiungere il server: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Accesso non riuscito: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect non riuscito: ${error}', + 'addServer.addPlexTitle' => 'Accedi con Plex', + 'addServer.plexAuthIntro' => 'Scegli come accedere a Plex. Il flusso browser apre plex.tv dove confermi la connessione; l\'opzione QR è comoda per TV / dispositivi remoti.', + 'addServer.plexQRPrompt' => 'Scansiona questo QR code per accedere.', + 'addServer.waitingForPlexConfirmation' => 'In attesa della conferma da plex.tv…', + 'addServer.pinExpired' => 'PIN scaduto prima dell\'accesso. Riprova.', + 'addServer.duplicatePlexAccount' => 'Questo dispositivo è già connesso a un account Plex. Disconnettiti dalle impostazioni per cambiare account.', + '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.addConnectionIntroGlobal' => 'Aggiungi un altro server media. Puoi combinare account Plex e server Jellyfin — i contenuti di ogni backend connesso compaiono insieme nella schermata principale.', + 'addServer.addConnectionIntroScoped' => 'Aggiungi un nuovo server, o prendine in prestito uno da un altro profilo.', + 'addServer.signInWithPlexCard' => 'Accedi con Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Autorizza questo dispositivo con il tuo account Plex. I server condivisi con l\'account vengono inclusi automaticamente.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Autorizza un nuovo account Plex. I suoi utenti Home appaiono come profili.', + 'addServer.connectToJellyfinCard' => 'Connetti a Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Inserisci l\'URL del tuo server Jellyfin e accedi con nome utente + password (Quick Connect in arrivo).', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Accedi a un server Jellyfin. Collegato a ${name}.', + 'addServer.borrowFromAnotherProfile' => 'Prendi in prestito da un altro profilo', + 'addServer.borrowFromAnotherProfileSubtitle' => 'Riutilizza una connessione già associata a un altro profilo. I profili sorgente protetti da PIN richiedono il PIN.', _ => null, }; } diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 88271900..84669c56 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsJa with BaseTranslations implements Translations { +class TranslationsJa extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsJa({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsJa with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsJa with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsJa _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsJa with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingJa subtitlingStyling = _TranslationsSubtitlingStylingJa._(_root); @override late final _TranslationsMpvConfigJa mpvConfig = _TranslationsMpvConfigJa._(_root); @override late final _TranslationsDialogJa dialog = _TranslationsDialogJa._(_root); + @override late final _TranslationsProfilesJa profiles = _TranslationsProfilesJa._(_root); + @override late final _TranslationsConnectionsJa connections = _TranslationsConnectionsJa._(_root); @override late final _TranslationsDiscoverJa discover = _TranslationsDiscoverJa._(_root); @override late final _TranslationsErrorsJa errors = _TranslationsErrorsJa._(_root); @override late final _TranslationsLibrariesJa libraries = _TranslationsLibrariesJa._(_root); @@ -78,11 +82,12 @@ class TranslationsJa with BaseTranslations implements T @override late final _TranslationsServerTasksJa serverTasks = _TranslationsServerTasksJa._(_root); @override late final _TranslationsTraktJa trakt = _TranslationsTraktJa._(_root); @override late final _TranslationsTrackersJa trackers = _TranslationsTrackersJa._(_root); + @override late final _TranslationsAddServerJa addServer = _TranslationsAddServerJa._(_root); } // Path: app -class _TranslationsAppJa implements TranslationsAppEn { - _TranslationsAppJa._(this._root); +class _TranslationsAppJa extends TranslationsAppEn { + _TranslationsAppJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppJa implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthJa implements TranslationsAuthEn { - _TranslationsAuthJa._(this._root); +class _TranslationsAuthJa extends TranslationsAuthEn { + _TranslationsAuthJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field // Translations + @override String get signIn => 'サインイン'; @override String get signInWithPlex => 'Plexでサインイン'; @override String get showQRCode => 'QRコードを表示'; @override String get authenticate => '認証'; @@ -104,11 +110,19 @@ class _TranslationsAuthJa implements TranslationsAuthEn { @override String get scanQRToSignIn => 'このQRコードをスキャンしてサインイン'; @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 quickConnectCode => 'Quick Connect コード'; + @override String get quickConnectInstructions => 'Web ブラウザで Jellyfin サーバーを開いてログインし、ユーザーメニューから Quick Connect を選択します。このコードを入力してサインインを承認してください。'; + @override String get quickConnectWaiting => '承認を待っています…'; + @override String get quickConnectCancel => 'キャンセル'; + @override String get quickConnectExpired => '承認される前に Quick Connect コードの有効期限が切れました。もう一度お試しください。'; } // Path: common -class _TranslationsCommonJa implements TranslationsCommonEn { - _TranslationsCommonJa._(this._root); +class _TranslationsCommonJa extends TranslationsCommonEn { + _TranslationsCommonJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonJa implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensJa implements TranslationsScreensEn { - _TranslationsScreensJa._(this._root); +class _TranslationsScreensJa extends TranslationsScreensEn { + _TranslationsScreensJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensJa implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateJa implements TranslationsUpdateEn { - _TranslationsUpdateJa._(this._root); +class _TranslationsUpdateJa extends TranslationsUpdateEn { + _TranslationsUpdateJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateJa implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsJa implements TranslationsSettingsEn { - _TranslationsSettingsJa._(this._root); +class _TranslationsSettingsJa extends TranslationsSettingsEn { + _TranslationsSettingsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsJa implements TranslationsSettingsEn { @override String get gridView => 'グリッド'; @override String get listView => 'リスト'; @override String get showHeroSection => 'ヒーローセクションを表示'; - @override String get useGlobalHubs => 'Plex Homeレイアウトを使用'; - @override String get useGlobalHubsDescription => '公式Plexクライアントのようにホームページのハブを表示。オフにすると、ライブラリごとのおすすめを表示。'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'ハブにサーバー名を表示'; @override String get showServerNameOnHubsDescription => 'ハブタイトルに常にサーバー名を表示。オフにすると、重複名のみ表示。'; @override String get groupLibrariesByServer => 'サーバーごとにライブラリをグループ化'; - @override String get groupLibrariesByServerDescription => '複数のサーバーに接続しているとき、サイドバーに各 Plex サーバーのヘッダーを表示します。'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'サイドバーを常に開いておく'; @override String get alwaysKeepSidebarOpenDescription => 'サイドバーを展開したまま、コンテンツ領域が調整される'; @override String get showUnwatchedCount => '未視聴数を表示'; @@ -385,8 +399,8 @@ class _TranslationsSettingsJa implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchJa implements TranslationsSearchEn { - _TranslationsSearchJa._(this._root); +class _TranslationsSearchJa extends TranslationsSearchEn { + _TranslationsSearchJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchJa implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysJa implements TranslationsHotkeysEn { - _TranslationsHotkeysJa._(this._root); +class _TranslationsHotkeysJa extends TranslationsHotkeysEn { + _TranslationsHotkeysJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysJa implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoJa implements TranslationsFileInfoEn { - _TranslationsFileInfoJa._(this._root); +class _TranslationsFileInfoJa extends TranslationsFileInfoEn { + _TranslationsFileInfoJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoJa implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuJa implements TranslationsMediaMenuEn { - _TranslationsMediaMenuJa._(this._root); +class _TranslationsMediaMenuJa extends TranslationsMediaMenuEn { + _TranslationsMediaMenuJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuJa implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityJa implements TranslationsAccessibilityEn { - _TranslationsAccessibilityJa._(this._root); +class _TranslationsAccessibilityJa extends TranslationsAccessibilityEn { + _TranslationsAccessibilityJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityJa implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsJa implements TranslationsTooltipsEn { - _TranslationsTooltipsJa._(this._root); +class _TranslationsTooltipsJa extends TranslationsTooltipsEn { + _TranslationsTooltipsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsJa implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsJa implements TranslationsVideoControlsEn { - _TranslationsVideoControlsJa._(this._root); +class _TranslationsVideoControlsJa extends TranslationsVideoControlsEn { + _TranslationsVideoControlsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsJa implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusJa implements TranslationsUserStatusEn { - _TranslationsUserStatusJa._(this._root); +class _TranslationsUserStatusJa extends TranslationsUserStatusEn { + _TranslationsUserStatusJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusJa implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesJa implements TranslationsMessagesEn { - _TranslationsMessagesJa._(this._root); +class _TranslationsMessagesJa extends TranslationsMessagesEn { + _TranslationsMessagesJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesJa implements TranslationsMessagesEn { @override String get musicNotSupported => '音楽の再生はまだサポートされていません'; @override String get noDescriptionAvailable => '説明はありません'; @override String get noProfilesAvailable => '利用可能なプロフィールがありません'; - @override String get contactAdminForProfiles => 'プロフィールを追加するにはPlex管理者にお問い合わせください'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'このアイテムのライブラリセクションを判別できません'; @override String get logsCleared => 'ログをクリアしました'; @override String get logsCopied => 'ログをクリップボードにコピーしました'; @@ -636,8 +650,8 @@ class _TranslationsMessagesJa implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingJa implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingJa._(this._root); +class _TranslationsSubtitlingStylingJa extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingJa implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigJa implements TranslationsMpvConfigEn { - _TranslationsMpvConfigJa._(this._root); +class _TranslationsMpvConfigJa extends TranslationsMpvConfigEn { + _TranslationsMpvConfigJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigJa implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogJa implements TranslationsDialogEn { - _TranslationsDialogJa._(this._root); +class _TranslationsDialogJa extends TranslationsDialogEn { + _TranslationsDialogJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogJa implements TranslationsDialogEn { @override String get confirmAction => '操作の確認'; } +// Path: profiles +class _TranslationsProfilesJa extends TranslationsProfilesEn { + _TranslationsProfilesJa._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Plezyプロファイルを追加'; + @override String get switchingProfile => 'プロファイルを切り替え中…'; + @override String get deleteThisProfileTitle => 'このプロファイルを削除しますか?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} が削除されます。接続自体は影響を受けません。'; + @override String get active => 'アクティブ'; + @override String get manage => '管理'; + @override String get delete => '削除'; + @override String get signOut => 'サインアウト'; + @override String get signOutPlexTitle => 'Plex からサインアウトしますか?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} とこのアカウントのすべての Plex Home ユーザーがこのデバイスから削除されます。いつでも再度サインインできます。'; + @override String get signedOutPlex => 'Plex からサインアウトしました。'; + @override String get signOutFailed => 'サインアウトに失敗しました。'; + @override String get sectionTitle => 'プロファイル'; + @override String get summarySingle => 'プロファイルを追加して、管理対象ユーザーとローカルIDを混在させます'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count}個のプロファイル · アクティブ: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count}個のプロファイル'; + @override String get removeConnectionTitle => '接続を削除しますか?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName}は${connectionLabel}へのアクセスを失います。接続自体は他のプロファイルで引き続き使用できます。'; + @override String get deleteProfileTitle => 'プロファイルを削除しますか?'; + @override String deleteProfileMessage({required Object displayName}) => 'このデバイスから${displayName}とそのすべての接続が削除されます。Plex/Jellyfinサーバー自体には影響しません。'; + @override String get profileNameLabel => 'プロファイル名'; + @override String get pinProtectionLabel => 'PIN保護'; + @override String get pinManagedByPlex => 'PINはPlexで管理されています。plex.tvで編集してください。'; + @override String get noPinSetEditOnPlex => 'PINが設定されていません。要求するには、plex.tvでHomeユーザーを編集してください。'; + @override String get setPin => 'PINを設定'; + @override String get connectionsLabel => '接続'; + @override String get add => '追加'; + @override String get deleteProfileButton => 'プロファイルを削除'; + @override String get noConnectionsHint => '接続がありません — このプロファイルを使うには1つ追加してください。'; + @override String get plexHomeAccount => 'Plex Homeアカウント'; + @override String get connectionDefault => 'デフォルト'; + @override String get makeDefault => 'デフォルトに設定'; + @override String get removeConnection => '削除'; + @override String borrowAddTo({required Object displayName}) => '${displayName}に追加'; + @override String get borrowExplain => '別のプロファイルから接続を借ります。PIN保護されたソースプロファイルは、共有前にPINを要求します。'; + @override String get borrowEmpty => 'まだ借りるものがありません。'; + @override String get borrowEmptySubtitle => 'まず別のプロファイルにPlexアカウントまたはJellyfinサーバーを接続してから、ここに戻ってきてください。'; + @override String get newProfile => '新しいプロファイル'; + @override String get profileNameHint => '例:ゲスト、キッズ、ファミリールーム'; + @override String get pinProtectionOptional => 'PIN保護(オプション)'; + @override String get pinExplain => 'このプロファイルに切り替えるには4桁のPINが必要です。ソフトバリア — アプリデータを消去できる人なら回避できます。'; + @override String get continueButton => '続ける'; + @override String get pinsDontMatch => 'PINが一致しません'; +} + +// Path: connections +class _TranslationsConnectionsJa extends TranslationsConnectionsEn { + _TranslationsConnectionsJa._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => '接続'; + @override String get addConnection => '接続を追加'; + @override String get addConnectionSubtitleNoProfile => 'Plexでサインインするか、Jellyfinサーバーに接続'; + @override String addConnectionSubtitleScoped({required Object displayName}) => '${displayName} に追加 — Plexアカウント、Jellyfinサーバー、または別のプロファイルから借用'; + @override String sessionExpiredOne({required Object name}) => '${name} のセッションの有効期限が切れました'; + @override String sessionExpiredMany({required Object count}) => '${count} 台のサーバーのセッションの有効期限が切れました'; + @override String get signInAgain => '再度サインイン'; +} + // Path: discover -class _TranslationsDiscoverJa implements TranslationsDiscoverEn { - _TranslationsDiscoverJa._(this._root); +class _TranslationsDiscoverJa extends TranslationsDiscoverEn { + _TranslationsDiscoverJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverJa implements TranslationsDiscoverEn { @override String get noContentAvailable => 'コンテンツがありません'; @override String get addMediaToLibraries => 'ライブラリにメディアを追加してください'; @override String get continueWatching => '視聴を続ける'; + @override String get nextUp => '次のエピソード'; + @override String get recentlyAdded => '最近追加'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'あらすじ'; @override String get cast => 'キャスト'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverJa implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsJa implements TranslationsErrorsEn { - _TranslationsErrorsJa._(this._root); +class _TranslationsErrorsJa extends TranslationsErrorsEn { + _TranslationsErrorsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => '検索に失敗しました: ${error}'; @override String connectionTimeout({required Object context}) => '${context}の読み込み中に接続がタイムアウトしました'; - @override String get connectionFailed => 'Plexサーバーに接続できません'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => '${context}の読み込みに失敗しました: ${error}'; @override String get noClientAvailable => 'クライアントが利用できません'; @override String authenticationFailed({required Object error}) => '認証に失敗しました: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsJa implements TranslationsErrorsEn { @override String get invalidToken => '無効なトークン'; @override String failedToVerifyToken({required Object error}) => 'トークンの検証に失敗しました: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => '${displayName}への切替に失敗しました'; + @override String failedToDeleteProfile({required Object displayName}) => '${displayName}の削除に失敗しました'; + @override String get failedToRate => '評価を更新できませんでした'; } // Path: libraries -class _TranslationsLibrariesJa implements TranslationsLibrariesEn { - _TranslationsLibrariesJa._(this._root); +class _TranslationsLibrariesJa extends TranslationsLibrariesEn { + _TranslationsLibrariesJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesJa implements TranslationsLibrariesEn { @override String get folders => 'フォルダ'; @override late final _TranslationsLibrariesTabsJa tabs = _TranslationsLibrariesTabsJa._(_root); @override late final _TranslationsLibrariesGroupingsJa groupings = _TranslationsLibrariesGroupingsJa._(_root); + @override late final _TranslationsLibrariesFilterCategoriesJa filterCategories = _TranslationsLibrariesFilterCategoriesJa._(_root); + @override late final _TranslationsLibrariesSortLabelsJa sortLabels = _TranslationsLibrariesSortLabelsJa._(_root); } // Path: about -class _TranslationsAboutJa implements TranslationsAboutEn { - _TranslationsAboutJa._(this._root); +class _TranslationsAboutJa extends TranslationsAboutEn { + _TranslationsAboutJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutJa implements TranslationsAboutEn { @override String get title => 'アプリについて'; @override String get openSourceLicenses => 'オープンソースライセンス'; @override String versionLabel({required Object version}) => 'バージョン ${version}'; - @override String get appDescription => 'Flutter製の美しいPlexクライアント'; + @override String get appDescription => 'Flutter製の美しいPlex・Jellyfinクライアント'; @override String get viewLicensesDescription => 'サードパーティライブラリのライセンスを表示'; } // Path: serverSelection -class _TranslationsServerSelectionJa implements TranslationsServerSelectionEn { - _TranslationsServerSelectionJa._(this._root); +class _TranslationsServerSelectionJa extends TranslationsServerSelectionEn { + _TranslationsServerSelectionJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionJa implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailJa implements TranslationsHubDetailEn { - _TranslationsHubDetailJa._(this._root); +class _TranslationsHubDetailJa extends TranslationsHubDetailEn { + _TranslationsHubDetailJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailJa implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsJa implements TranslationsLogsEn { - _TranslationsLogsJa._(this._root); +class _TranslationsLogsJa extends TranslationsLogsEn { + _TranslationsLogsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsJa implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesJa implements TranslationsLicensesEn { - _TranslationsLicensesJa._(this._root); +class _TranslationsLicensesJa extends TranslationsLicensesEn { + _TranslationsLicensesJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesJa implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationJa implements TranslationsNavigationEn { - _TranslationsNavigationJa._(this._root); +class _TranslationsNavigationJa extends TranslationsNavigationEn { + _TranslationsNavigationJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationJa implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvJa implements TranslationsLiveTvEn { - _TranslationsLiveTvJa._(this._root); +class _TranslationsLiveTvJa extends TranslationsLiveTvEn { + _TranslationsLiveTvJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvJa implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsJa implements TranslationsCollectionsEn { - _TranslationsCollectionsJa._(this._root); +class _TranslationsCollectionsJa extends TranslationsCollectionsEn { + _TranslationsCollectionsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsJa implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsJa implements TranslationsPlaylistsEn { - _TranslationsPlaylistsJa._(this._root); +class _TranslationsPlaylistsJa extends TranslationsPlaylistsEn { + _TranslationsPlaylistsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsJa implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherJa implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherJa._(this._root); +class _TranslationsWatchTogetherJa extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherJa implements TranslationsWatchTogetherEn { @override String get recentRooms => '最近のルーム'; @override String get renameRoom => 'ルーム名を変更'; @override String get removeRoom => '削除'; + @override String get guestSwitchUnavailable => '切り替えできません — サーバーが同期できません'; + @override String get guestSwitchFailed => '切り替えできません — このサーバーにコンテンツが見つかりません'; } // Path: downloads -class _TranslationsDownloadsJa implements TranslationsDownloadsEn { - _TranslationsDownloadsJa._(this._root); +class _TranslationsDownloadsJa extends TranslationsDownloadsEn { + _TranslationsDownloadsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsJa implements TranslationsDownloadsEn { @override String get editSyncFilter => '同期フィルター'; @override String get syncAllItems => 'すべてのアイテムを同期中'; @override String get syncUnwatchedItems => '未視聴のアイテムを同期中'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'サーバー: ${server} • ${status}'; + @override String get syncRuleAvailable => '利用可能'; + @override String get syncRuleOffline => 'オフライン'; + @override String get syncRuleSignInRequired => 'サインインが必要'; + @override String get syncRuleNotAvailableForProfile => '現在のプロフィールでは利用できません'; + @override String get syncRuleUnknownServer => '不明なサーバー'; @override String get syncRuleListCreated => '同期ルールを作成しました'; } // Path: shaders -class _TranslationsShadersJa implements TranslationsShadersEn { - _TranslationsShadersJa._(this._root); +class _TranslationsShadersJa extends TranslationsShadersEn { + _TranslationsShadersJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersJa implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteJa implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteJa._(this._root); +class _TranslationsCompanionRemoteJa extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteJa implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsJa implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsJa._(this._root); +class _TranslationsVideoSettingsJa extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsJa implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerJa implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerJa._(this._root); +class _TranslationsExternalPlayerJa extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerJa implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditJa implements TranslationsMetadataEditEn { - _TranslationsMetadataEditJa._(this._root); +class _TranslationsMetadataEditJa extends TranslationsMetadataEditEn { + _TranslationsMetadataEditJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditJa implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenJa implements TranslationsMatchScreenEn { - _TranslationsMatchScreenJa._(this._root); +class _TranslationsMatchScreenJa extends TranslationsMatchScreenEn { + _TranslationsMatchScreenJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenJa implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksJa implements TranslationsServerTasksEn { - _TranslationsServerTasksJa._(this._root); +class _TranslationsServerTasksJa extends TranslationsServerTasksEn { + _TranslationsServerTasksJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksJa implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktJa implements TranslationsTraktEn { - _TranslationsTraktJa._(this._root); +class _TranslationsTraktJa extends TranslationsTraktEn { + _TranslationsTraktJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktJa implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersJa implements TranslationsTrackersEn { - _TranslationsTrackersJa._(this._root); +class _TranslationsTrackersJa extends TranslationsTrackersEn { + _TranslationsTrackersJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersJa implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterJa libraryFilter = _TranslationsTrackersLibraryFilterJa._(_root); } +// Path: addServer +class _TranslationsAddServerJa extends TranslationsAddServerEn { + _TranslationsAddServerJa._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Jellyfinサーバーを追加'; + @override String get jellyfinUrlIntro => 'JellyfinサーバーのURLを入力してください — 例: `https://jellyfin.example.com`。サインインは後から行えます。'; + @override String get serverUrl => 'サーバーURL'; + @override String get findServer => 'サーバーを検索'; + @override String get username => 'ユーザー名'; + @override String get password => 'パスワード'; + @override String get signIn => 'サインイン'; + @override String get change => '変更'; + @override String get required => '必須'; + @override String couldNotReachServer({required Object error}) => 'サーバーに接続できませんでした: ${error}'; + @override String signInFailed({required Object error}) => 'サインインに失敗しました: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connectに失敗しました: ${error}'; + @override String get addPlexTitle => 'Plexでサインイン'; + @override String get plexAuthIntro => 'Plexへのサインイン方法を選択します。ブラウザフローではplex.tvが開き、接続を確認します。QRオプションはTVやリモートデバイスに便利です。'; + @override String get plexQRPrompt => 'このQRコードをスキャンしてサインインしてください。'; + @override String get waitingForPlexConfirmation => 'plex.tvがサインインを確認するのを待っています…'; + @override String get pinExpired => 'サインイン前にPINの有効期限が切れました。もう一度お試しください。'; + @override String get duplicatePlexAccount => 'このデバイスはすでにPlexアカウントにサインインしています。アカウントを切り替えるには設定からサインアウトしてください。'; + @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 addConnectionIntroGlobal => '別のメディアサーバーを追加します。PlexアカウントとJellyfinサーバーを組み合わせて使用でき、接続済みのすべてのバックエンドのアイテムがホーム画面に並びます。'; + @override String get addConnectionIntroScoped => '新しいサーバーを追加するか、別のプロファイルから借りてください。'; + @override String get signInWithPlexCard => 'Plexでサインイン'; + @override String get signInWithPlexCardSubtitle => 'このデバイスをあなたのPlexアカウントで認証します。アカウントで共有されているサーバーは自動的に含まれます。'; + @override String get signInWithPlexCardSubtitleScoped => '新しいPlexアカウントを認証します。そのHomeユーザーがプロファイルとして表示されます。'; + @override String get connectToJellyfinCard => 'Jellyfinに接続'; + @override String get connectToJellyfinCardSubtitle => 'JellyfinサーバーのURLを入力し、ユーザー名+パスワードでサインインします(Quick Connectは近日対応予定)。'; + @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfinサーバーにサインインします。${name}に紐付けられます。'; + @override String get borrowFromAnotherProfile => '別のプロファイルから借りる'; + @override String get borrowFromAnotherProfileSubtitle => '別のプロファイルに紐付け済みの接続を再利用します。PIN保護されたソースプロファイルはPINの入力を求めます。'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsJa implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsJa._(this._root); +class _TranslationsHotkeysActionsJa extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsJa implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsJa implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsJa._(this._root); +class _TranslationsVideoControlsPipErrorsJa extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsJa implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsJa implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsJa._(this._root); +class _TranslationsLibrariesTabsJa extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsJa implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsJa implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsJa._(this._root); +class _TranslationsLibrariesGroupingsJa extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsJa implements TranslationsLibrariesGrouping @override String get folders => 'フォルダ'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesJa extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesJa._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get genre => 'ジャンル'; + @override String get year => '年'; + @override String get contentRating => '視聴年齢区分'; + @override String get tag => 'タグ'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsJa extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsJa._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get title => 'タイトル'; + @override String get dateAdded => '追加日'; + @override String get releaseDate => 'リリース日'; + @override String get rating => '評価'; + @override String get lastPlayed => '最終再生'; + @override String get playCount => '再生回数'; + @override String get random => 'ランダム'; + @override String get dateShared => '共有日'; + @override String get latestEpisodeAirDate => '最新エピソード放送日'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionJa implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionJa._(this._root); +class _TranslationsCompanionRemoteSessionJa extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionJa implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingJa implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingJa._(this._root); +class _TranslationsCompanionRemotePairingJa extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingJa implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteJa implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteJa._(this._root); +class _TranslationsCompanionRemoteRemoteJa extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteJa implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesJa implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesJa._(this._root); +class _TranslationsTrackersServicesJa extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesJa implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeJa implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeJa._(this._root); +class _TranslationsTrackersDeviceCodeJa extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeJa implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyJa implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyJa._(this._root); +class _TranslationsTrackersOauthProxyJa extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyJa implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterJa implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterJa._(this._root); +class _TranslationsTrackersLibraryFilterJa extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterJa._(TranslationsJa root) : this._root = root, super.internal(root); final TranslationsJa _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsJa { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'サインイン', 'auth.signInWithPlex' => 'Plexでサインイン', 'auth.showQRCode' => 'QRコードを表示', 'auth.authenticate' => '認証', @@ -1550,6 +1719,14 @@ extension on TranslationsJa { 'auth.scanQRToSignIn' => 'このQRコードをスキャンしてサインイン', 'auth.waitingForAuth' => '認証を待機中...\nブラウザでサインインを完了してください。', 'auth.useBrowser' => 'ブラウザを使用', + 'auth.or' => 'または', + 'auth.connectToJellyfin' => 'Jellyfinに接続', + 'auth.useQuickConnect' => 'Quick Connect を使う', + 'auth.quickConnectCode' => 'Quick Connect コード', + 'auth.quickConnectInstructions' => 'Web ブラウザで Jellyfin サーバーを開いてログインし、ユーザーメニューから Quick Connect を選択します。このコードを入力してサインインを承認してください。', + 'auth.quickConnectWaiting' => '承認を待っています…', + 'auth.quickConnectCancel' => 'キャンセル', + 'auth.quickConnectExpired' => '承認される前に Quick Connect コードの有効期限が切れました。もう一度お試しください。', 'common.cancel' => 'キャンセル', 'common.save' => '保存', 'common.close' => '閉じる', @@ -1636,12 +1813,12 @@ extension on TranslationsJa { 'settings.gridView' => 'グリッド', 'settings.listView' => 'リスト', 'settings.showHeroSection' => 'ヒーローセクションを表示', - 'settings.useGlobalHubs' => 'Plex Homeレイアウトを使用', - 'settings.useGlobalHubsDescription' => '公式Plexクライアントのようにホームページのハブを表示。オフにすると、ライブラリごとのおすすめを表示。', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'ハブにサーバー名を表示', 'settings.showServerNameOnHubsDescription' => 'ハブタイトルに常にサーバー名を表示。オフにすると、重複名のみ表示。', 'settings.groupLibrariesByServer' => 'サーバーごとにライブラリをグループ化', - 'settings.groupLibrariesByServerDescription' => '複数のサーバーに接続しているとき、サイドバーに各 Plex サーバーのヘッダーを表示します。', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'サイドバーを常に開いておく', 'settings.alwaysKeepSidebarOpenDescription' => 'サイドバーを展開したまま、コンテンツ領域が調整される', 'settings.showUnwatchedCount' => '未視聴数を表示', @@ -1962,7 +2139,7 @@ extension on TranslationsJa { 'messages.musicNotSupported' => '音楽の再生はまだサポートされていません', 'messages.noDescriptionAvailable' => '説明はありません', 'messages.noProfilesAvailable' => '利用可能なプロフィールがありません', - 'messages.contactAdminForProfiles' => 'プロフィールを追加するにはPlex管理者にお問い合わせください', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'このアイテムのライブラリセクションを判別できません', 'messages.logsCleared' => 'ログをクリアしました', 'messages.logsCopied' => 'ログをクリップボードにコピーしました', @@ -2016,11 +2193,65 @@ extension on TranslationsJa { 'mpvConfig.confirmDeletePreset' => 'このプリセットを削除してもよろしいですか?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => '操作の確認', + 'profiles.addPlezyProfile' => 'Plezyプロファイルを追加', + 'profiles.switchingProfile' => 'プロファイルを切り替え中…', + 'profiles.deleteThisProfileTitle' => 'このプロファイルを削除しますか?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} が削除されます。接続自体は影響を受けません。', + 'profiles.active' => 'アクティブ', + 'profiles.manage' => '管理', + 'profiles.delete' => '削除', + 'profiles.signOut' => 'サインアウト', + 'profiles.signOutPlexTitle' => 'Plex からサインアウトしますか?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} とこのアカウントのすべての Plex Home ユーザーがこのデバイスから削除されます。いつでも再度サインインできます。', + 'profiles.signedOutPlex' => 'Plex からサインアウトしました。', + 'profiles.signOutFailed' => 'サインアウトに失敗しました。', + 'profiles.sectionTitle' => 'プロファイル', + 'profiles.summarySingle' => 'プロファイルを追加して、管理対象ユーザーとローカルIDを混在させます', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count}個のプロファイル · アクティブ: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count}個のプロファイル', + 'profiles.removeConnectionTitle' => '接続を削除しますか?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName}は${connectionLabel}へのアクセスを失います。接続自体は他のプロファイルで引き続き使用できます。', + 'profiles.deleteProfileTitle' => 'プロファイルを削除しますか?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'このデバイスから${displayName}とそのすべての接続が削除されます。Plex/Jellyfinサーバー自体には影響しません。', + 'profiles.profileNameLabel' => 'プロファイル名', + 'profiles.pinProtectionLabel' => 'PIN保護', + 'profiles.pinManagedByPlex' => 'PINはPlexで管理されています。plex.tvで編集してください。', + 'profiles.noPinSetEditOnPlex' => 'PINが設定されていません。要求するには、plex.tvでHomeユーザーを編集してください。', + 'profiles.setPin' => 'PINを設定', + 'profiles.connectionsLabel' => '接続', + 'profiles.add' => '追加', + 'profiles.deleteProfileButton' => 'プロファイルを削除', + 'profiles.noConnectionsHint' => '接続がありません — このプロファイルを使うには1つ追加してください。', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Plex Homeアカウント', + 'profiles.connectionDefault' => 'デフォルト', + 'profiles.makeDefault' => 'デフォルトに設定', + 'profiles.removeConnection' => '削除', + 'profiles.borrowAddTo' => ({required Object displayName}) => '${displayName}に追加', + 'profiles.borrowExplain' => '別のプロファイルから接続を借ります。PIN保護されたソースプロファイルは、共有前にPINを要求します。', + 'profiles.borrowEmpty' => 'まだ借りるものがありません。', + 'profiles.borrowEmptySubtitle' => 'まず別のプロファイルにPlexアカウントまたはJellyfinサーバーを接続してから、ここに戻ってきてください。', + 'profiles.newProfile' => '新しいプロファイル', + 'profiles.profileNameHint' => '例:ゲスト、キッズ、ファミリールーム', + 'profiles.pinProtectionOptional' => 'PIN保護(オプション)', + 'profiles.pinExplain' => 'このプロファイルに切り替えるには4桁のPINが必要です。ソフトバリア — アプリデータを消去できる人なら回避できます。', + 'profiles.continueButton' => '続ける', + 'profiles.pinsDontMatch' => 'PINが一致しません', + 'connections.sectionTitle' => '接続', + 'connections.addConnection' => '接続を追加', + 'connections.addConnectionSubtitleNoProfile' => 'Plexでサインインするか、Jellyfinサーバーに接続', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => '${displayName} に追加 — Plexアカウント、Jellyfinサーバー、または別のプロファイルから借用', + 'connections.sessionExpiredOne' => ({required Object name}) => '${name} のセッションの有効期限が切れました', + 'connections.sessionExpiredMany' => ({required Object count}) => '${count} 台のサーバーのセッションの有効期限が切れました', + 'connections.signInAgain' => '再度サインイン', 'discover.title' => '探す', 'discover.switchProfile' => 'プロフィール切替', 'discover.noContentAvailable' => 'コンテンツがありません', 'discover.addMediaToLibraries' => 'ライブラリにメディアを追加してください', 'discover.continueWatching' => '視聴を続ける', + 'discover.nextUp' => '次のエピソード', + 'discover.recentlyAdded' => '最近追加', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'あらすじ', 'discover.cast' => 'キャスト', @@ -2032,7 +2263,7 @@ extension on TranslationsJa { 'discover.minutesLeft' => ({required Object minutes}) => '残り${minutes}分', 'errors.searchFailed' => ({required Object error}) => '検索に失敗しました: ${error}', 'errors.connectionTimeout' => ({required Object context}) => '${context}の読み込み中に接続がタイムアウトしました', - 'errors.connectionFailed' => 'Plexサーバーに接続できません', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => '${context}の読み込みに失敗しました: ${error}', 'errors.noClientAvailable' => 'クライアントが利用できません', 'errors.authenticationFailed' => ({required Object error}) => '認証に失敗しました: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsJa { 'errors.invalidToken' => '無効なトークン', 'errors.failedToVerifyToken' => ({required Object error}) => 'トークンの検証に失敗しました: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => '${displayName}への切替に失敗しました', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => '${displayName}の削除に失敗しました', + 'errors.failedToRate' => '評価を更新できませんでした', 'libraries.title' => 'ライブラリ', 'libraries.scanLibraryFiles' => 'ライブラリファイルをスキャン', 'libraries.scanLibrary' => 'ライブラリをスキャン', @@ -2054,8 +2287,6 @@ extension on TranslationsJa { 'libraries.analyzing' => ({required Object title}) => '"${title}"を解析中...', 'libraries.analysisStarted' => ({required Object title}) => '"${title}"の解析を開始しました', 'libraries.failedToAnalyze' => ({required Object error}) => 'ライブラリの解析に失敗しました: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'ライブラリが見つかりません', 'libraries.allLibrariesHidden' => 'すべてのライブラリが非表示です', 'libraries.hiddenLibrariesCount' => ({required Object count}) => '非表示のライブラリ (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsJa { 'libraries.groupings.seasons' => 'シーズン', 'libraries.groupings.episodes' => 'エピソード', 'libraries.groupings.folders' => 'フォルダ', + 'libraries.filterCategories.genre' => 'ジャンル', + 'libraries.filterCategories.year' => '年', + 'libraries.filterCategories.contentRating' => '視聴年齢区分', + 'libraries.filterCategories.tag' => 'タグ', + 'libraries.sortLabels.title' => 'タイトル', + 'libraries.sortLabels.dateAdded' => '追加日', + 'libraries.sortLabels.releaseDate' => 'リリース日', + 'libraries.sortLabels.rating' => '評価', + 'libraries.sortLabels.lastPlayed' => '最終再生', + 'libraries.sortLabels.playCount' => '再生回数', + 'libraries.sortLabels.random' => 'ランダム', + 'libraries.sortLabels.dateShared' => '共有日', + 'libraries.sortLabels.latestEpisodeAirDate' => '最新エピソード放送日', 'about.title' => 'アプリについて', 'about.openSourceLicenses' => 'オープンソースライセンス', 'about.versionLabel' => ({required Object version}) => 'バージョン ${version}', - 'about.appDescription' => 'Flutter製の美しいPlexクライアント', + 'about.appDescription' => 'Flutter製の美しいPlex・Jellyfinクライアント', 'about.viewLicensesDescription' => 'サードパーティライブラリのライセンスを表示', 'serverSelection.allServerConnectionsFailed' => 'どのサーバーにも接続できませんでした。ネットワークを確認してもう一度お試しください。', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => '${username} (${email})のサーバーが見つかりません', @@ -2243,6 +2487,8 @@ extension on TranslationsJa { 'watchTogether.recentRooms' => '最近のルーム', 'watchTogether.renameRoom' => 'ルーム名を変更', 'watchTogether.removeRoom' => '削除', + 'watchTogether.guestSwitchUnavailable' => '切り替えできません — サーバーが同期できません', + 'watchTogether.guestSwitchFailed' => '切り替えできません — このサーバーにコンテンツが見つかりません', 'downloads.title' => 'ダウンロード', 'downloads.manage' => '管理', 'downloads.tvShows' => 'テレビ番組', @@ -2291,6 +2537,12 @@ extension on TranslationsJa { 'downloads.editSyncFilter' => '同期フィルター', 'downloads.syncAllItems' => 'すべてのアイテムを同期中', 'downloads.syncUnwatchedItems' => '未視聴のアイテムを同期中', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'サーバー: ${server} • ${status}', + 'downloads.syncRuleAvailable' => '利用可能', + 'downloads.syncRuleOffline' => 'オフライン', + 'downloads.syncRuleSignInRequired' => 'サインインが必要', + 'downloads.syncRuleNotAvailableForProfile' => '現在のプロフィールでは利用できません', + 'downloads.syncRuleUnknownServer' => '不明なサーバー', 'downloads.syncRuleListCreated' => '同期ルールを作成しました', 'shaders.title' => 'シェーダー', 'shaders.noShaderDescription' => '映像補正なし', @@ -2484,6 +2736,8 @@ extension on TranslationsJa { 'trakt.disconnectConfirmBody' => 'Plezy から Trakt への再生イベント送信が停止します。いつでも再接続できます。', 'trakt.scrobble' => 'リアルタイムのスクロブル', 'trakt.scrobbleDescription' => '再生中に再生・一時停止・停止イベントを Trakt に送信します。', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => '視聴済みステータスを同期', 'trakt.watchedSyncDescription' => 'Plezy で項目を視聴済みにすると、Trakt でも視聴済みになります。', 'trackers.title' => 'トラッカー', @@ -2519,6 +2773,38 @@ extension on TranslationsJa { 'trackers.libraryFilter.modeHintWhitelist' => '下でチェックしたライブラリのみ同期します。', 'trackers.libraryFilter.libraries' => 'ライブラリ', 'trackers.libraryFilter.noLibraries' => '利用できるライブラリがありません', + 'addServer.addJellyfinTitle' => 'Jellyfinサーバーを追加', + 'addServer.jellyfinUrlIntro' => 'JellyfinサーバーのURLを入力してください — 例: `https://jellyfin.example.com`。サインインは後から行えます。', + 'addServer.serverUrl' => 'サーバーURL', + 'addServer.findServer' => 'サーバーを検索', + 'addServer.username' => 'ユーザー名', + 'addServer.password' => 'パスワード', + 'addServer.signIn' => 'サインイン', + 'addServer.change' => '変更', + 'addServer.required' => '必須', + 'addServer.couldNotReachServer' => ({required Object error}) => 'サーバーに接続できませんでした: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'サインインに失敗しました: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connectに失敗しました: ${error}', + 'addServer.addPlexTitle' => 'Plexでサインイン', + 'addServer.plexAuthIntro' => 'Plexへのサインイン方法を選択します。ブラウザフローではplex.tvが開き、接続を確認します。QRオプションはTVやリモートデバイスに便利です。', + 'addServer.plexQRPrompt' => 'このQRコードをスキャンしてサインインしてください。', + 'addServer.waitingForPlexConfirmation' => 'plex.tvがサインインを確認するのを待っています…', + 'addServer.pinExpired' => 'サインイン前にPINの有効期限が切れました。もう一度お試しください。', + 'addServer.duplicatePlexAccount' => 'このデバイスはすでにPlexアカウントにサインインしています。アカウントを切り替えるには設定からサインアウトしてください。', + 'addServer.failedToRegisterAccount' => ({required Object error}) => 'アカウントの登録に失敗しました: ${error}', + 'addServer.enterJellyfinUrlError' => 'JellyfinサーバーのURLを入力してください', + 'addServer.addConnectionTitle' => '接続を追加', + 'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name}に追加', + 'addServer.addConnectionIntroGlobal' => '別のメディアサーバーを追加します。PlexアカウントとJellyfinサーバーを組み合わせて使用でき、接続済みのすべてのバックエンドのアイテムがホーム画面に並びます。', + 'addServer.addConnectionIntroScoped' => '新しいサーバーを追加するか、別のプロファイルから借りてください。', + 'addServer.signInWithPlexCard' => 'Plexでサインイン', + 'addServer.signInWithPlexCardSubtitle' => 'このデバイスをあなたのPlexアカウントで認証します。アカウントで共有されているサーバーは自動的に含まれます。', + 'addServer.signInWithPlexCardSubtitleScoped' => '新しいPlexアカウントを認証します。そのHomeユーザーがプロファイルとして表示されます。', + 'addServer.connectToJellyfinCard' => 'Jellyfinに接続', + 'addServer.connectToJellyfinCardSubtitle' => 'JellyfinサーバーのURLを入力し、ユーザー名+パスワードでサインインします(Quick Connectは近日対応予定)。', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Jellyfinサーバーにサインインします。${name}に紐付けられます。', + 'addServer.borrowFromAnotherProfile' => '別のプロファイルから借りる', + 'addServer.borrowFromAnotherProfileSubtitle' => '別のプロファイルに紐付け済みの接続を再利用します。PIN保護されたソースプロファイルはPINの入力を求めます。', _ => null, }; } diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 751fe01f..f3a2d99a 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsKo with BaseTranslations implements Translations { +class TranslationsKo extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsKo({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsKo with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsKo with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsKo _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsKo with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingKo subtitlingStyling = _TranslationsSubtitlingStylingKo._(_root); @override late final _TranslationsMpvConfigKo mpvConfig = _TranslationsMpvConfigKo._(_root); @override late final _TranslationsDialogKo dialog = _TranslationsDialogKo._(_root); + @override late final _TranslationsProfilesKo profiles = _TranslationsProfilesKo._(_root); + @override late final _TranslationsConnectionsKo connections = _TranslationsConnectionsKo._(_root); @override late final _TranslationsDiscoverKo discover = _TranslationsDiscoverKo._(_root); @override late final _TranslationsErrorsKo errors = _TranslationsErrorsKo._(_root); @override late final _TranslationsLibrariesKo libraries = _TranslationsLibrariesKo._(_root); @@ -78,11 +82,12 @@ class TranslationsKo with BaseTranslations implements T @override late final _TranslationsServerTasksKo serverTasks = _TranslationsServerTasksKo._(_root); @override late final _TranslationsTraktKo trakt = _TranslationsTraktKo._(_root); @override late final _TranslationsTrackersKo trackers = _TranslationsTrackersKo._(_root); + @override late final _TranslationsAddServerKo addServer = _TranslationsAddServerKo._(_root); } // Path: app -class _TranslationsAppKo implements TranslationsAppEn { - _TranslationsAppKo._(this._root); +class _TranslationsAppKo extends TranslationsAppEn { + _TranslationsAppKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppKo implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthKo implements TranslationsAuthEn { - _TranslationsAuthKo._(this._root); +class _TranslationsAuthKo extends TranslationsAuthEn { + _TranslationsAuthKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field // Translations + @override String get signIn => '로그인'; @override String get signInWithPlex => 'Plex 계정으로 로그인'; @override String get showQRCode => 'QR 코드'; @override String get authenticate => '인증'; @@ -104,11 +110,19 @@ class _TranslationsAuthKo implements TranslationsAuthEn { @override String get scanQRToSignIn => 'QR 코드를 스캔하여 로그인'; @override String get waitingForAuth => '인증 대기 중... 브라우저에서 로그인을 완료해 주세요.'; @override String get useBrowser => '브라우저 사용'; + @override String get or => '또는'; + @override String get connectToJellyfin => 'Jellyfin에 연결'; + @override String get useQuickConnect => 'Quick Connect 사용'; + @override String get quickConnectCode => 'Quick Connect 코드'; + @override String get quickConnectInstructions => '웹 브라우저에서 Jellyfin 서버를 열어 로그인한 뒤 사용자 메뉴에서 Quick Connect를 선택하세요. 이 코드를 입력해 로그인을 승인하세요.'; + @override String get quickConnectWaiting => '승인 대기 중…'; + @override String get quickConnectCancel => '취소'; + @override String get quickConnectExpired => '승인되기 전에 Quick Connect 코드가 만료되었습니다. 다시 시도하세요.'; } // Path: common -class _TranslationsCommonKo implements TranslationsCommonEn { - _TranslationsCommonKo._(this._root); +class _TranslationsCommonKo extends TranslationsCommonEn { + _TranslationsCommonKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonKo implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensKo implements TranslationsScreensEn { - _TranslationsScreensKo._(this._root); +class _TranslationsScreensKo extends TranslationsScreensEn { + _TranslationsScreensKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensKo implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateKo implements TranslationsUpdateEn { - _TranslationsUpdateKo._(this._root); +class _TranslationsUpdateKo extends TranslationsUpdateEn { + _TranslationsUpdateKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateKo implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsKo implements TranslationsSettingsEn { - _TranslationsSettingsKo._(this._root); +class _TranslationsSettingsKo extends TranslationsSettingsEn { + _TranslationsSettingsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn { @override String get gridView => '그리드 보기'; @override String get listView => '목록 보기'; @override String get showHeroSection => '주요 추천 영역 표시'; - @override String get useGlobalHubs => 'Plex 홈 레이아웃 사용'; - @override String get useGlobalHubsDescription => '공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => '허브에 서버 이름 표시'; @override String get showServerNameOnHubsDescription => '허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.'; @override String get groupLibrariesByServer => '서버별로 라이브러리 그룹화'; - @override String get groupLibrariesByServerDescription => '여러 서버에 연결되어 있을 때 사이드바에 각 Plex 서버의 헤더를 표시합니다.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => '사이드바 항상 열어두기'; @override String get alwaysKeepSidebarOpenDescription => '사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다'; @override String get showUnwatchedCount => '미시청 수 표시'; @@ -385,8 +399,8 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchKo implements TranslationsSearchEn { - _TranslationsSearchKo._(this._root); +class _TranslationsSearchKo extends TranslationsSearchEn { + _TranslationsSearchKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchKo implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysKo implements TranslationsHotkeysEn { - _TranslationsHotkeysKo._(this._root); +class _TranslationsHotkeysKo extends TranslationsHotkeysEn { + _TranslationsHotkeysKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysKo implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoKo implements TranslationsFileInfoEn { - _TranslationsFileInfoKo._(this._root); +class _TranslationsFileInfoKo extends TranslationsFileInfoEn { + _TranslationsFileInfoKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoKo implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuKo implements TranslationsMediaMenuEn { - _TranslationsMediaMenuKo._(this._root); +class _TranslationsMediaMenuKo extends TranslationsMediaMenuEn { + _TranslationsMediaMenuKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuKo implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityKo implements TranslationsAccessibilityEn { - _TranslationsAccessibilityKo._(this._root); +class _TranslationsAccessibilityKo extends TranslationsAccessibilityEn { + _TranslationsAccessibilityKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityKo implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsKo implements TranslationsTooltipsEn { - _TranslationsTooltipsKo._(this._root); +class _TranslationsTooltipsKo extends TranslationsTooltipsEn { + _TranslationsTooltipsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsKo implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn { - _TranslationsVideoControlsKo._(this._root); +class _TranslationsVideoControlsKo extends TranslationsVideoControlsEn { + _TranslationsVideoControlsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsKo implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusKo implements TranslationsUserStatusEn { - _TranslationsUserStatusKo._(this._root); +class _TranslationsUserStatusKo extends TranslationsUserStatusEn { + _TranslationsUserStatusKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusKo implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesKo implements TranslationsMessagesEn { - _TranslationsMessagesKo._(this._root); +class _TranslationsMessagesKo extends TranslationsMessagesEn { + _TranslationsMessagesKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesKo implements TranslationsMessagesEn { @override String get musicNotSupported => '음악 재생 미지원'; @override String get noDescriptionAvailable => '설명이 없습니다'; @override String get noProfilesAvailable => '사용 가능한 프로필이 없습니다'; - @override String get contactAdminForProfiles => '프로필을 추가하려면 Plex 관리자에게 문의하세요'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => '이 항목의 라이브러리 섹션을 확인할 수 없습니다'; @override String get logsCleared => '로그가 삭제 되었습니다'; @override String get logsCopied => '로그가 클립보드에 복사 되었습니다'; @@ -636,8 +650,8 @@ class _TranslationsMessagesKo implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingKo implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingKo._(this._root); +class _TranslationsSubtitlingStylingKo extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingKo implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigKo implements TranslationsMpvConfigEn { - _TranslationsMpvConfigKo._(this._root); +class _TranslationsMpvConfigKo extends TranslationsMpvConfigEn { + _TranslationsMpvConfigKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigKo implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogKo implements TranslationsDialogEn { - _TranslationsDialogKo._(this._root); +class _TranslationsDialogKo extends TranslationsDialogEn { + _TranslationsDialogKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogKo implements TranslationsDialogEn { @override String get confirmAction => '확인'; } +// Path: profiles +class _TranslationsProfilesKo extends TranslationsProfilesEn { + _TranslationsProfilesKo._(TranslationsKo root) : this._root = root, super.internal(root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Plezy 프로필 추가'; + @override String get switchingProfile => '프로필 전환 중…'; + @override String get deleteThisProfileTitle => '이 프로필을 삭제하시겠습니까?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} 이(가) 제거됩니다. 연결 자체는 영향을 받지 않습니다.'; + @override String get active => '활성'; + @override String get manage => '관리'; + @override String get delete => '삭제'; + @override String get signOut => '로그아웃'; + @override String get signOutPlexTitle => 'Plex에서 로그아웃하시겠습니까?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} 및 이 계정의 모든 Plex Home 사용자가 이 기기에서 제거됩니다. 언제든지 다시 로그인할 수 있습니다.'; + @override String get signedOutPlex => 'Plex에서 로그아웃되었습니다.'; + @override String get signOutFailed => '로그아웃에 실패했습니다.'; + @override String get sectionTitle => '프로필'; + @override String get summarySingle => '관리되는 사용자와 로컬 ID를 혼합하려면 프로필을 추가하세요'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count}개 프로필 · 활성: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count}개 프로필'; + @override String get removeConnectionTitle => '연결을 제거하시겠습니까?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName}이(가) ${connectionLabel}에 대한 액세스를 잃게 됩니다. 연결 자체는 다른 프로필에서 계속 사용할 수 있습니다.'; + @override String get deleteProfileTitle => '프로필을 삭제하시겠습니까?'; + @override String deleteProfileMessage({required Object displayName}) => '이 기기에서 ${displayName}과(와) 모든 연결이 제거됩니다. 기본 Plex/Jellyfin 서버에는 영향을 주지 않습니다.'; + @override String get profileNameLabel => '프로필 이름'; + @override String get pinProtectionLabel => 'PIN 보호'; + @override String get pinManagedByPlex => 'PIN은 Plex에서 관리됩니다. plex.tv에서 편집하세요.'; + @override String get noPinSetEditOnPlex => '설정된 PIN이 없습니다. 요구하려면 plex.tv에서 Home 사용자를 편집하세요.'; + @override String get setPin => 'PIN 설정'; + @override String get connectionsLabel => '연결'; + @override String get add => '추가'; + @override String get deleteProfileButton => '프로필 삭제'; + @override String get noConnectionsHint => '연결이 없습니다 — 이 프로필을 사용하려면 하나 추가하세요.'; + @override String get plexHomeAccount => 'Plex Home 계정'; + @override String get connectionDefault => '기본값'; + @override String get makeDefault => '기본값으로 설정'; + @override String get removeConnection => '제거'; + @override String borrowAddTo({required Object displayName}) => '${displayName}에 추가'; + @override String get borrowExplain => '다른 프로필에서 연결을 빌립니다. PIN 보호된 원본 프로필은 공유 전에 PIN을 요구합니다.'; + @override String get borrowEmpty => '아직 빌릴 것이 없습니다.'; + @override String get borrowEmptySubtitle => '먼저 다른 프로필에 Plex 계정 또는 Jellyfin 서버를 연결한 다음 여기로 돌아오세요.'; + @override String get newProfile => '새 프로필'; + @override String get profileNameHint => '예: 손님, 어린이, 가족실'; + @override String get pinProtectionOptional => 'PIN 보호 (선택 사항)'; + @override String get pinExplain => '이 프로필로 전환하려면 4자리 PIN이 필요합니다. 부드러운 장벽 — 앱 데이터를 지울 수 있는 사람은 우회할 수 있습니다.'; + @override String get continueButton => '계속'; + @override String get pinsDontMatch => 'PIN이 일치하지 않습니다'; +} + +// Path: connections +class _TranslationsConnectionsKo extends TranslationsConnectionsEn { + _TranslationsConnectionsKo._(TranslationsKo root) : this._root = root, super.internal(root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => '연결'; + @override String get addConnection => '연결 추가'; + @override String get addConnectionSubtitleNoProfile => 'Plex로 로그인하거나 Jellyfin 서버에 연결'; + @override String addConnectionSubtitleScoped({required Object displayName}) => '${displayName} 에 추가 — Plex 계정, Jellyfin 서버 또는 다른 프로필에서 빌리기'; + @override String sessionExpiredOne({required Object name}) => '${name} 의 세션이 만료되었습니다'; + @override String sessionExpiredMany({required Object count}) => '${count} 개의 서버에서 세션이 만료되었습니다'; + @override String get signInAgain => '다시 로그인'; +} + // Path: discover -class _TranslationsDiscoverKo implements TranslationsDiscoverEn { - _TranslationsDiscoverKo._(this._root); +class _TranslationsDiscoverKo extends TranslationsDiscoverEn { + _TranslationsDiscoverKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverKo implements TranslationsDiscoverEn { @override String get noContentAvailable => '사용 가능한 콘텐츠가 없습니다'; @override String get addMediaToLibraries => '미디어 라이브러리에 미디어를 추가해 주세요'; @override String get continueWatching => '계속 시청'; + @override String get nextUp => '다음 에피소드'; + @override String get recentlyAdded => '최근에 추가됨'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => '개요'; @override String get cast => '출연진'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverKo implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsKo implements TranslationsErrorsEn { - _TranslationsErrorsKo._(this._root); +class _TranslationsErrorsKo extends TranslationsErrorsEn { + _TranslationsErrorsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => '검색 실패: ${error}'; @override String connectionTimeout({required Object context}) => '${context} 로드 중 연결 시간 초과'; - @override String get connectionFailed => 'Plex 서버에 연결할 수 없음'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => '${context} 로드 실패: ${error}'; @override String get noClientAvailable => '사용 가능한 클라이언트가 없습니다'; @override String authenticationFailed({required Object error}) => '인증 실패: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsKo implements TranslationsErrorsEn { @override String get invalidToken => '토큰이 유효하지 않습니다'; @override String failedToVerifyToken({required Object error}) => '토큰을 확인할 수 없습니다: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => '${displayName}으로 전환할 수 없습니다'; + @override String failedToDeleteProfile({required Object displayName}) => '${displayName}을(를) 삭제할 수 없습니다'; + @override String get failedToRate => '평점을 업데이트하지 못했습니다'; } // Path: libraries -class _TranslationsLibrariesKo implements TranslationsLibrariesEn { - _TranslationsLibrariesKo._(this._root); +class _TranslationsLibrariesKo extends TranslationsLibrariesEn { + _TranslationsLibrariesKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesKo implements TranslationsLibrariesEn { @override String get folders => '폴더'; @override late final _TranslationsLibrariesTabsKo tabs = _TranslationsLibrariesTabsKo._(_root); @override late final _TranslationsLibrariesGroupingsKo groupings = _TranslationsLibrariesGroupingsKo._(_root); + @override late final _TranslationsLibrariesFilterCategoriesKo filterCategories = _TranslationsLibrariesFilterCategoriesKo._(_root); + @override late final _TranslationsLibrariesSortLabelsKo sortLabels = _TranslationsLibrariesSortLabelsKo._(_root); } // Path: about -class _TranslationsAboutKo implements TranslationsAboutEn { - _TranslationsAboutKo._(this._root); +class _TranslationsAboutKo extends TranslationsAboutEn { + _TranslationsAboutKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutKo implements TranslationsAboutEn { @override String get title => '소개'; @override String get openSourceLicenses => '오픈소스 라이선스'; @override String versionLabel({required Object version}) => '버전 ${version}'; - @override String get appDescription => '아름다운 Flutter Plex 클라이언트'; + @override String get appDescription => '아름다운 Flutter용 Plex 및 Jellyfin 클라이언트'; @override String get viewLicensesDescription => '타사 라이브러리 라이선스 보기'; } // Path: serverSelection -class _TranslationsServerSelectionKo implements TranslationsServerSelectionEn { - _TranslationsServerSelectionKo._(this._root); +class _TranslationsServerSelectionKo extends TranslationsServerSelectionEn { + _TranslationsServerSelectionKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionKo implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailKo implements TranslationsHubDetailEn { - _TranslationsHubDetailKo._(this._root); +class _TranslationsHubDetailKo extends TranslationsHubDetailEn { + _TranslationsHubDetailKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailKo implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsKo implements TranslationsLogsEn { - _TranslationsLogsKo._(this._root); +class _TranslationsLogsKo extends TranslationsLogsEn { + _TranslationsLogsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsKo implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesKo implements TranslationsLicensesEn { - _TranslationsLicensesKo._(this._root); +class _TranslationsLicensesKo extends TranslationsLicensesEn { + _TranslationsLicensesKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesKo implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationKo implements TranslationsNavigationEn { - _TranslationsNavigationKo._(this._root); +class _TranslationsNavigationKo extends TranslationsNavigationEn { + _TranslationsNavigationKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationKo implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvKo implements TranslationsLiveTvEn { - _TranslationsLiveTvKo._(this._root); +class _TranslationsLiveTvKo extends TranslationsLiveTvEn { + _TranslationsLiveTvKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvKo implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsKo implements TranslationsCollectionsEn { - _TranslationsCollectionsKo._(this._root); +class _TranslationsCollectionsKo extends TranslationsCollectionsEn { + _TranslationsCollectionsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsKo implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsKo implements TranslationsPlaylistsEn { - _TranslationsPlaylistsKo._(this._root); +class _TranslationsPlaylistsKo extends TranslationsPlaylistsEn { + _TranslationsPlaylistsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsKo implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherKo implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherKo._(this._root); +class _TranslationsWatchTogetherKo extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherKo implements TranslationsWatchTogetherEn { @override String get recentRooms => '최근 방'; @override String get renameRoom => '방 이름 변경'; @override String get removeRoom => '제거'; + @override String get guestSwitchUnavailable => '전환할 수 없음 — 동기화 서버를 사용할 수 없습니다'; + @override String get guestSwitchFailed => '전환할 수 없음 — 이 서버에서 콘텐츠를 찾을 수 없습니다'; } // Path: downloads -class _TranslationsDownloadsKo implements TranslationsDownloadsEn { - _TranslationsDownloadsKo._(this._root); +class _TranslationsDownloadsKo extends TranslationsDownloadsEn { + _TranslationsDownloadsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsKo implements TranslationsDownloadsEn { @override String get editSyncFilter => '동기화 필터'; @override String get syncAllItems => '모든 항목 동기화 중'; @override String get syncUnwatchedItems => '시청하지 않은 항목 동기화 중'; + @override String syncRuleServerContext({required Object server, required Object status}) => '서버: ${server} • ${status}'; + @override String get syncRuleAvailable => '사용 가능'; + @override String get syncRuleOffline => '오프라인'; + @override String get syncRuleSignInRequired => '로그인 필요'; + @override String get syncRuleNotAvailableForProfile => '현재 프로필에서 사용할 수 없음'; + @override String get syncRuleUnknownServer => '알 수 없는 서버'; @override String get syncRuleListCreated => '동기화 규칙이 생성되었습니다'; } // Path: shaders -class _TranslationsShadersKo implements TranslationsShadersEn { - _TranslationsShadersKo._(this._root); +class _TranslationsShadersKo extends TranslationsShadersEn { + _TranslationsShadersKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersKo implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteKo implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteKo._(this._root); +class _TranslationsCompanionRemoteKo extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteKo implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsKo implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsKo._(this._root); +class _TranslationsVideoSettingsKo extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsKo implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerKo implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerKo._(this._root); +class _TranslationsExternalPlayerKo extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerKo implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditKo implements TranslationsMetadataEditEn { - _TranslationsMetadataEditKo._(this._root); +class _TranslationsMetadataEditKo extends TranslationsMetadataEditEn { + _TranslationsMetadataEditKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditKo implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenKo implements TranslationsMatchScreenEn { - _TranslationsMatchScreenKo._(this._root); +class _TranslationsMatchScreenKo extends TranslationsMatchScreenEn { + _TranslationsMatchScreenKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenKo implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksKo implements TranslationsServerTasksEn { - _TranslationsServerTasksKo._(this._root); +class _TranslationsServerTasksKo extends TranslationsServerTasksEn { + _TranslationsServerTasksKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksKo implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktKo implements TranslationsTraktEn { - _TranslationsTraktKo._(this._root); +class _TranslationsTraktKo extends TranslationsTraktEn { + _TranslationsTraktKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktKo implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersKo implements TranslationsTrackersEn { - _TranslationsTrackersKo._(this._root); +class _TranslationsTrackersKo extends TranslationsTrackersEn { + _TranslationsTrackersKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersKo implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterKo libraryFilter = _TranslationsTrackersLibraryFilterKo._(_root); } +// Path: addServer +class _TranslationsAddServerKo extends TranslationsAddServerEn { + _TranslationsAddServerKo._(TranslationsKo root) : this._root = root, super.internal(root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Jellyfin 서버 추가'; + @override String get jellyfinUrlIntro => 'Jellyfin 서버 URL을 입력하세요 — 예: `https://jellyfin.example.com`. 이후에 로그인할 수 있습니다.'; + @override String get serverUrl => '서버 URL'; + @override String get findServer => '서버 찾기'; + @override String get username => '사용자 이름'; + @override String get password => '비밀번호'; + @override String get signIn => '로그인'; + @override String get change => '변경'; + @override String get required => '필수'; + @override String couldNotReachServer({required Object error}) => '서버에 연결할 수 없습니다: ${error}'; + @override String signInFailed({required Object error}) => '로그인 실패: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect 실패: ${error}'; + @override String get addPlexTitle => 'Plex로 로그인'; + @override String get plexAuthIntro => 'Plex에 로그인할 방법을 선택하세요. 브라우저 플로우는 plex.tv를 열어 연결을 확인하며, QR 옵션은 TV나 원격 장치에 편리합니다.'; + @override String get plexQRPrompt => '이 QR 코드를 스캔하여 로그인하세요.'; + @override String get waitingForPlexConfirmation => 'plex.tv에서 로그인을 확인하는 중…'; + @override String get pinExpired => '로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.'; + @override String get duplicatePlexAccount => '이 기기는 이미 Plex 계정에 로그인되어 있습니다. 계정을 변경하려면 설정에서 로그아웃하세요.'; + @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 addConnectionIntroGlobal => '다른 미디어 서버를 추가하세요. Plex 계정과 Jellyfin 서버를 함께 사용할 수 있으며, 연결된 모든 백엔드의 항목이 홈 화면에 함께 표시됩니다.'; + @override String get addConnectionIntroScoped => '새 서버를 추가하거나 다른 프로필에서 빌리세요.'; + @override String get signInWithPlexCard => 'Plex로 로그인'; + @override String get signInWithPlexCardSubtitle => '이 기기를 Plex 계정으로 인증합니다. 계정과 공유된 서버가 자동으로 함께 추가됩니다.'; + @override String get signInWithPlexCardSubtitleScoped => '새 Plex 계정을 인증합니다. 해당 Home 사용자가 프로필로 표시됩니다.'; + @override String get connectToJellyfinCard => 'Jellyfin에 연결'; + @override String get connectToJellyfinCardSubtitle => 'Jellyfin 서버 URL을 입력하고 사용자 이름 + 비밀번호로 로그인하세요 (Quick Connect는 곧 지원 예정).'; + @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.'; + @override String get borrowFromAnotherProfile => '다른 프로필에서 빌리기'; + @override String get borrowFromAnotherProfileSubtitle => '이미 다른 프로필에 연결된 연결을 재사용합니다. PIN으로 보호된 소스 프로필은 PIN을 요청합니다.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsKo implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsKo._(this._root); +class _TranslationsHotkeysActionsKo extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsKo implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsKo implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsKo._(this._root); +class _TranslationsVideoControlsPipErrorsKo extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsKo implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsKo implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsKo._(this._root); +class _TranslationsLibrariesTabsKo extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsKo implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsKo implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsKo._(this._root); +class _TranslationsLibrariesGroupingsKo extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsKo implements TranslationsLibrariesGrouping @override String get folders => '폴더'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesKo extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesKo._(TranslationsKo root) : this._root = root, super.internal(root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String get genre => '장르'; + @override String get year => '연도'; + @override String get contentRating => '시청 등급'; + @override String get tag => '태그'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsKo extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsKo._(TranslationsKo root) : this._root = root, super.internal(root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String get title => '제목'; + @override String get dateAdded => '추가된 날짜'; + @override String get releaseDate => '출시일'; + @override String get rating => '평점'; + @override String get lastPlayed => '마지막 재생'; + @override String get playCount => '재생 횟수'; + @override String get random => '무작위'; + @override String get dateShared => '공유된 날짜'; + @override String get latestEpisodeAirDate => '최신 에피소드 방영일'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionKo implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionKo._(this._root); +class _TranslationsCompanionRemoteSessionKo extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionKo implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingKo implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingKo._(this._root); +class _TranslationsCompanionRemotePairingKo extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingKo implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteKo implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteKo._(this._root); +class _TranslationsCompanionRemoteRemoteKo extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteKo implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesKo implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesKo._(this._root); +class _TranslationsTrackersServicesKo extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesKo implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeKo implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeKo._(this._root); +class _TranslationsTrackersDeviceCodeKo extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeKo implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyKo implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyKo._(this._root); +class _TranslationsTrackersOauthProxyKo extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyKo implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterKo implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterKo._(this._root); +class _TranslationsTrackersLibraryFilterKo extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterKo._(TranslationsKo root) : this._root = root, super.internal(root); final TranslationsKo _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsKo { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => '로그인', 'auth.signInWithPlex' => 'Plex 계정으로 로그인', 'auth.showQRCode' => 'QR 코드', 'auth.authenticate' => '인증', @@ -1550,6 +1719,14 @@ extension on TranslationsKo { 'auth.scanQRToSignIn' => 'QR 코드를 스캔하여 로그인', 'auth.waitingForAuth' => '인증 대기 중... 브라우저에서 로그인을 완료해 주세요.', 'auth.useBrowser' => '브라우저 사용', + 'auth.or' => '또는', + 'auth.connectToJellyfin' => 'Jellyfin에 연결', + 'auth.useQuickConnect' => 'Quick Connect 사용', + 'auth.quickConnectCode' => 'Quick Connect 코드', + 'auth.quickConnectInstructions' => '웹 브라우저에서 Jellyfin 서버를 열어 로그인한 뒤 사용자 메뉴에서 Quick Connect를 선택하세요. 이 코드를 입력해 로그인을 승인하세요.', + 'auth.quickConnectWaiting' => '승인 대기 중…', + 'auth.quickConnectCancel' => '취소', + 'auth.quickConnectExpired' => '승인되기 전에 Quick Connect 코드가 만료되었습니다. 다시 시도하세요.', 'common.cancel' => '취소', 'common.save' => '저장', 'common.close' => '닫기', @@ -1636,12 +1813,12 @@ extension on TranslationsKo { 'settings.gridView' => '그리드 보기', 'settings.listView' => '목록 보기', 'settings.showHeroSection' => '주요 추천 영역 표시', - 'settings.useGlobalHubs' => 'Plex 홈 레이아웃 사용', - 'settings.useGlobalHubsDescription' => '공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => '허브에 서버 이름 표시', 'settings.showServerNameOnHubsDescription' => '허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.', 'settings.groupLibrariesByServer' => '서버별로 라이브러리 그룹화', - 'settings.groupLibrariesByServerDescription' => '여러 서버에 연결되어 있을 때 사이드바에 각 Plex 서버의 헤더를 표시합니다.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => '사이드바 항상 열어두기', 'settings.alwaysKeepSidebarOpenDescription' => '사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다', 'settings.showUnwatchedCount' => '미시청 수 표시', @@ -1962,7 +2139,7 @@ extension on TranslationsKo { 'messages.musicNotSupported' => '음악 재생 미지원', 'messages.noDescriptionAvailable' => '설명이 없습니다', 'messages.noProfilesAvailable' => '사용 가능한 프로필이 없습니다', - 'messages.contactAdminForProfiles' => '프로필을 추가하려면 Plex 관리자에게 문의하세요', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => '이 항목의 라이브러리 섹션을 확인할 수 없습니다', 'messages.logsCleared' => '로그가 삭제 되었습니다', 'messages.logsCopied' => '로그가 클립보드에 복사 되었습니다', @@ -2016,11 +2193,65 @@ extension on TranslationsKo { 'mpvConfig.confirmDeletePreset' => '이 프리셋을 삭제 하시겠습니까?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => '확인', + 'profiles.addPlezyProfile' => 'Plezy 프로필 추가', + 'profiles.switchingProfile' => '프로필 전환 중…', + 'profiles.deleteThisProfileTitle' => '이 프로필을 삭제하시겠습니까?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} 이(가) 제거됩니다. 연결 자체는 영향을 받지 않습니다.', + 'profiles.active' => '활성', + 'profiles.manage' => '관리', + 'profiles.delete' => '삭제', + 'profiles.signOut' => '로그아웃', + 'profiles.signOutPlexTitle' => 'Plex에서 로그아웃하시겠습니까?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} 및 이 계정의 모든 Plex Home 사용자가 이 기기에서 제거됩니다. 언제든지 다시 로그인할 수 있습니다.', + 'profiles.signedOutPlex' => 'Plex에서 로그아웃되었습니다.', + 'profiles.signOutFailed' => '로그아웃에 실패했습니다.', + 'profiles.sectionTitle' => '프로필', + 'profiles.summarySingle' => '관리되는 사용자와 로컬 ID를 혼합하려면 프로필을 추가하세요', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count}개 프로필 · 활성: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count}개 프로필', + 'profiles.removeConnectionTitle' => '연결을 제거하시겠습니까?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName}이(가) ${connectionLabel}에 대한 액세스를 잃게 됩니다. 연결 자체는 다른 프로필에서 계속 사용할 수 있습니다.', + 'profiles.deleteProfileTitle' => '프로필을 삭제하시겠습니까?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => '이 기기에서 ${displayName}과(와) 모든 연결이 제거됩니다. 기본 Plex/Jellyfin 서버에는 영향을 주지 않습니다.', + 'profiles.profileNameLabel' => '프로필 이름', + 'profiles.pinProtectionLabel' => 'PIN 보호', + 'profiles.pinManagedByPlex' => 'PIN은 Plex에서 관리됩니다. plex.tv에서 편집하세요.', + 'profiles.noPinSetEditOnPlex' => '설정된 PIN이 없습니다. 요구하려면 plex.tv에서 Home 사용자를 편집하세요.', + 'profiles.setPin' => 'PIN 설정', + 'profiles.connectionsLabel' => '연결', + 'profiles.add' => '추가', + 'profiles.deleteProfileButton' => '프로필 삭제', + 'profiles.noConnectionsHint' => '연결이 없습니다 — 이 프로필을 사용하려면 하나 추가하세요.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Plex Home 계정', + 'profiles.connectionDefault' => '기본값', + 'profiles.makeDefault' => '기본값으로 설정', + 'profiles.removeConnection' => '제거', + 'profiles.borrowAddTo' => ({required Object displayName}) => '${displayName}에 추가', + 'profiles.borrowExplain' => '다른 프로필에서 연결을 빌립니다. PIN 보호된 원본 프로필은 공유 전에 PIN을 요구합니다.', + 'profiles.borrowEmpty' => '아직 빌릴 것이 없습니다.', + 'profiles.borrowEmptySubtitle' => '먼저 다른 프로필에 Plex 계정 또는 Jellyfin 서버를 연결한 다음 여기로 돌아오세요.', + 'profiles.newProfile' => '새 프로필', + 'profiles.profileNameHint' => '예: 손님, 어린이, 가족실', + 'profiles.pinProtectionOptional' => 'PIN 보호 (선택 사항)', + 'profiles.pinExplain' => '이 프로필로 전환하려면 4자리 PIN이 필요합니다. 부드러운 장벽 — 앱 데이터를 지울 수 있는 사람은 우회할 수 있습니다.', + 'profiles.continueButton' => '계속', + 'profiles.pinsDontMatch' => 'PIN이 일치하지 않습니다', + 'connections.sectionTitle' => '연결', + 'connections.addConnection' => '연결 추가', + 'connections.addConnectionSubtitleNoProfile' => 'Plex로 로그인하거나 Jellyfin 서버에 연결', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => '${displayName} 에 추가 — Plex 계정, Jellyfin 서버 또는 다른 프로필에서 빌리기', + 'connections.sessionExpiredOne' => ({required Object name}) => '${name} 의 세션이 만료되었습니다', + 'connections.sessionExpiredMany' => ({required Object count}) => '${count} 개의 서버에서 세션이 만료되었습니다', + 'connections.signInAgain' => '다시 로그인', 'discover.title' => '발견', 'discover.switchProfile' => '사용자 전환', 'discover.noContentAvailable' => '사용 가능한 콘텐츠가 없습니다', 'discover.addMediaToLibraries' => '미디어 라이브러리에 미디어를 추가해 주세요', 'discover.continueWatching' => '계속 시청', + 'discover.nextUp' => '다음 에피소드', + 'discover.recentlyAdded' => '최근에 추가됨', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => '개요', 'discover.cast' => '출연진', @@ -2032,7 +2263,7 @@ extension on TranslationsKo { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes}분 남음', 'errors.searchFailed' => ({required Object error}) => '검색 실패: ${error}', 'errors.connectionTimeout' => ({required Object context}) => '${context} 로드 중 연결 시간 초과', - 'errors.connectionFailed' => 'Plex 서버에 연결할 수 없음', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => '${context} 로드 실패: ${error}', 'errors.noClientAvailable' => '사용 가능한 클라이언트가 없습니다', 'errors.authenticationFailed' => ({required Object error}) => '인증 실패: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsKo { 'errors.invalidToken' => '토큰이 유효하지 않습니다', 'errors.failedToVerifyToken' => ({required Object error}) => '토큰을 확인할 수 없습니다: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => '${displayName}으로 전환할 수 없습니다', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => '${displayName}을(를) 삭제할 수 없습니다', + 'errors.failedToRate' => '평점을 업데이트하지 못했습니다', 'libraries.title' => '미디어 라이브러리', 'libraries.scanLibraryFiles' => '미디어 라이브러리 파일 스캔', 'libraries.scanLibrary' => '미디어 라이브러리 스캔', @@ -2054,8 +2287,6 @@ extension on TranslationsKo { 'libraries.analyzing' => ({required Object title}) => '"${title}" 분석 중...', 'libraries.analysisStarted' => ({required Object title}) => '"${title}" 분석 시작됨', 'libraries.failedToAnalyze' => ({required Object error}) => '미디어 라이브러리 분석 실패: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => '미디어 라이브러리 없음', 'libraries.allLibrariesHidden' => '모든 라이브러리가 숨겨졌습니다', 'libraries.hiddenLibrariesCount' => ({required Object count}) => '숨겨진 라이브러리 (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsKo { 'libraries.groupings.seasons' => '시즌', 'libraries.groupings.episodes' => '화', 'libraries.groupings.folders' => '폴더', + 'libraries.filterCategories.genre' => '장르', + 'libraries.filterCategories.year' => '연도', + 'libraries.filterCategories.contentRating' => '시청 등급', + 'libraries.filterCategories.tag' => '태그', + 'libraries.sortLabels.title' => '제목', + 'libraries.sortLabels.dateAdded' => '추가된 날짜', + 'libraries.sortLabels.releaseDate' => '출시일', + 'libraries.sortLabels.rating' => '평점', + 'libraries.sortLabels.lastPlayed' => '마지막 재생', + 'libraries.sortLabels.playCount' => '재생 횟수', + 'libraries.sortLabels.random' => '무작위', + 'libraries.sortLabels.dateShared' => '공유된 날짜', + 'libraries.sortLabels.latestEpisodeAirDate' => '최신 에피소드 방영일', 'about.title' => '소개', 'about.openSourceLicenses' => '오픈소스 라이선스', 'about.versionLabel' => ({required Object version}) => '버전 ${version}', - 'about.appDescription' => '아름다운 Flutter Plex 클라이언트', + 'about.appDescription' => '아름다운 Flutter용 Plex 및 Jellyfin 클라이언트', 'about.viewLicensesDescription' => '타사 라이브러리 라이선스 보기', 'serverSelection.allServerConnectionsFailed' => '어떤 서버에도 연결할 수 없습니다. 네트워크를 확인하고 다시 시도하세요.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => '${username} (${email})의 서버를 찾을 수 없습니다.', @@ -2243,6 +2487,8 @@ extension on TranslationsKo { 'watchTogether.recentRooms' => '최근 방', 'watchTogether.renameRoom' => '방 이름 변경', 'watchTogether.removeRoom' => '제거', + 'watchTogether.guestSwitchUnavailable' => '전환할 수 없음 — 동기화 서버를 사용할 수 없습니다', + 'watchTogether.guestSwitchFailed' => '전환할 수 없음 — 이 서버에서 콘텐츠를 찾을 수 없습니다', 'downloads.title' => '다운로드', 'downloads.manage' => '관리', 'downloads.tvShows' => 'TV 프로그램', @@ -2291,6 +2537,12 @@ extension on TranslationsKo { 'downloads.editSyncFilter' => '동기화 필터', 'downloads.syncAllItems' => '모든 항목 동기화 중', 'downloads.syncUnwatchedItems' => '시청하지 않은 항목 동기화 중', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '서버: ${server} • ${status}', + 'downloads.syncRuleAvailable' => '사용 가능', + 'downloads.syncRuleOffline' => '오프라인', + 'downloads.syncRuleSignInRequired' => '로그인 필요', + 'downloads.syncRuleNotAvailableForProfile' => '현재 프로필에서 사용할 수 없음', + 'downloads.syncRuleUnknownServer' => '알 수 없는 서버', 'downloads.syncRuleListCreated' => '동기화 규칙이 생성되었습니다', 'shaders.title' => '셰이더', 'shaders.noShaderDescription' => '비디오 향상 없음', @@ -2484,6 +2736,8 @@ extension on TranslationsKo { 'trakt.disconnectConfirmBody' => 'Plezy가 Trakt로 재생 이벤트를 보내지 않습니다. 언제든지 다시 연결할 수 있습니다.', 'trakt.scrobble' => '실시간 스크로블', 'trakt.scrobbleDescription' => '재생 중 재생, 일시정지, 정지 이벤트를 Trakt로 전송합니다.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => '시청 상태 동기화', 'trakt.watchedSyncDescription' => 'Plezy에서 시청 완료로 표시한 항목이 Trakt에도 시청 완료로 표시됩니다.', 'trackers.title' => '트래커', @@ -2519,6 +2773,38 @@ extension on TranslationsKo { 'trackers.libraryFilter.modeHintWhitelist' => '아래에 선택한 라이브러리만 동기화합니다.', 'trackers.libraryFilter.libraries' => '라이브러리', 'trackers.libraryFilter.noLibraries' => '사용 가능한 라이브러리가 없습니다', + 'addServer.addJellyfinTitle' => 'Jellyfin 서버 추가', + 'addServer.jellyfinUrlIntro' => 'Jellyfin 서버 URL을 입력하세요 — 예: `https://jellyfin.example.com`. 이후에 로그인할 수 있습니다.', + 'addServer.serverUrl' => '서버 URL', + 'addServer.findServer' => '서버 찾기', + 'addServer.username' => '사용자 이름', + 'addServer.password' => '비밀번호', + 'addServer.signIn' => '로그인', + 'addServer.change' => '변경', + 'addServer.required' => '필수', + 'addServer.couldNotReachServer' => ({required Object error}) => '서버에 연결할 수 없습니다: ${error}', + 'addServer.signInFailed' => ({required Object error}) => '로그인 실패: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect 실패: ${error}', + 'addServer.addPlexTitle' => 'Plex로 로그인', + 'addServer.plexAuthIntro' => 'Plex에 로그인할 방법을 선택하세요. 브라우저 플로우는 plex.tv를 열어 연결을 확인하며, QR 옵션은 TV나 원격 장치에 편리합니다.', + 'addServer.plexQRPrompt' => '이 QR 코드를 스캔하여 로그인하세요.', + 'addServer.waitingForPlexConfirmation' => 'plex.tv에서 로그인을 확인하는 중…', + 'addServer.pinExpired' => '로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.', + 'addServer.duplicatePlexAccount' => '이 기기는 이미 Plex 계정에 로그인되어 있습니다. 계정을 변경하려면 설정에서 로그아웃하세요.', + 'addServer.failedToRegisterAccount' => ({required Object error}) => '계정 등록 실패: ${error}', + 'addServer.enterJellyfinUrlError' => 'Jellyfin 서버 URL을 입력하세요', + 'addServer.addConnectionTitle' => '연결 추가', + 'addServer.addConnectionTitleScoped' => ({required Object name}) => '${name}에 추가', + 'addServer.addConnectionIntroGlobal' => '다른 미디어 서버를 추가하세요. Plex 계정과 Jellyfin 서버를 함께 사용할 수 있으며, 연결된 모든 백엔드의 항목이 홈 화면에 함께 표시됩니다.', + 'addServer.addConnectionIntroScoped' => '새 서버를 추가하거나 다른 프로필에서 빌리세요.', + 'addServer.signInWithPlexCard' => 'Plex로 로그인', + 'addServer.signInWithPlexCardSubtitle' => '이 기기를 Plex 계정으로 인증합니다. 계정과 공유된 서버가 자동으로 함께 추가됩니다.', + 'addServer.signInWithPlexCardSubtitleScoped' => '새 Plex 계정을 인증합니다. 해당 Home 사용자가 프로필로 표시됩니다.', + 'addServer.connectToJellyfinCard' => 'Jellyfin에 연결', + 'addServer.connectToJellyfinCardSubtitle' => 'Jellyfin 서버 URL을 입력하고 사용자 이름 + 비밀번호로 로그인하세요 (Quick Connect는 곧 지원 예정).', + '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 2cba0d05..fba1dda0 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsNb with BaseTranslations implements Translations { +class TranslationsNb extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsNb({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsNb with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsNb with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsNb _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsNb with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingNb subtitlingStyling = _TranslationsSubtitlingStylingNb._(_root); @override late final _TranslationsMpvConfigNb mpvConfig = _TranslationsMpvConfigNb._(_root); @override late final _TranslationsDialogNb dialog = _TranslationsDialogNb._(_root); + @override late final _TranslationsProfilesNb profiles = _TranslationsProfilesNb._(_root); + @override late final _TranslationsConnectionsNb connections = _TranslationsConnectionsNb._(_root); @override late final _TranslationsDiscoverNb discover = _TranslationsDiscoverNb._(_root); @override late final _TranslationsErrorsNb errors = _TranslationsErrorsNb._(_root); @override late final _TranslationsLibrariesNb libraries = _TranslationsLibrariesNb._(_root); @@ -78,11 +82,12 @@ class TranslationsNb with BaseTranslations implements T @override late final _TranslationsServerTasksNb serverTasks = _TranslationsServerTasksNb._(_root); @override late final _TranslationsTraktNb trakt = _TranslationsTraktNb._(_root); @override late final _TranslationsTrackersNb trackers = _TranslationsTrackersNb._(_root); + @override late final _TranslationsAddServerNb addServer = _TranslationsAddServerNb._(_root); } // Path: app -class _TranslationsAppNb implements TranslationsAppEn { - _TranslationsAppNb._(this._root); +class _TranslationsAppNb extends TranslationsAppEn { + _TranslationsAppNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppNb implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthNb implements TranslationsAuthEn { - _TranslationsAuthNb._(this._root); +class _TranslationsAuthNb extends TranslationsAuthEn { + _TranslationsAuthNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field // Translations + @override String get signIn => 'Logg på'; @override String get signInWithPlex => 'Logg inn med Plex'; @override String get showQRCode => 'Vis QR-kode'; @override String get authenticate => 'Autentiser'; @@ -104,11 +110,19 @@ class _TranslationsAuthNb implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Skann denne QR-koden for å logge inn'; @override String get waitingForAuth => 'Venter på autentisering...\nFullfør innloggingen i nettleseren din.'; @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 quickConnectCode => 'Quick Connect-kode'; + @override String get quickConnectInstructions => 'Åpne Jellyfin-serveren din i en nettleser, logg inn, og velg Quick Connect i brukermenyen. Skriv inn denne koden for å godkjenne påloggingen.'; + @override String get quickConnectWaiting => 'Venter på godkjenning…'; + @override String get quickConnectCancel => 'Avbryt'; + @override String get quickConnectExpired => 'Quick Connect-koden utløp før godkjenning. Prøv igjen.'; } // Path: common -class _TranslationsCommonNb implements TranslationsCommonEn { - _TranslationsCommonNb._(this._root); +class _TranslationsCommonNb extends TranslationsCommonEn { + _TranslationsCommonNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonNb implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensNb implements TranslationsScreensEn { - _TranslationsScreensNb._(this._root); +class _TranslationsScreensNb extends TranslationsScreensEn { + _TranslationsScreensNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensNb implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateNb implements TranslationsUpdateEn { - _TranslationsUpdateNb._(this._root); +class _TranslationsUpdateNb extends TranslationsUpdateEn { + _TranslationsUpdateNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateNb implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsNb implements TranslationsSettingsEn { - _TranslationsSettingsNb._(this._root); +class _TranslationsSettingsNb extends TranslationsSettingsEn { + _TranslationsSettingsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsNb implements TranslationsSettingsEn { @override String get gridView => 'Rutenett'; @override String get listView => 'Liste'; @override String get showHeroSection => 'Vis fremhevet seksjon'; - @override String get useGlobalHubs => 'Bruk Plex Home-layout'; - @override String get useGlobalHubsDescription => 'Vis hjemmeside-huber som den offisielle Plex-klienten. Når av, vises per-bibliotek-anbefalinger i stedet.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Vis servernavn på huber'; @override String get showServerNameOnHubsDescription => 'Vis alltid servernavnet i hubtitler. Når av, vises kun for dupliserte hubnavn.'; @override String get groupLibrariesByServer => 'Grupper biblioteker etter server'; - @override String get groupLibrariesByServerDescription => 'Vis en overskrift for hver Plex-server i sidefeltet når du er koblet til flere servere.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Hold sidefeltet alltid åpent'; @override String get alwaysKeepSidebarOpenDescription => 'Sidefeltet forblir utvidet og innholdsområdet tilpasser seg'; @override String get showUnwatchedCount => 'Vis antall usette'; @@ -385,8 +399,8 @@ class _TranslationsSettingsNb implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchNb implements TranslationsSearchEn { - _TranslationsSearchNb._(this._root); +class _TranslationsSearchNb extends TranslationsSearchEn { + _TranslationsSearchNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchNb implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysNb implements TranslationsHotkeysEn { - _TranslationsHotkeysNb._(this._root); +class _TranslationsHotkeysNb extends TranslationsHotkeysEn { + _TranslationsHotkeysNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysNb implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoNb implements TranslationsFileInfoEn { - _TranslationsFileInfoNb._(this._root); +class _TranslationsFileInfoNb extends TranslationsFileInfoEn { + _TranslationsFileInfoNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoNb implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuNb implements TranslationsMediaMenuEn { - _TranslationsMediaMenuNb._(this._root); +class _TranslationsMediaMenuNb extends TranslationsMediaMenuEn { + _TranslationsMediaMenuNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuNb implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityNb implements TranslationsAccessibilityEn { - _TranslationsAccessibilityNb._(this._root); +class _TranslationsAccessibilityNb extends TranslationsAccessibilityEn { + _TranslationsAccessibilityNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityNb implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsNb implements TranslationsTooltipsEn { - _TranslationsTooltipsNb._(this._root); +class _TranslationsTooltipsNb extends TranslationsTooltipsEn { + _TranslationsTooltipsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsNb implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsNb implements TranslationsVideoControlsEn { - _TranslationsVideoControlsNb._(this._root); +class _TranslationsVideoControlsNb extends TranslationsVideoControlsEn { + _TranslationsVideoControlsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsNb implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusNb implements TranslationsUserStatusEn { - _TranslationsUserStatusNb._(this._root); +class _TranslationsUserStatusNb extends TranslationsUserStatusEn { + _TranslationsUserStatusNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusNb implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesNb implements TranslationsMessagesEn { - _TranslationsMessagesNb._(this._root); +class _TranslationsMessagesNb extends TranslationsMessagesEn { + _TranslationsMessagesNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesNb implements TranslationsMessagesEn { @override String get musicNotSupported => 'Musikkavspilling støttes ikke ennå'; @override String get noDescriptionAvailable => 'Ingen beskrivelse tilgjengelig'; @override String get noProfilesAvailable => 'Ingen profiler tilgjengelige'; - @override String get contactAdminForProfiles => 'Kontakt Plex-administratoren for å legge til profiler'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Kan ikke fastslå bibliotekseksjonen for dette elementet'; @override String get logsCleared => 'Logger tømt'; @override String get logsCopied => 'Logger kopiert til utklippstavle'; @@ -636,8 +650,8 @@ class _TranslationsMessagesNb implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingNb implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingNb._(this._root); +class _TranslationsSubtitlingStylingNb extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingNb implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigNb implements TranslationsMpvConfigEn { - _TranslationsMpvConfigNb._(this._root); +class _TranslationsMpvConfigNb extends TranslationsMpvConfigEn { + _TranslationsMpvConfigNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigNb implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogNb implements TranslationsDialogEn { - _TranslationsDialogNb._(this._root); +class _TranslationsDialogNb extends TranslationsDialogEn { + _TranslationsDialogNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogNb implements TranslationsDialogEn { @override String get confirmAction => 'Bekreft handling'; } +// Path: profiles +class _TranslationsProfilesNb extends TranslationsProfilesEn { + _TranslationsProfilesNb._(TranslationsNb root) : this._root = root, super.internal(root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Legg til Plezy-profil'; + @override String get switchingProfile => 'Bytter profil…'; + @override String get deleteThisProfileTitle => 'Slett denne profilen?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} fjernes. Tilkoblinger påvirkes ikke.'; + @override String get active => 'Aktiv'; + @override String get manage => 'Administrer'; + @override String get delete => 'Slett'; + @override String get signOut => 'Logg ut'; + @override String get signOutPlexTitle => 'Logge ut av Plex?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} og alle Plex Home-brukere på denne kontoen fjernes fra denne enheten. Du kan logge inn igjen når som helst.'; + @override String get signedOutPlex => 'Logget ut av Plex.'; + @override String get signOutFailed => 'Utlogging mislyktes.'; + @override String get sectionTitle => 'Profiler'; + @override String get summarySingle => 'Legg til profiler for å blande administrerte brukere og lokale identiteter'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} profiler · aktiv: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} profiler'; + @override String get removeConnectionTitle => 'Fjerne tilkobling?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} mister tilgang til ${connectionLabel}. Tilkoblingen er fortsatt tilgjengelig for andre profiler.'; + @override String get deleteProfileTitle => 'Slette profil?'; + @override String deleteProfileMessage({required Object displayName}) => 'Dette fjerner ${displayName} og alle dens tilkoblinger fra denne enheten. De underliggende Plex/Jellyfin-serverne berøres ikke.'; + @override String get profileNameLabel => 'Profilnavn'; + @override String get pinProtectionLabel => 'PIN-beskyttelse'; + @override String get pinManagedByPlex => 'PIN administreres av Plex. Rediger på plex.tv.'; + @override String get noPinSetEditOnPlex => 'Ingen PIN er satt. For å kreve én, rediger Home-brukeren på plex.tv.'; + @override String get setPin => 'Sett PIN'; + @override String get connectionsLabel => 'Tilkoblinger'; + @override String get add => 'Legg til'; + @override String get deleteProfileButton => 'Slett profil'; + @override String get noConnectionsHint => 'Ingen tilkoblinger — legg til én for å bruke denne profilen.'; + @override String get plexHomeAccount => 'Plex Home-konto'; + @override String get connectionDefault => 'Standard'; + @override String get makeDefault => 'Gjør til standard'; + @override String get removeConnection => 'Fjern'; + @override String borrowAddTo({required Object displayName}) => 'Legg til ${displayName}'; + @override String get borrowExplain => 'Lån en tilkobling fra en annen profil. PIN-beskyttede kildeprofiler ber om PIN før deling.'; + @override String get borrowEmpty => 'Ingenting å låne enda.'; + @override String get borrowEmptySubtitle => 'Koble først en Plex-konto eller Jellyfin-server til en annen profil, og kom så tilbake hit.'; + @override String get newProfile => 'Ny profil'; + @override String get profileNameHint => 'f.eks. Gjester, Barn, Familierom'; + @override String get pinProtectionOptional => 'PIN-beskyttelse (valgfri)'; + @override String get pinExplain => '4-sifret PIN kreves for å bytte til denne profilen. Myk barriere — alle som kan fjerne appdata kan omgå den.'; + @override String get continueButton => 'Fortsett'; + @override String get pinsDontMatch => 'PIN-ene samsvarer ikke'; +} + +// Path: connections +class _TranslationsConnectionsNb extends TranslationsConnectionsEn { + _TranslationsConnectionsNb._(TranslationsNb root) : this._root = root, super.internal(root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Tilkoblinger'; + @override String get addConnection => 'Legg til tilkobling'; + @override String get addConnectionSubtitleNoProfile => 'Logg inn med Plex eller koble til en Jellyfin-server'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Legg til ${displayName} — Plex-konto, Jellyfin-server eller lån fra en annen profil'; + @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'; +} + // Path: discover -class _TranslationsDiscoverNb implements TranslationsDiscoverEn { - _TranslationsDiscoverNb._(this._root); +class _TranslationsDiscoverNb extends TranslationsDiscoverEn { + _TranslationsDiscoverNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverNb implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Ingen innhold tilgjengelig'; @override String get addMediaToLibraries => 'Legg til medier i bibliotekene dine'; @override String get continueWatching => 'Fortsett å se'; + @override String get nextUp => 'Neste opp'; + @override String get recentlyAdded => 'Nylig lagt til'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Oversikt'; @override String get cast => 'Skuespillere'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverNb implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsNb implements TranslationsErrorsEn { - _TranslationsErrorsNb._(this._root); +class _TranslationsErrorsNb extends TranslationsErrorsEn { + _TranslationsErrorsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Søk mislyktes: ${error}'; @override String connectionTimeout({required Object context}) => 'Tidsavbrudd ved lasting av ${context}'; - @override String get connectionFailed => 'Kunne ikke koble til Plex-server'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Kunne ikke laste ${context}: ${error}'; @override String get noClientAvailable => 'Ingen klient tilgjengelig'; @override String authenticationFailed({required Object error}) => 'Autentisering mislyktes: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsNb implements TranslationsErrorsEn { @override String get invalidToken => 'Ugyldig token'; @override String failedToVerifyToken({required Object error}) => 'Kunne ikke verifisere token: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Kunne ikke bytte til ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Kunne ikke slette ${displayName}'; + @override String get failedToRate => 'Kunne ikke oppdatere vurderingen'; } // Path: libraries -class _TranslationsLibrariesNb implements TranslationsLibrariesEn { - _TranslationsLibrariesNb._(this._root); +class _TranslationsLibrariesNb extends TranslationsLibrariesEn { + _TranslationsLibrariesNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesNb implements TranslationsLibrariesEn { @override String get folders => 'mapper'; @override late final _TranslationsLibrariesTabsNb tabs = _TranslationsLibrariesTabsNb._(_root); @override late final _TranslationsLibrariesGroupingsNb groupings = _TranslationsLibrariesGroupingsNb._(_root); + @override late final _TranslationsLibrariesFilterCategoriesNb filterCategories = _TranslationsLibrariesFilterCategoriesNb._(_root); + @override late final _TranslationsLibrariesSortLabelsNb sortLabels = _TranslationsLibrariesSortLabelsNb._(_root); } // Path: about -class _TranslationsAboutNb implements TranslationsAboutEn { - _TranslationsAboutNb._(this._root); +class _TranslationsAboutNb extends TranslationsAboutEn { + _TranslationsAboutNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutNb implements TranslationsAboutEn { @override String get title => 'Om'; @override String get openSourceLicenses => 'Åpen kildekode-lisenser'; @override String versionLabel({required Object version}) => 'Versjon ${version}'; - @override String get appDescription => 'En vakker Plex-klient for Flutter'; + @override String get appDescription => 'En vakker Plex- og Jellyfin-klient for Flutter'; @override String get viewLicensesDescription => 'Vis lisenser for tredjepartsbiblioteker'; } // Path: serverSelection -class _TranslationsServerSelectionNb implements TranslationsServerSelectionEn { - _TranslationsServerSelectionNb._(this._root); +class _TranslationsServerSelectionNb extends TranslationsServerSelectionEn { + _TranslationsServerSelectionNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionNb implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailNb implements TranslationsHubDetailEn { - _TranslationsHubDetailNb._(this._root); +class _TranslationsHubDetailNb extends TranslationsHubDetailEn { + _TranslationsHubDetailNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailNb implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsNb implements TranslationsLogsEn { - _TranslationsLogsNb._(this._root); +class _TranslationsLogsNb extends TranslationsLogsEn { + _TranslationsLogsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsNb implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesNb implements TranslationsLicensesEn { - _TranslationsLicensesNb._(this._root); +class _TranslationsLicensesNb extends TranslationsLicensesEn { + _TranslationsLicensesNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesNb implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationNb implements TranslationsNavigationEn { - _TranslationsNavigationNb._(this._root); +class _TranslationsNavigationNb extends TranslationsNavigationEn { + _TranslationsNavigationNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationNb implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvNb implements TranslationsLiveTvEn { - _TranslationsLiveTvNb._(this._root); +class _TranslationsLiveTvNb extends TranslationsLiveTvEn { + _TranslationsLiveTvNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvNb implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsNb implements TranslationsCollectionsEn { - _TranslationsCollectionsNb._(this._root); +class _TranslationsCollectionsNb extends TranslationsCollectionsEn { + _TranslationsCollectionsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsNb implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsNb implements TranslationsPlaylistsEn { - _TranslationsPlaylistsNb._(this._root); +class _TranslationsPlaylistsNb extends TranslationsPlaylistsEn { + _TranslationsPlaylistsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsNb implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherNb implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherNb._(this._root); +class _TranslationsWatchTogetherNb extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherNb implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Nylige rom'; @override String get renameRoom => 'Gi nytt navn til rom'; @override String get removeRoom => 'Fjern'; + @override String get guestSwitchUnavailable => 'Kunne ikke bytte — server ikke tilgjengelig for synkronisering'; + @override String get guestSwitchFailed => 'Kunne ikke bytte — innhold ble ikke funnet på denne serveren'; } // Path: downloads -class _TranslationsDownloadsNb implements TranslationsDownloadsEn { - _TranslationsDownloadsNb._(this._root); +class _TranslationsDownloadsNb extends TranslationsDownloadsEn { + _TranslationsDownloadsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsNb implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Synkroniseringsfilter'; @override String get syncAllItems => 'Synkroniserer alle elementer'; @override String get syncUnwatchedItems => 'Synkroniserer usette elementer'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Server: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Tilgjengelig'; + @override String get syncRuleOffline => 'Frakoblet'; + @override String get syncRuleSignInRequired => 'Innlogging kreves'; + @override String get syncRuleNotAvailableForProfile => 'Ikke tilgjengelig for gjeldende profil'; + @override String get syncRuleUnknownServer => 'Ukjent server'; @override String get syncRuleListCreated => 'Synkroniseringsregel opprettet'; } // Path: shaders -class _TranslationsShadersNb implements TranslationsShadersEn { - _TranslationsShadersNb._(this._root); +class _TranslationsShadersNb extends TranslationsShadersEn { + _TranslationsShadersNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersNb implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteNb implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteNb._(this._root); +class _TranslationsCompanionRemoteNb extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteNb implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsNb implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsNb._(this._root); +class _TranslationsVideoSettingsNb extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsNb implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerNb implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerNb._(this._root); +class _TranslationsExternalPlayerNb extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerNb implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditNb implements TranslationsMetadataEditEn { - _TranslationsMetadataEditNb._(this._root); +class _TranslationsMetadataEditNb extends TranslationsMetadataEditEn { + _TranslationsMetadataEditNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditNb implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenNb implements TranslationsMatchScreenEn { - _TranslationsMatchScreenNb._(this._root); +class _TranslationsMatchScreenNb extends TranslationsMatchScreenEn { + _TranslationsMatchScreenNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenNb implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksNb implements TranslationsServerTasksEn { - _TranslationsServerTasksNb._(this._root); +class _TranslationsServerTasksNb extends TranslationsServerTasksEn { + _TranslationsServerTasksNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksNb implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktNb implements TranslationsTraktEn { - _TranslationsTraktNb._(this._root); +class _TranslationsTraktNb extends TranslationsTraktEn { + _TranslationsTraktNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktNb implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersNb implements TranslationsTrackersEn { - _TranslationsTrackersNb._(this._root); +class _TranslationsTrackersNb extends TranslationsTrackersEn { + _TranslationsTrackersNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersNb implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterNb libraryFilter = _TranslationsTrackersLibraryFilterNb._(_root); } +// Path: addServer +class _TranslationsAddServerNb extends TranslationsAddServerEn { + _TranslationsAddServerNb._(TranslationsNb root) : this._root = root, super.internal(root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Legg til Jellyfin-server'; + @override String get jellyfinUrlIntro => 'Oppgi URL-en til Jellyfin-serveren din — f.eks. `https://jellyfin.example.com`. Du kan logge på etterpå.'; + @override String get serverUrl => 'Server-URL'; + @override String get findServer => 'Finn server'; + @override String get username => 'Brukernavn'; + @override String get password => 'Passord'; + @override String get signIn => 'Logg på'; + @override String get change => 'Endre'; + @override String get required => 'Påkrevd'; + @override String couldNotReachServer({required Object error}) => 'Kunne ikke nå serveren: ${error}'; + @override String signInFailed({required Object error}) => 'Pålogging mislyktes: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect mislyktes: ${error}'; + @override String get addPlexTitle => 'Logg på med Plex'; + @override String get plexAuthIntro => 'Velg hvordan du vil logge på Plex. Nettleserflyten åpner plex.tv der du bekrefter tilkoblingen; QR-alternativet er praktisk for TV / fjern-enheter.'; + @override String get plexQRPrompt => 'Skann denne QR-koden for å logge på.'; + @override String get waitingForPlexConfirmation => 'Venter på at plex.tv bekrefter påloggingen…'; + @override String get pinExpired => 'PIN-koden gikk ut før pålogging. Prøv igjen.'; + @override String get duplicatePlexAccount => 'Denne enheten er allerede pålogget en Plex-konto. Logg ut fra innstillingene for å bytte konto.'; + @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 i ${name}'; + @override String get addConnectionIntroGlobal => 'Legg til enda en medieserver. Du kan blande Plex-kontoer og Jellyfin-servere — innhold fra alle tilkoblede backender vises sammen på startsiden.'; + @override String get addConnectionIntroScoped => 'Legg til en ny server, eller lån en fra en annen profil.'; + @override String get signInWithPlexCard => 'Logg på med Plex'; + @override String get signInWithPlexCardSubtitle => 'Autoriser denne enheten mot Plex-kontoen din. Servere delt med kontoen følger med automatisk.'; + @override String get signInWithPlexCardSubtitleScoped => 'Autoriser en ny Plex-konto. Dens Home-brukere vises som profiler.'; + @override String get connectToJellyfinCard => 'Koble til Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Oppgi URL-en til Jellyfin-serveren din og logg på med brukernavn + passord (Quick Connect kommer snart).'; + @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 tilkobling som allerede er tilknyttet en annen profil. PIN-beskyttede kildeprofiler ber om PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsNb implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsNb._(this._root); +class _TranslationsHotkeysActionsNb extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsNb implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsNb implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsNb._(this._root); +class _TranslationsVideoControlsPipErrorsNb extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsNb implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsNb implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsNb._(this._root); +class _TranslationsLibrariesTabsNb extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsNb implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsNb implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsNb._(this._root); +class _TranslationsLibrariesGroupingsNb extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsNb implements TranslationsLibrariesGrouping @override String get folders => 'Mapper'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesNb extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesNb._(TranslationsNb root) : this._root = root, super.internal(root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String get genre => 'Sjanger'; + @override String get year => 'År'; + @override String get contentRating => 'Aldersgrense'; + @override String get tag => 'Tag'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsNb extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsNb._(TranslationsNb root) : this._root = root, super.internal(root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String get title => 'Tittel'; + @override String get dateAdded => 'Lagt til-dato'; + @override String get releaseDate => 'Utgivelsesdato'; + @override String get rating => 'Vurdering'; + @override String get lastPlayed => 'Sist spilt'; + @override String get playCount => 'Avspillinger'; + @override String get random => 'Tilfeldig'; + @override String get dateShared => 'Delingsdato'; + @override String get latestEpisodeAirDate => 'Siste episodes sendedato'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionNb implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionNb._(this._root); +class _TranslationsCompanionRemoteSessionNb extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionNb implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingNb implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingNb._(this._root); +class _TranslationsCompanionRemotePairingNb extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingNb implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteNb implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteNb._(this._root); +class _TranslationsCompanionRemoteRemoteNb extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteNb implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesNb implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesNb._(this._root); +class _TranslationsTrackersServicesNb extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesNb implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeNb implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeNb._(this._root); +class _TranslationsTrackersDeviceCodeNb extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeNb implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyNb implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyNb._(this._root); +class _TranslationsTrackersOauthProxyNb extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyNb implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterNb implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterNb._(this._root); +class _TranslationsTrackersLibraryFilterNb extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterNb._(TranslationsNb root) : this._root = root, super.internal(root); final TranslationsNb _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsNb { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Logg på', 'auth.signInWithPlex' => 'Logg inn med Plex', 'auth.showQRCode' => 'Vis QR-kode', 'auth.authenticate' => 'Autentiser', @@ -1550,6 +1719,14 @@ extension on TranslationsNb { 'auth.scanQRToSignIn' => 'Skann denne QR-koden for å logge inn', 'auth.waitingForAuth' => 'Venter på autentisering...\nFullfør innloggingen i nettleseren din.', 'auth.useBrowser' => 'Bruk nettleser', + 'auth.or' => 'eller', + 'auth.connectToJellyfin' => 'Koble til Jellyfin', + 'auth.useQuickConnect' => 'Bruk Quick Connect', + 'auth.quickConnectCode' => 'Quick Connect-kode', + 'auth.quickConnectInstructions' => 'Åpne Jellyfin-serveren din i en nettleser, logg inn, og velg Quick Connect i brukermenyen. Skriv inn denne koden for å godkjenne påloggingen.', + 'auth.quickConnectWaiting' => 'Venter på godkjenning…', + 'auth.quickConnectCancel' => 'Avbryt', + 'auth.quickConnectExpired' => 'Quick Connect-koden utløp før godkjenning. Prøv igjen.', 'common.cancel' => 'Avbryt', 'common.save' => 'Lagre', 'common.close' => 'Lukk', @@ -1636,12 +1813,12 @@ extension on TranslationsNb { 'settings.gridView' => 'Rutenett', 'settings.listView' => 'Liste', 'settings.showHeroSection' => 'Vis fremhevet seksjon', - 'settings.useGlobalHubs' => 'Bruk Plex Home-layout', - 'settings.useGlobalHubsDescription' => 'Vis hjemmeside-huber som den offisielle Plex-klienten. Når av, vises per-bibliotek-anbefalinger i stedet.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Vis servernavn på huber', 'settings.showServerNameOnHubsDescription' => 'Vis alltid servernavnet i hubtitler. Når av, vises kun for dupliserte hubnavn.', 'settings.groupLibrariesByServer' => 'Grupper biblioteker etter server', - 'settings.groupLibrariesByServerDescription' => 'Vis en overskrift for hver Plex-server i sidefeltet når du er koblet til flere servere.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Hold sidefeltet alltid åpent', 'settings.alwaysKeepSidebarOpenDescription' => 'Sidefeltet forblir utvidet og innholdsområdet tilpasser seg', 'settings.showUnwatchedCount' => 'Vis antall usette', @@ -1962,7 +2139,7 @@ extension on TranslationsNb { 'messages.musicNotSupported' => 'Musikkavspilling støttes ikke ennå', 'messages.noDescriptionAvailable' => 'Ingen beskrivelse tilgjengelig', 'messages.noProfilesAvailable' => 'Ingen profiler tilgjengelige', - 'messages.contactAdminForProfiles' => 'Kontakt Plex-administratoren for å legge til profiler', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Kan ikke fastslå bibliotekseksjonen for dette elementet', 'messages.logsCleared' => 'Logger tømt', 'messages.logsCopied' => 'Logger kopiert til utklippstavle', @@ -2016,11 +2193,65 @@ extension on TranslationsNb { 'mpvConfig.confirmDeletePreset' => 'Er du sikker på at du vil slette denne forhåndsinnstillingen?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# kommentar', 'dialog.confirmAction' => 'Bekreft handling', + 'profiles.addPlezyProfile' => 'Legg til Plezy-profil', + 'profiles.switchingProfile' => 'Bytter profil…', + 'profiles.deleteThisProfileTitle' => 'Slett denne profilen?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} fjernes. Tilkoblinger påvirkes ikke.', + 'profiles.active' => 'Aktiv', + 'profiles.manage' => 'Administrer', + 'profiles.delete' => 'Slett', + 'profiles.signOut' => 'Logg ut', + 'profiles.signOutPlexTitle' => 'Logge ut av Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} og alle Plex Home-brukere på denne kontoen fjernes fra denne enheten. Du kan logge inn igjen når som helst.', + 'profiles.signedOutPlex' => 'Logget ut av Plex.', + 'profiles.signOutFailed' => 'Utlogging mislyktes.', + 'profiles.sectionTitle' => 'Profiler', + 'profiles.summarySingle' => 'Legg til profiler for å blande administrerte brukere og lokale identiteter', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} profiler · aktiv: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} profiler', + 'profiles.removeConnectionTitle' => 'Fjerne tilkobling?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} mister tilgang til ${connectionLabel}. Tilkoblingen er fortsatt tilgjengelig for andre profiler.', + 'profiles.deleteProfileTitle' => 'Slette profil?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Dette fjerner ${displayName} og alle dens tilkoblinger fra denne enheten. De underliggende Plex/Jellyfin-serverne berøres ikke.', + 'profiles.profileNameLabel' => 'Profilnavn', + 'profiles.pinProtectionLabel' => 'PIN-beskyttelse', + 'profiles.pinManagedByPlex' => 'PIN administreres av Plex. Rediger på plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'Ingen PIN er satt. For å kreve én, rediger Home-brukeren på plex.tv.', + 'profiles.setPin' => 'Sett PIN', + 'profiles.connectionsLabel' => 'Tilkoblinger', + 'profiles.add' => 'Legg til', + 'profiles.deleteProfileButton' => 'Slett profil', + 'profiles.noConnectionsHint' => 'Ingen tilkoblinger — legg til én for å bruke denne profilen.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Plex Home-konto', + 'profiles.connectionDefault' => 'Standard', + 'profiles.makeDefault' => 'Gjør til standard', + 'profiles.removeConnection' => 'Fjern', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Legg til ${displayName}', + 'profiles.borrowExplain' => 'Lån en tilkobling fra en annen profil. PIN-beskyttede kildeprofiler ber om PIN før deling.', + 'profiles.borrowEmpty' => 'Ingenting å låne enda.', + 'profiles.borrowEmptySubtitle' => 'Koble først en Plex-konto eller Jellyfin-server til en annen profil, og kom så tilbake hit.', + 'profiles.newProfile' => 'Ny profil', + 'profiles.profileNameHint' => 'f.eks. Gjester, Barn, Familierom', + 'profiles.pinProtectionOptional' => 'PIN-beskyttelse (valgfri)', + 'profiles.pinExplain' => '4-sifret PIN kreves for å bytte til denne profilen. Myk barriere — alle som kan fjerne appdata kan omgå den.', + 'profiles.continueButton' => 'Fortsett', + 'profiles.pinsDontMatch' => 'PIN-ene samsvarer ikke', + 'connections.sectionTitle' => 'Tilkoblinger', + 'connections.addConnection' => 'Legg til tilkobling', + 'connections.addConnectionSubtitleNoProfile' => 'Logg inn med Plex eller koble til en Jellyfin-server', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Legg til ${displayName} — Plex-konto, Jellyfin-server eller lån fra en annen profil', + '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', 'discover.title' => 'Oppdag', 'discover.switchProfile' => 'Bytt profil', 'discover.noContentAvailable' => 'Ingen innhold tilgjengelig', 'discover.addMediaToLibraries' => 'Legg til medier i bibliotekene dine', 'discover.continueWatching' => 'Fortsett å se', + 'discover.nextUp' => 'Neste opp', + 'discover.recentlyAdded' => 'Nylig lagt til', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Oversikt', 'discover.cast' => 'Skuespillere', @@ -2032,7 +2263,7 @@ extension on TranslationsNb { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min igjen', 'errors.searchFailed' => ({required Object error}) => 'Søk mislyktes: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Tidsavbrudd ved lasting av ${context}', - 'errors.connectionFailed' => 'Kunne ikke koble til Plex-server', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Kunne ikke laste ${context}: ${error}', 'errors.noClientAvailable' => 'Ingen klient tilgjengelig', 'errors.authenticationFailed' => ({required Object error}) => 'Autentisering mislyktes: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsNb { 'errors.invalidToken' => 'Ugyldig token', 'errors.failedToVerifyToken' => ({required Object error}) => 'Kunne ikke verifisere token: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Kunne ikke bytte til ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Kunne ikke slette ${displayName}', + 'errors.failedToRate' => 'Kunne ikke oppdatere vurderingen', 'libraries.title' => 'Biblioteker', 'libraries.scanLibraryFiles' => 'Skann bibliotekfiler', 'libraries.scanLibrary' => 'Skann bibliotek', @@ -2054,8 +2287,6 @@ extension on TranslationsNb { 'libraries.analyzing' => ({required Object title}) => 'Analyserer "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Analyse startet for "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Kunne ikke analysere bibliotek: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Ingen biblioteker funnet', 'libraries.allLibrariesHidden' => 'Alle biblioteker er skjult', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Skjulte biblioteker (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsNb { 'libraries.groupings.seasons' => 'Sesonger', 'libraries.groupings.episodes' => 'Episoder', 'libraries.groupings.folders' => 'Mapper', + 'libraries.filterCategories.genre' => 'Sjanger', + 'libraries.filterCategories.year' => 'År', + 'libraries.filterCategories.contentRating' => 'Aldersgrense', + 'libraries.filterCategories.tag' => 'Tag', + 'libraries.sortLabels.title' => 'Tittel', + 'libraries.sortLabels.dateAdded' => 'Lagt til-dato', + 'libraries.sortLabels.releaseDate' => 'Utgivelsesdato', + 'libraries.sortLabels.rating' => 'Vurdering', + 'libraries.sortLabels.lastPlayed' => 'Sist spilt', + 'libraries.sortLabels.playCount' => 'Avspillinger', + 'libraries.sortLabels.random' => 'Tilfeldig', + 'libraries.sortLabels.dateShared' => 'Delingsdato', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Siste episodes sendedato', 'about.title' => 'Om', 'about.openSourceLicenses' => 'Åpen kildekode-lisenser', 'about.versionLabel' => ({required Object version}) => 'Versjon ${version}', - 'about.appDescription' => 'En vakker Plex-klient for Flutter', + 'about.appDescription' => 'En vakker Plex- og Jellyfin-klient for Flutter', 'about.viewLicensesDescription' => 'Vis lisenser for tredjepartsbiblioteker', 'serverSelection.allServerConnectionsFailed' => 'Kunne ikke koble til noen servere. Sjekk nettverket ditt og prøv igjen.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Ingen servere funnet for ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsNb { 'watchTogether.recentRooms' => 'Nylige rom', 'watchTogether.renameRoom' => 'Gi nytt navn til rom', 'watchTogether.removeRoom' => 'Fjern', + 'watchTogether.guestSwitchUnavailable' => 'Kunne ikke bytte — server ikke tilgjengelig for synkronisering', + 'watchTogether.guestSwitchFailed' => 'Kunne ikke bytte — innhold ble ikke funnet på denne serveren', 'downloads.title' => 'Nedlastinger', 'downloads.manage' => 'Administrer', 'downloads.tvShows' => 'TV-serier', @@ -2291,6 +2537,12 @@ extension on TranslationsNb { 'downloads.editSyncFilter' => 'Synkroniseringsfilter', 'downloads.syncAllItems' => 'Synkroniserer alle elementer', 'downloads.syncUnwatchedItems' => 'Synkroniserer usette elementer', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Tilgjengelig', + 'downloads.syncRuleOffline' => 'Frakoblet', + 'downloads.syncRuleSignInRequired' => 'Innlogging kreves', + 'downloads.syncRuleNotAvailableForProfile' => 'Ikke tilgjengelig for gjeldende profil', + 'downloads.syncRuleUnknownServer' => 'Ukjent server', 'downloads.syncRuleListCreated' => 'Synkroniseringsregel opprettet', 'shaders.title' => 'Shadere', 'shaders.noShaderDescription' => 'Ingen videoforbedring', @@ -2484,6 +2736,8 @@ extension on TranslationsNb { 'trakt.disconnectConfirmBody' => 'Plezy slutter å sende avspillingshendelser til Trakt. Du kan koble til igjen når som helst.', 'trakt.scrobble' => 'Sanntids-scrobbling', 'trakt.scrobbleDescription' => 'Send avspillings-, pause- og stopphendelser til Trakt under avspilling.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Synkroniser sett-status', 'trakt.watchedSyncDescription' => 'Når du markerer noe som sett i Plezy, markeres det også på Trakt.', 'trackers.title' => 'Trackere', @@ -2519,6 +2773,38 @@ extension on TranslationsNb { 'trackers.libraryFilter.modeHintWhitelist' => 'Synkroniser kun bibliotekene du markerer nedenfor.', 'trackers.libraryFilter.libraries' => 'Biblioteker', 'trackers.libraryFilter.noLibraries' => 'Ingen biblioteker tilgjengelige', + 'addServer.addJellyfinTitle' => 'Legg til Jellyfin-server', + 'addServer.jellyfinUrlIntro' => 'Oppgi URL-en til Jellyfin-serveren din — f.eks. `https://jellyfin.example.com`. Du kan logge på etterpå.', + 'addServer.serverUrl' => 'Server-URL', + 'addServer.findServer' => 'Finn server', + 'addServer.username' => 'Brukernavn', + 'addServer.password' => 'Passord', + 'addServer.signIn' => 'Logg på', + 'addServer.change' => 'Endre', + 'addServer.required' => 'Påkrevd', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Kunne ikke nå serveren: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Pålogging mislyktes: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect mislyktes: ${error}', + 'addServer.addPlexTitle' => 'Logg på med Plex', + 'addServer.plexAuthIntro' => 'Velg hvordan du vil logge på Plex. Nettleserflyten åpner plex.tv der du bekrefter tilkoblingen; QR-alternativet er praktisk for TV / fjern-enheter.', + 'addServer.plexQRPrompt' => 'Skann denne QR-koden for å logge på.', + 'addServer.waitingForPlexConfirmation' => 'Venter på at plex.tv bekrefter påloggingen…', + 'addServer.pinExpired' => 'PIN-koden gikk ut før pålogging. Prøv igjen.', + 'addServer.duplicatePlexAccount' => 'Denne enheten er allerede pålogget en Plex-konto. Logg ut fra innstillingene for å bytte konto.', + '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 i ${name}', + 'addServer.addConnectionIntroGlobal' => 'Legg til enda en medieserver. Du kan blande Plex-kontoer og Jellyfin-servere — innhold fra alle tilkoblede backender vises sammen på startsiden.', + 'addServer.addConnectionIntroScoped' => 'Legg til en ny server, eller lån en fra en annen profil.', + 'addServer.signInWithPlexCard' => 'Logg på med Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Autoriser denne enheten mot Plex-kontoen din. Servere delt med kontoen følger med automatisk.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Autoriser en ny Plex-konto. Dens Home-brukere vises som profiler.', + 'addServer.connectToJellyfinCard' => 'Koble til Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Oppgi URL-en til Jellyfin-serveren din og logg på med brukernavn + passord (Quick Connect kommer snart).', + '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 tilkobling som allerede er tilknyttet en annen profil. PIN-beskyttede kildeprofiler ber om PIN.', _ => null, }; } diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 4e8a159d..221f0b38 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsNl with BaseTranslations implements Translations { +class TranslationsNl extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsNl({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsNl with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsNl with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsNl _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsNl with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingNl subtitlingStyling = _TranslationsSubtitlingStylingNl._(_root); @override late final _TranslationsMpvConfigNl mpvConfig = _TranslationsMpvConfigNl._(_root); @override late final _TranslationsDialogNl dialog = _TranslationsDialogNl._(_root); + @override late final _TranslationsProfilesNl profiles = _TranslationsProfilesNl._(_root); + @override late final _TranslationsConnectionsNl connections = _TranslationsConnectionsNl._(_root); @override late final _TranslationsDiscoverNl discover = _TranslationsDiscoverNl._(_root); @override late final _TranslationsErrorsNl errors = _TranslationsErrorsNl._(_root); @override late final _TranslationsLibrariesNl libraries = _TranslationsLibrariesNl._(_root); @@ -78,11 +82,12 @@ class TranslationsNl with BaseTranslations implements T @override late final _TranslationsServerTasksNl serverTasks = _TranslationsServerTasksNl._(_root); @override late final _TranslationsTraktNl trakt = _TranslationsTraktNl._(_root); @override late final _TranslationsTrackersNl trackers = _TranslationsTrackersNl._(_root); + @override late final _TranslationsAddServerNl addServer = _TranslationsAddServerNl._(_root); } // Path: app -class _TranslationsAppNl implements TranslationsAppEn { - _TranslationsAppNl._(this._root); +class _TranslationsAppNl extends TranslationsAppEn { + _TranslationsAppNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppNl implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthNl implements TranslationsAuthEn { - _TranslationsAuthNl._(this._root); +class _TranslationsAuthNl extends TranslationsAuthEn { + _TranslationsAuthNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field // Translations + @override String get signIn => 'Inloggen'; @override String get signInWithPlex => 'Inloggen met Plex'; @override String get showQRCode => 'Toon QR-code'; @override String get authenticate => 'Authenticeren'; @@ -104,11 +110,19 @@ class _TranslationsAuthNl implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Scan deze QR-code om in te loggen'; @override String get waitingForAuth => 'Wachten op authenticatie...\nVoltooi het inloggen in 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 quickConnectCode => 'Quick Connect-code'; + @override String get quickConnectInstructions => 'Open je Jellyfin-server in een webbrowser, log in en kies Quick Connect in het gebruikersmenu. Voer deze code in om de aanmelding goed te keuren.'; + @override String get quickConnectWaiting => 'Wachten op goedkeuring…'; + @override String get quickConnectCancel => 'Annuleren'; + @override String get quickConnectExpired => 'De Quick Connect-code is verlopen voordat hij werd goedgekeurd. Probeer het opnieuw.'; } // Path: common -class _TranslationsCommonNl implements TranslationsCommonEn { - _TranslationsCommonNl._(this._root); +class _TranslationsCommonNl extends TranslationsCommonEn { + _TranslationsCommonNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonNl implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensNl implements TranslationsScreensEn { - _TranslationsScreensNl._(this._root); +class _TranslationsScreensNl extends TranslationsScreensEn { + _TranslationsScreensNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensNl implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateNl implements TranslationsUpdateEn { - _TranslationsUpdateNl._(this._root); +class _TranslationsUpdateNl extends TranslationsUpdateEn { + _TranslationsUpdateNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateNl implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsNl implements TranslationsSettingsEn { - _TranslationsSettingsNl._(this._root); +class _TranslationsSettingsNl extends TranslationsSettingsEn { + _TranslationsSettingsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn { @override String get gridView => 'Raster'; @override String get listView => 'Lijst'; @override String get showHeroSection => 'Toon hoofdsectie'; - @override String get useGlobalHubs => 'Plex Home-indeling gebruiken'; - @override String get useGlobalHubsDescription => 'Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Servernaam tonen bij hubs'; @override String get showServerNameOnHubsDescription => 'Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.'; @override String get groupLibrariesByServer => 'Bibliotheken groeperen per server'; - @override String get groupLibrariesByServerDescription => 'Toont een kop voor elke Plex-server in de zijbalk wanneer je met meerdere servers verbonden bent.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Zijbalk altijd open houden'; @override String get alwaysKeepSidebarOpenDescription => 'Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan'; @override String get showUnwatchedCount => 'Aantal ongekeken tonen'; @@ -385,8 +399,8 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchNl implements TranslationsSearchEn { - _TranslationsSearchNl._(this._root); +class _TranslationsSearchNl extends TranslationsSearchEn { + _TranslationsSearchNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchNl implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysNl implements TranslationsHotkeysEn { - _TranslationsHotkeysNl._(this._root); +class _TranslationsHotkeysNl extends TranslationsHotkeysEn { + _TranslationsHotkeysNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysNl implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoNl implements TranslationsFileInfoEn { - _TranslationsFileInfoNl._(this._root); +class _TranslationsFileInfoNl extends TranslationsFileInfoEn { + _TranslationsFileInfoNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoNl implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuNl implements TranslationsMediaMenuEn { - _TranslationsMediaMenuNl._(this._root); +class _TranslationsMediaMenuNl extends TranslationsMediaMenuEn { + _TranslationsMediaMenuNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuNl implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityNl implements TranslationsAccessibilityEn { - _TranslationsAccessibilityNl._(this._root); +class _TranslationsAccessibilityNl extends TranslationsAccessibilityEn { + _TranslationsAccessibilityNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityNl implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsNl implements TranslationsTooltipsEn { - _TranslationsTooltipsNl._(this._root); +class _TranslationsTooltipsNl extends TranslationsTooltipsEn { + _TranslationsTooltipsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsNl implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn { - _TranslationsVideoControlsNl._(this._root); +class _TranslationsVideoControlsNl extends TranslationsVideoControlsEn { + _TranslationsVideoControlsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsNl implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusNl implements TranslationsUserStatusEn { - _TranslationsUserStatusNl._(this._root); +class _TranslationsUserStatusNl extends TranslationsUserStatusEn { + _TranslationsUserStatusNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusNl implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesNl implements TranslationsMessagesEn { - _TranslationsMessagesNl._(this._root); +class _TranslationsMessagesNl extends TranslationsMessagesEn { + _TranslationsMessagesNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesNl implements TranslationsMessagesEn { @override String get musicNotSupported => 'Muziek afspelen wordt nog niet ondersteund'; @override String get noDescriptionAvailable => 'Geen beschrijving beschikbaar'; @override String get noProfilesAvailable => 'Geen profielen beschikbaar'; - @override String get contactAdminForProfiles => 'Neem contact op met je Plex-beheerder om profielen toe te voegen'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Kan bibliotheeksectie voor dit item niet bepalen'; @override String get logsCleared => 'Logs gewist'; @override String get logsCopied => 'Logs gekopieerd naar klembord'; @@ -636,8 +650,8 @@ class _TranslationsMessagesNl implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingNl implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingNl._(this._root); +class _TranslationsSubtitlingStylingNl extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingNl implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigNl implements TranslationsMpvConfigEn { - _TranslationsMpvConfigNl._(this._root); +class _TranslationsMpvConfigNl extends TranslationsMpvConfigEn { + _TranslationsMpvConfigNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigNl implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogNl implements TranslationsDialogEn { - _TranslationsDialogNl._(this._root); +class _TranslationsDialogNl extends TranslationsDialogEn { + _TranslationsDialogNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogNl implements TranslationsDialogEn { @override String get confirmAction => 'Bevestig actie'; } +// Path: profiles +class _TranslationsProfilesNl extends TranslationsProfilesEn { + _TranslationsProfilesNl._(TranslationsNl root) : this._root = root, super.internal(root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Plezy-profiel toevoegen'; + @override String get switchingProfile => 'Profiel wisselen…'; + @override String get deleteThisProfileTitle => 'Dit profiel verwijderen?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} wordt verwijderd. Verbindingen worden niet aangetast.'; + @override String get active => 'Actief'; + @override String get manage => 'Beheren'; + @override String get delete => 'Verwijderen'; + @override String get signOut => 'Afmelden'; + @override String get signOutPlexTitle => 'Afmelden bij Plex?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} en alle Plex Home-gebruikers van dit account worden van dit apparaat verwijderd. Je kunt op elk moment opnieuw inloggen.'; + @override String get signedOutPlex => 'Afgemeld bij Plex.'; + @override String get signOutFailed => 'Afmelden mislukt.'; + @override String get sectionTitle => 'Profielen'; + @override String get summarySingle => 'Voeg profielen toe om beheerde gebruikers en lokale identiteiten te combineren'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} profielen · actief: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} profielen'; + @override String get removeConnectionTitle => 'Verbinding verwijderen?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} verliest toegang tot ${connectionLabel}. De verbinding blijft beschikbaar voor andere profielen.'; + @override String get deleteProfileTitle => 'Profiel verwijderen?'; + @override String deleteProfileMessage({required Object displayName}) => 'Hiermee worden ${displayName} en al zijn verbindingen van dit apparaat verwijderd. De onderliggende Plex/Jellyfin-servers worden niet beïnvloed.'; + @override String get profileNameLabel => 'Profielnaam'; + @override String get pinProtectionLabel => 'PIN-beveiliging'; + @override String get pinManagedByPlex => 'PIN wordt beheerd door Plex. Bewerk op plex.tv.'; + @override String get noPinSetEditOnPlex => 'Geen PIN ingesteld. Bewerk de Home-gebruiker op plex.tv om er één te vereisen.'; + @override String get setPin => 'PIN instellen'; + @override String get connectionsLabel => 'Verbindingen'; + @override String get add => 'Toevoegen'; + @override String get deleteProfileButton => 'Profiel verwijderen'; + @override String get noConnectionsHint => 'Geen verbindingen — voeg er één toe om dit profiel te gebruiken.'; + @override String get plexHomeAccount => 'Plex Home-account'; + @override String get connectionDefault => 'Standaard'; + @override String get makeDefault => 'Als standaard instellen'; + @override String get removeConnection => 'Verwijderen'; + @override String borrowAddTo({required Object displayName}) => 'Toevoegen aan ${displayName}'; + @override String get borrowExplain => 'Leen een verbinding van een ander profiel. PIN-beveiligde bronprofielen vragen om de PIN voordat ze delen.'; + @override String get borrowEmpty => 'Nog niets te lenen.'; + @override String get borrowEmptySubtitle => 'Verbind eerst een Plex-account of Jellyfin-server met een ander profiel en kom dan hier terug.'; + @override String get newProfile => 'Nieuw profiel'; + @override String get profileNameHint => 'bijv. Gasten, Kinderen, Woonkamer'; + @override String get pinProtectionOptional => 'PIN-beveiliging (optioneel)'; + @override String get pinExplain => '4-cijferige PIN vereist om naar dit profiel te schakelen. Zachte barrière — iedereen die appgegevens kan wissen, kan deze omzeilen.'; + @override String get continueButton => 'Doorgaan'; + @override String get pinsDontMatch => 'PIN-codes komen niet overeen'; +} + +// Path: connections +class _TranslationsConnectionsNl extends TranslationsConnectionsEn { + _TranslationsConnectionsNl._(TranslationsNl root) : this._root = root, super.internal(root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Verbindingen'; + @override String get addConnection => 'Verbinding toevoegen'; + @override String get addConnectionSubtitleNoProfile => 'Meld je aan met Plex of verbind een Jellyfin-server'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Toevoegen aan ${displayName} — Plex-account, Jellyfin-server of lenen van een ander profiel'; + @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'; +} + // Path: discover -class _TranslationsDiscoverNl implements TranslationsDiscoverEn { - _TranslationsDiscoverNl._(this._root); +class _TranslationsDiscoverNl extends TranslationsDiscoverEn { + _TranslationsDiscoverNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverNl implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Geen inhoud beschikbaar'; @override String get addMediaToLibraries => 'Voeg wat media toe aan je bibliotheken'; @override String get continueWatching => 'Verder kijken'; + @override String get nextUp => 'Volgende'; + @override String get recentlyAdded => 'Recent toegevoegd'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Overzicht'; @override String get cast => 'Acteurs'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverNl implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsNl implements TranslationsErrorsEn { - _TranslationsErrorsNl._(this._root); +class _TranslationsErrorsNl extends TranslationsErrorsEn { + _TranslationsErrorsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Zoeken mislukt: ${error}'; @override String connectionTimeout({required Object context}) => 'Verbinding time-out tijdens laden ${context}'; - @override String get connectionFailed => 'Kan geen verbinding maken met Plex server'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Kon ${context} niet laden: ${error}'; @override String get noClientAvailable => 'Geen client beschikbaar'; @override String authenticationFailed({required Object error}) => 'Authenticatie mislukt: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsNl implements TranslationsErrorsEn { @override String get invalidToken => 'Ongeldig token'; @override String failedToVerifyToken({required Object error}) => 'Kon token niet verifiëren: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Kon niet wisselen naar ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Kon ${displayName} niet verwijderen'; + @override String get failedToRate => 'Beoordeling kon niet worden bijgewerkt'; } // Path: libraries -class _TranslationsLibrariesNl implements TranslationsLibrariesEn { - _TranslationsLibrariesNl._(this._root); +class _TranslationsLibrariesNl extends TranslationsLibrariesEn { + _TranslationsLibrariesNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesNl implements TranslationsLibrariesEn { @override String get folders => 'mappen'; @override late final _TranslationsLibrariesTabsNl tabs = _TranslationsLibrariesTabsNl._(_root); @override late final _TranslationsLibrariesGroupingsNl groupings = _TranslationsLibrariesGroupingsNl._(_root); + @override late final _TranslationsLibrariesFilterCategoriesNl filterCategories = _TranslationsLibrariesFilterCategoriesNl._(_root); + @override late final _TranslationsLibrariesSortLabelsNl sortLabels = _TranslationsLibrariesSortLabelsNl._(_root); } // Path: about -class _TranslationsAboutNl implements TranslationsAboutEn { - _TranslationsAboutNl._(this._root); +class _TranslationsAboutNl extends TranslationsAboutEn { + _TranslationsAboutNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutNl implements TranslationsAboutEn { @override String get title => 'Over'; @override String get openSourceLicenses => 'Open Source licenties'; @override String versionLabel({required Object version}) => 'Versie ${version}'; - @override String get appDescription => 'Een mooie Plex client voor Flutter'; + @override String get appDescription => 'Een mooie Plex- en Jellyfin-client voor Flutter'; @override String get viewLicensesDescription => 'Bekijk licenties van third-party bibliotheken'; } // Path: serverSelection -class _TranslationsServerSelectionNl implements TranslationsServerSelectionEn { - _TranslationsServerSelectionNl._(this._root); +class _TranslationsServerSelectionNl extends TranslationsServerSelectionEn { + _TranslationsServerSelectionNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionNl implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailNl implements TranslationsHubDetailEn { - _TranslationsHubDetailNl._(this._root); +class _TranslationsHubDetailNl extends TranslationsHubDetailEn { + _TranslationsHubDetailNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailNl implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsNl implements TranslationsLogsEn { - _TranslationsLogsNl._(this._root); +class _TranslationsLogsNl extends TranslationsLogsEn { + _TranslationsLogsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsNl implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesNl implements TranslationsLicensesEn { - _TranslationsLicensesNl._(this._root); +class _TranslationsLicensesNl extends TranslationsLicensesEn { + _TranslationsLicensesNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesNl implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationNl implements TranslationsNavigationEn { - _TranslationsNavigationNl._(this._root); +class _TranslationsNavigationNl extends TranslationsNavigationEn { + _TranslationsNavigationNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationNl implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvNl implements TranslationsLiveTvEn { - _TranslationsLiveTvNl._(this._root); +class _TranslationsLiveTvNl extends TranslationsLiveTvEn { + _TranslationsLiveTvNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvNl implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsNl implements TranslationsCollectionsEn { - _TranslationsCollectionsNl._(this._root); +class _TranslationsCollectionsNl extends TranslationsCollectionsEn { + _TranslationsCollectionsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsNl implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsNl implements TranslationsPlaylistsEn { - _TranslationsPlaylistsNl._(this._root); +class _TranslationsPlaylistsNl extends TranslationsPlaylistsEn { + _TranslationsPlaylistsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsNl implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherNl implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherNl._(this._root); +class _TranslationsWatchTogetherNl extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherNl implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Recente kamers'; @override String get renameRoom => 'Kamer hernoemen'; @override String get removeRoom => 'Verwijderen'; + @override String get guestSwitchUnavailable => 'Kon niet schakelen — server niet beschikbaar voor synchronisatie'; + @override String get guestSwitchFailed => 'Kon niet schakelen — inhoud niet gevonden op deze server'; } // Path: downloads -class _TranslationsDownloadsNl implements TranslationsDownloadsEn { - _TranslationsDownloadsNl._(this._root); +class _TranslationsDownloadsNl extends TranslationsDownloadsEn { + _TranslationsDownloadsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsNl implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Synchronisatiefilter'; @override String get syncAllItems => 'Alle items synchroniseren'; @override String get syncUnwatchedItems => 'Ongekeken items synchroniseren'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Server: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Beschikbaar'; + @override String get syncRuleOffline => 'Offline'; + @override String get syncRuleSignInRequired => 'Inloggen vereist'; + @override String get syncRuleNotAvailableForProfile => 'Niet beschikbaar voor huidig profiel'; + @override String get syncRuleUnknownServer => 'Onbekende server'; @override String get syncRuleListCreated => 'Synchronisatieregel aangemaakt'; } // Path: shaders -class _TranslationsShadersNl implements TranslationsShadersEn { - _TranslationsShadersNl._(this._root); +class _TranslationsShadersNl extends TranslationsShadersEn { + _TranslationsShadersNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersNl implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteNl implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteNl._(this._root); +class _TranslationsCompanionRemoteNl extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteNl implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsNl implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsNl._(this._root); +class _TranslationsVideoSettingsNl extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsNl implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerNl implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerNl._(this._root); +class _TranslationsExternalPlayerNl extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerNl implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditNl implements TranslationsMetadataEditEn { - _TranslationsMetadataEditNl._(this._root); +class _TranslationsMetadataEditNl extends TranslationsMetadataEditEn { + _TranslationsMetadataEditNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditNl implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenNl implements TranslationsMatchScreenEn { - _TranslationsMatchScreenNl._(this._root); +class _TranslationsMatchScreenNl extends TranslationsMatchScreenEn { + _TranslationsMatchScreenNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenNl implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksNl implements TranslationsServerTasksEn { - _TranslationsServerTasksNl._(this._root); +class _TranslationsServerTasksNl extends TranslationsServerTasksEn { + _TranslationsServerTasksNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksNl implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktNl implements TranslationsTraktEn { - _TranslationsTraktNl._(this._root); +class _TranslationsTraktNl extends TranslationsTraktEn { + _TranslationsTraktNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktNl implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersNl implements TranslationsTrackersEn { - _TranslationsTrackersNl._(this._root); +class _TranslationsTrackersNl extends TranslationsTrackersEn { + _TranslationsTrackersNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersNl implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterNl libraryFilter = _TranslationsTrackersLibraryFilterNl._(_root); } +// Path: addServer +class _TranslationsAddServerNl extends TranslationsAddServerEn { + _TranslationsAddServerNl._(TranslationsNl root) : this._root = root, super.internal(root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Jellyfin-server toevoegen'; + @override String get jellyfinUrlIntro => 'Voer de URL van je Jellyfin-server in — bijv. `https://jellyfin.example.com`. Je kunt daarna inloggen.'; + @override String get serverUrl => 'Server-URL'; + @override String get findServer => 'Server zoeken'; + @override String get username => 'Gebruikersnaam'; + @override String get password => 'Wachtwoord'; + @override String get signIn => 'Inloggen'; + @override String get change => 'Wijzigen'; + @override String get required => 'Vereist'; + @override String couldNotReachServer({required Object error}) => 'Kon de server niet bereiken: ${error}'; + @override String signInFailed({required Object error}) => 'Inloggen mislukt: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect mislukt: ${error}'; + @override String get addPlexTitle => 'Inloggen met Plex'; + @override String get plexAuthIntro => 'Kies hoe je wilt inloggen bij Plex. De browserflow opent plex.tv waar je de verbinding bevestigt; de QR-optie is handig voor TV / externe apparaten.'; + @override String get plexQRPrompt => 'Scan deze QR-code om in te loggen.'; + @override String get waitingForPlexConfirmation => 'Wachten tot plex.tv je inloggen bevestigt…'; + @override String get pinExpired => 'PIN verlopen vóór inloggen. Probeer opnieuw.'; + @override String get duplicatePlexAccount => 'Dit apparaat is al ingelogd op een Plex-account. Log uit via instellingen om van account te wisselen.'; + @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 addConnectionIntroGlobal => 'Voeg nog een mediaserver toe. Je kunt Plex-accounts en Jellyfin-servers combineren — items van alle gekoppelde backends verschijnen samen op het startscherm.'; + @override String get addConnectionIntroScoped => 'Voeg een nieuwe server toe, of leen er een van een ander profiel.'; + @override String get signInWithPlexCard => 'Inloggen met Plex'; + @override String get signInWithPlexCardSubtitle => 'Autoriseer dit apparaat met je Plex-account. Servers gedeeld met het account worden automatisch toegevoegd.'; + @override String get signInWithPlexCardSubtitleScoped => 'Autoriseer een nieuw Plex-account. De bijbehorende Home-gebruikers verschijnen als profielen.'; + @override String get connectToJellyfinCard => 'Verbinden met Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Voer de URL van je Jellyfin-server in en log in met gebruikersnaam + wachtwoord (Quick Connect komt eraan).'; + @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.'; + @override String get borrowFromAnotherProfile => 'Lenen van een ander profiel'; + @override String get borrowFromAnotherProfileSubtitle => 'Hergebruik een verbinding die al aan een ander profiel is gekoppeld. PIN-beveiligde bronprofielen vragen om de PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsNl implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsNl._(this._root); +class _TranslationsHotkeysActionsNl extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsNl implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsNl implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsNl._(this._root); +class _TranslationsVideoControlsPipErrorsNl extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsNl implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsNl implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsNl._(this._root); +class _TranslationsLibrariesTabsNl extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsNl implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsNl implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsNl._(this._root); +class _TranslationsLibrariesGroupingsNl extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsNl implements TranslationsLibrariesGrouping @override String get folders => 'Mappen'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesNl extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesNl._(TranslationsNl root) : this._root = root, super.internal(root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String get genre => 'Genre'; + @override String get year => 'Jaar'; + @override String get contentRating => 'Leeftijdsclassificatie'; + @override String get tag => 'Tag'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsNl extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsNl._(TranslationsNl root) : this._root = root, super.internal(root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String get title => 'Titel'; + @override String get dateAdded => 'Toegevoegd op'; + @override String get releaseDate => 'Uitgavedatum'; + @override String get rating => 'Beoordeling'; + @override String get lastPlayed => 'Laatst afgespeeld'; + @override String get playCount => 'Aantal afspelingen'; + @override String get random => 'Willekeurig'; + @override String get dateShared => 'Gedeeld op'; + @override String get latestEpisodeAirDate => 'Laatste afleveringsuitzending'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionNl implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionNl._(this._root); +class _TranslationsCompanionRemoteSessionNl extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionNl implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingNl implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingNl._(this._root); +class _TranslationsCompanionRemotePairingNl extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingNl implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteNl implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteNl._(this._root); +class _TranslationsCompanionRemoteRemoteNl extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteNl implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesNl implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesNl._(this._root); +class _TranslationsTrackersServicesNl extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesNl implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeNl implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeNl._(this._root); +class _TranslationsTrackersDeviceCodeNl extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeNl implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyNl implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyNl._(this._root); +class _TranslationsTrackersOauthProxyNl extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyNl implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterNl implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterNl._(this._root); +class _TranslationsTrackersLibraryFilterNl extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterNl._(TranslationsNl root) : this._root = root, super.internal(root); final TranslationsNl _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsNl { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Inloggen', 'auth.signInWithPlex' => 'Inloggen met Plex', 'auth.showQRCode' => 'Toon QR-code', 'auth.authenticate' => 'Authenticeren', @@ -1550,6 +1719,14 @@ extension on TranslationsNl { 'auth.scanQRToSignIn' => 'Scan deze QR-code om in te loggen', 'auth.waitingForAuth' => 'Wachten op authenticatie...\nVoltooi het inloggen in je browser.', 'auth.useBrowser' => 'Gebruik browser', + 'auth.or' => 'of', + 'auth.connectToJellyfin' => 'Verbinden met Jellyfin', + 'auth.useQuickConnect' => 'Quick Connect gebruiken', + 'auth.quickConnectCode' => 'Quick Connect-code', + 'auth.quickConnectInstructions' => 'Open je Jellyfin-server in een webbrowser, log in en kies Quick Connect in het gebruikersmenu. Voer deze code in om de aanmelding goed te keuren.', + 'auth.quickConnectWaiting' => 'Wachten op goedkeuring…', + 'auth.quickConnectCancel' => 'Annuleren', + 'auth.quickConnectExpired' => 'De Quick Connect-code is verlopen voordat hij werd goedgekeurd. Probeer het opnieuw.', 'common.cancel' => 'Annuleren', 'common.save' => 'Opslaan', 'common.close' => 'Sluiten', @@ -1636,12 +1813,12 @@ extension on TranslationsNl { 'settings.gridView' => 'Raster', 'settings.listView' => 'Lijst', 'settings.showHeroSection' => 'Toon hoofdsectie', - 'settings.useGlobalHubs' => 'Plex Home-indeling gebruiken', - 'settings.useGlobalHubsDescription' => 'Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Servernaam tonen bij hubs', 'settings.showServerNameOnHubsDescription' => 'Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.', 'settings.groupLibrariesByServer' => 'Bibliotheken groeperen per server', - 'settings.groupLibrariesByServerDescription' => 'Toont een kop voor elke Plex-server in de zijbalk wanneer je met meerdere servers verbonden bent.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Zijbalk altijd open houden', 'settings.alwaysKeepSidebarOpenDescription' => 'Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan', 'settings.showUnwatchedCount' => 'Aantal ongekeken tonen', @@ -1962,7 +2139,7 @@ extension on TranslationsNl { 'messages.musicNotSupported' => 'Muziek afspelen wordt nog niet ondersteund', 'messages.noDescriptionAvailable' => 'Geen beschrijving beschikbaar', 'messages.noProfilesAvailable' => 'Geen profielen beschikbaar', - 'messages.contactAdminForProfiles' => 'Neem contact op met je Plex-beheerder om profielen toe te voegen', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Kan bibliotheeksectie voor dit item niet bepalen', 'messages.logsCleared' => 'Logs gewist', 'messages.logsCopied' => 'Logs gekopieerd naar klembord', @@ -2016,11 +2193,65 @@ extension on TranslationsNl { 'mpvConfig.confirmDeletePreset' => 'Weet je zeker dat je deze voorinstelling wilt verwijderen?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Bevestig actie', + 'profiles.addPlezyProfile' => 'Plezy-profiel toevoegen', + 'profiles.switchingProfile' => 'Profiel wisselen…', + 'profiles.deleteThisProfileTitle' => 'Dit profiel verwijderen?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} wordt verwijderd. Verbindingen worden niet aangetast.', + 'profiles.active' => 'Actief', + 'profiles.manage' => 'Beheren', + 'profiles.delete' => 'Verwijderen', + 'profiles.signOut' => 'Afmelden', + 'profiles.signOutPlexTitle' => 'Afmelden bij Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} en alle Plex Home-gebruikers van dit account worden van dit apparaat verwijderd. Je kunt op elk moment opnieuw inloggen.', + 'profiles.signedOutPlex' => 'Afgemeld bij Plex.', + 'profiles.signOutFailed' => 'Afmelden mislukt.', + 'profiles.sectionTitle' => 'Profielen', + 'profiles.summarySingle' => 'Voeg profielen toe om beheerde gebruikers en lokale identiteiten te combineren', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} profielen · actief: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} profielen', + 'profiles.removeConnectionTitle' => 'Verbinding verwijderen?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} verliest toegang tot ${connectionLabel}. De verbinding blijft beschikbaar voor andere profielen.', + 'profiles.deleteProfileTitle' => 'Profiel verwijderen?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Hiermee worden ${displayName} en al zijn verbindingen van dit apparaat verwijderd. De onderliggende Plex/Jellyfin-servers worden niet beïnvloed.', + 'profiles.profileNameLabel' => 'Profielnaam', + 'profiles.pinProtectionLabel' => 'PIN-beveiliging', + 'profiles.pinManagedByPlex' => 'PIN wordt beheerd door Plex. Bewerk op plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'Geen PIN ingesteld. Bewerk de Home-gebruiker op plex.tv om er één te vereisen.', + 'profiles.setPin' => 'PIN instellen', + 'profiles.connectionsLabel' => 'Verbindingen', + 'profiles.add' => 'Toevoegen', + 'profiles.deleteProfileButton' => 'Profiel verwijderen', + 'profiles.noConnectionsHint' => 'Geen verbindingen — voeg er één toe om dit profiel te gebruiken.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Plex Home-account', + 'profiles.connectionDefault' => 'Standaard', + 'profiles.makeDefault' => 'Als standaard instellen', + 'profiles.removeConnection' => 'Verwijderen', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Toevoegen aan ${displayName}', + 'profiles.borrowExplain' => 'Leen een verbinding van een ander profiel. PIN-beveiligde bronprofielen vragen om de PIN voordat ze delen.', + 'profiles.borrowEmpty' => 'Nog niets te lenen.', + 'profiles.borrowEmptySubtitle' => 'Verbind eerst een Plex-account of Jellyfin-server met een ander profiel en kom dan hier terug.', + 'profiles.newProfile' => 'Nieuw profiel', + 'profiles.profileNameHint' => 'bijv. Gasten, Kinderen, Woonkamer', + 'profiles.pinProtectionOptional' => 'PIN-beveiliging (optioneel)', + 'profiles.pinExplain' => '4-cijferige PIN vereist om naar dit profiel te schakelen. Zachte barrière — iedereen die appgegevens kan wissen, kan deze omzeilen.', + 'profiles.continueButton' => 'Doorgaan', + 'profiles.pinsDontMatch' => 'PIN-codes komen niet overeen', + 'connections.sectionTitle' => 'Verbindingen', + 'connections.addConnection' => 'Verbinding toevoegen', + 'connections.addConnectionSubtitleNoProfile' => 'Meld je aan met Plex of verbind een Jellyfin-server', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Toevoegen aan ${displayName} — Plex-account, Jellyfin-server of lenen van een ander profiel', + 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessie verlopen voor ${name}', + 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessie verlopen voor ${count} servers', + 'connections.signInAgain' => 'Opnieuw aanmelden', 'discover.title' => 'Ontdekken', 'discover.switchProfile' => 'Wissel van profiel', 'discover.noContentAvailable' => 'Geen inhoud beschikbaar', 'discover.addMediaToLibraries' => 'Voeg wat media toe aan je bibliotheken', 'discover.continueWatching' => 'Verder kijken', + 'discover.nextUp' => 'Volgende', + 'discover.recentlyAdded' => 'Recent toegevoegd', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Overzicht', 'discover.cast' => 'Acteurs', @@ -2032,7 +2263,7 @@ extension on TranslationsNl { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min over', 'errors.searchFailed' => ({required Object error}) => 'Zoeken mislukt: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Verbinding time-out tijdens laden ${context}', - 'errors.connectionFailed' => 'Kan geen verbinding maken met Plex server', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Kon ${context} niet laden: ${error}', 'errors.noClientAvailable' => 'Geen client beschikbaar', 'errors.authenticationFailed' => ({required Object error}) => 'Authenticatie mislukt: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsNl { 'errors.invalidToken' => 'Ongeldig token', 'errors.failedToVerifyToken' => ({required Object error}) => 'Kon token niet verifiëren: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Kon niet wisselen naar ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Kon ${displayName} niet verwijderen', + 'errors.failedToRate' => 'Beoordeling kon niet worden bijgewerkt', 'libraries.title' => 'Bibliotheken', 'libraries.scanLibraryFiles' => 'Scan bibliotheek bestanden', 'libraries.scanLibrary' => 'Scan bibliotheek', @@ -2054,8 +2287,6 @@ extension on TranslationsNl { 'libraries.analyzing' => ({required Object title}) => 'Analyseren "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Analyse gestart voor "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Kon bibliotheek niet analyseren: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Geen bibliotheken gevonden', 'libraries.allLibrariesHidden' => 'Alle bibliotheken zijn verborgen', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Verborgen bibliotheken (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsNl { 'libraries.groupings.seasons' => 'Seizoenen', 'libraries.groupings.episodes' => 'Afleveringen', 'libraries.groupings.folders' => 'Mappen', + 'libraries.filterCategories.genre' => 'Genre', + 'libraries.filterCategories.year' => 'Jaar', + 'libraries.filterCategories.contentRating' => 'Leeftijdsclassificatie', + 'libraries.filterCategories.tag' => 'Tag', + 'libraries.sortLabels.title' => 'Titel', + 'libraries.sortLabels.dateAdded' => 'Toegevoegd op', + 'libraries.sortLabels.releaseDate' => 'Uitgavedatum', + 'libraries.sortLabels.rating' => 'Beoordeling', + 'libraries.sortLabels.lastPlayed' => 'Laatst afgespeeld', + 'libraries.sortLabels.playCount' => 'Aantal afspelingen', + 'libraries.sortLabels.random' => 'Willekeurig', + 'libraries.sortLabels.dateShared' => 'Gedeeld op', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Laatste afleveringsuitzending', 'about.title' => 'Over', 'about.openSourceLicenses' => 'Open Source licenties', 'about.versionLabel' => ({required Object version}) => 'Versie ${version}', - 'about.appDescription' => 'Een mooie Plex client voor Flutter', + 'about.appDescription' => 'Een mooie Plex- en Jellyfin-client voor Flutter', 'about.viewLicensesDescription' => 'Bekijk licenties van third-party bibliotheken', 'serverSelection.allServerConnectionsFailed' => 'Kon niet verbinden met servers. Controleer je netwerk en probeer opnieuw.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Geen servers gevonden voor ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsNl { 'watchTogether.recentRooms' => 'Recente kamers', 'watchTogether.renameRoom' => 'Kamer hernoemen', 'watchTogether.removeRoom' => 'Verwijderen', + 'watchTogether.guestSwitchUnavailable' => 'Kon niet schakelen — server niet beschikbaar voor synchronisatie', + 'watchTogether.guestSwitchFailed' => 'Kon niet schakelen — inhoud niet gevonden op deze server', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Beheren', 'downloads.tvShows' => 'Series', @@ -2291,6 +2537,12 @@ extension on TranslationsNl { 'downloads.editSyncFilter' => 'Synchronisatiefilter', 'downloads.syncAllItems' => 'Alle items synchroniseren', 'downloads.syncUnwatchedItems' => 'Ongekeken items synchroniseren', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Beschikbaar', + 'downloads.syncRuleOffline' => 'Offline', + 'downloads.syncRuleSignInRequired' => 'Inloggen vereist', + 'downloads.syncRuleNotAvailableForProfile' => 'Niet beschikbaar voor huidig profiel', + 'downloads.syncRuleUnknownServer' => 'Onbekende server', 'downloads.syncRuleListCreated' => 'Synchronisatieregel aangemaakt', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Geen videoverbetering', @@ -2484,6 +2736,8 @@ extension on TranslationsNl { 'trakt.disconnectConfirmBody' => 'Plezy stopt met het versturen van afspeelgebeurtenissen naar Trakt. Je kunt op elk moment opnieuw verbinden.', 'trakt.scrobble' => 'Realtime scrobbling', 'trakt.scrobbleDescription' => 'Verstuur play-, pauze- en stopgebeurtenissen tijdens afspelen naar Trakt.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Bekeken-status synchroniseren', 'trakt.watchedSyncDescription' => 'Wanneer je items als bekeken markeert in Plezy, worden ze ook op Trakt gemarkeerd.', 'trackers.title' => 'Trackers', @@ -2519,6 +2773,38 @@ extension on TranslationsNl { 'trackers.libraryFilter.modeHintWhitelist' => 'Synchroniseer alleen de hieronder aangevinkte bibliotheken.', 'trackers.libraryFilter.libraries' => 'Bibliotheken', 'trackers.libraryFilter.noLibraries' => 'Geen bibliotheken beschikbaar', + 'addServer.addJellyfinTitle' => 'Jellyfin-server toevoegen', + 'addServer.jellyfinUrlIntro' => 'Voer de URL van je Jellyfin-server in — bijv. `https://jellyfin.example.com`. Je kunt daarna inloggen.', + 'addServer.serverUrl' => 'Server-URL', + 'addServer.findServer' => 'Server zoeken', + 'addServer.username' => 'Gebruikersnaam', + 'addServer.password' => 'Wachtwoord', + 'addServer.signIn' => 'Inloggen', + 'addServer.change' => 'Wijzigen', + 'addServer.required' => 'Vereist', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Kon de server niet bereiken: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Inloggen mislukt: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect mislukt: ${error}', + 'addServer.addPlexTitle' => 'Inloggen met Plex', + 'addServer.plexAuthIntro' => 'Kies hoe je wilt inloggen bij Plex. De browserflow opent plex.tv waar je de verbinding bevestigt; de QR-optie is handig voor TV / externe apparaten.', + 'addServer.plexQRPrompt' => 'Scan deze QR-code om in te loggen.', + 'addServer.waitingForPlexConfirmation' => 'Wachten tot plex.tv je inloggen bevestigt…', + 'addServer.pinExpired' => 'PIN verlopen vóór inloggen. Probeer opnieuw.', + 'addServer.duplicatePlexAccount' => 'Dit apparaat is al ingelogd op een Plex-account. Log uit via instellingen om van account te wisselen.', + '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.addConnectionIntroGlobal' => 'Voeg nog een mediaserver toe. Je kunt Plex-accounts en Jellyfin-servers combineren — items van alle gekoppelde backends verschijnen samen op het startscherm.', + 'addServer.addConnectionIntroScoped' => 'Voeg een nieuwe server toe, of leen er een van een ander profiel.', + 'addServer.signInWithPlexCard' => 'Inloggen met Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Autoriseer dit apparaat met je Plex-account. Servers gedeeld met het account worden automatisch toegevoegd.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Autoriseer een nieuw Plex-account. De bijbehorende Home-gebruikers verschijnen als profielen.', + 'addServer.connectToJellyfinCard' => 'Verbinden met Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Voer de URL van je Jellyfin-server in en log in met gebruikersnaam + wachtwoord (Quick Connect komt eraan).', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.', + 'addServer.borrowFromAnotherProfile' => 'Lenen van een ander profiel', + 'addServer.borrowFromAnotherProfileSubtitle' => 'Hergebruik een verbinding die al aan een ander profiel is gekoppeld. PIN-beveiligde bronprofielen vragen om de PIN.', _ => null, }; } diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 9ea61a7a..1b530b62 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsPl with BaseTranslations implements Translations { +class TranslationsPl extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsPl({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsPl with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsPl with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsPl _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsPl with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingPl subtitlingStyling = _TranslationsSubtitlingStylingPl._(_root); @override late final _TranslationsMpvConfigPl mpvConfig = _TranslationsMpvConfigPl._(_root); @override late final _TranslationsDialogPl dialog = _TranslationsDialogPl._(_root); + @override late final _TranslationsProfilesPl profiles = _TranslationsProfilesPl._(_root); + @override late final _TranslationsConnectionsPl connections = _TranslationsConnectionsPl._(_root); @override late final _TranslationsDiscoverPl discover = _TranslationsDiscoverPl._(_root); @override late final _TranslationsErrorsPl errors = _TranslationsErrorsPl._(_root); @override late final _TranslationsLibrariesPl libraries = _TranslationsLibrariesPl._(_root); @@ -78,11 +82,12 @@ class TranslationsPl with BaseTranslations implements T @override late final _TranslationsServerTasksPl serverTasks = _TranslationsServerTasksPl._(_root); @override late final _TranslationsTraktPl trakt = _TranslationsTraktPl._(_root); @override late final _TranslationsTrackersPl trackers = _TranslationsTrackersPl._(_root); + @override late final _TranslationsAddServerPl addServer = _TranslationsAddServerPl._(_root); } // Path: app -class _TranslationsAppPl implements TranslationsAppEn { - _TranslationsAppPl._(this._root); +class _TranslationsAppPl extends TranslationsAppEn { + _TranslationsAppPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppPl implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthPl implements TranslationsAuthEn { - _TranslationsAuthPl._(this._root); +class _TranslationsAuthPl extends TranslationsAuthEn { + _TranslationsAuthPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field // Translations + @override String get signIn => 'Zaloguj się'; @override String get signInWithPlex => 'Zaloguj się przez Plex'; @override String get showQRCode => 'Pokaż kod QR'; @override String get authenticate => 'Uwierzytelnienie'; @@ -104,11 +110,19 @@ class _TranslationsAuthPl implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Zeskanuj ten kod QR, aby się zalogować'; @override String get waitingForAuth => 'Oczekiwanie na uwierzytelnienie...\nDokończ logowanie 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 quickConnectCode => 'Kod Quick Connect'; + @override String get quickConnectInstructions => 'Otwórz swój serwer Jellyfin w przeglądarce, zaloguj się i wybierz Quick Connect z menu użytkownika. Wprowadź ten kod, aby zatwierdzić logowanie.'; + @override String get quickConnectWaiting => 'Oczekiwanie na zatwierdzenie…'; + @override String get quickConnectCancel => 'Anuluj'; + @override String get quickConnectExpired => 'Kod Quick Connect wygasł przed zatwierdzeniem. Spróbuj ponownie.'; } // Path: common -class _TranslationsCommonPl implements TranslationsCommonEn { - _TranslationsCommonPl._(this._root); +class _TranslationsCommonPl extends TranslationsCommonEn { + _TranslationsCommonPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonPl implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensPl implements TranslationsScreensEn { - _TranslationsScreensPl._(this._root); +class _TranslationsScreensPl extends TranslationsScreensEn { + _TranslationsScreensPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensPl implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdatePl implements TranslationsUpdateEn { - _TranslationsUpdatePl._(this._root); +class _TranslationsUpdatePl extends TranslationsUpdateEn { + _TranslationsUpdatePl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdatePl implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsPl implements TranslationsSettingsEn { - _TranslationsSettingsPl._(this._root); +class _TranslationsSettingsPl extends TranslationsSettingsEn { + _TranslationsSettingsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsPl implements TranslationsSettingsEn { @override String get gridView => 'Siatka'; @override String get listView => 'Lista'; @override String get showHeroSection => 'Pokaż sekcję wyróżnioną'; - @override String get useGlobalHubs => 'Użyj układu Plex Home'; - @override String get useGlobalHubsDescription => 'Pokaż huby strony głównej jak w oficjalnym kliencie Plex. Gdy wyłączone, pokazuje rekomendacje per biblioteka.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Pokaż nazwę serwera w hubach'; @override String get showServerNameOnHubsDescription => 'Zawsze wyświetlaj nazwę serwera w tytułach hubów. Gdy wyłączone, pokazuje tylko dla zduplikowanych nazw.'; @override String get groupLibrariesByServer => 'Grupuj biblioteki według serwera'; - @override String get groupLibrariesByServerDescription => 'Pokazuj nagłówek dla każdego serwera Plex na pasku bocznym, gdy jesteś połączony z wieloma serwerami.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Zawsze utrzymuj panel boczny otwarty'; @override String get alwaysKeepSidebarOpenDescription => 'Panel boczny jest rozwinięty, a obszar treści dostosowuje się'; @override String get showUnwatchedCount => 'Pokaż liczbę nieobejrzanych'; @@ -385,8 +399,8 @@ class _TranslationsSettingsPl implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchPl implements TranslationsSearchEn { - _TranslationsSearchPl._(this._root); +class _TranslationsSearchPl extends TranslationsSearchEn { + _TranslationsSearchPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchPl implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysPl implements TranslationsHotkeysEn { - _TranslationsHotkeysPl._(this._root); +class _TranslationsHotkeysPl extends TranslationsHotkeysEn { + _TranslationsHotkeysPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysPl implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoPl implements TranslationsFileInfoEn { - _TranslationsFileInfoPl._(this._root); +class _TranslationsFileInfoPl extends TranslationsFileInfoEn { + _TranslationsFileInfoPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoPl implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuPl implements TranslationsMediaMenuEn { - _TranslationsMediaMenuPl._(this._root); +class _TranslationsMediaMenuPl extends TranslationsMediaMenuEn { + _TranslationsMediaMenuPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuPl implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityPl implements TranslationsAccessibilityEn { - _TranslationsAccessibilityPl._(this._root); +class _TranslationsAccessibilityPl extends TranslationsAccessibilityEn { + _TranslationsAccessibilityPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityPl implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsPl implements TranslationsTooltipsEn { - _TranslationsTooltipsPl._(this._root); +class _TranslationsTooltipsPl extends TranslationsTooltipsEn { + _TranslationsTooltipsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsPl implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsPl implements TranslationsVideoControlsEn { - _TranslationsVideoControlsPl._(this._root); +class _TranslationsVideoControlsPl extends TranslationsVideoControlsEn { + _TranslationsVideoControlsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsPl implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusPl implements TranslationsUserStatusEn { - _TranslationsUserStatusPl._(this._root); +class _TranslationsUserStatusPl extends TranslationsUserStatusEn { + _TranslationsUserStatusPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusPl implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesPl implements TranslationsMessagesEn { - _TranslationsMessagesPl._(this._root); +class _TranslationsMessagesPl extends TranslationsMessagesEn { + _TranslationsMessagesPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesPl implements TranslationsMessagesEn { @override String get musicNotSupported => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane'; @override String get noDescriptionAvailable => 'Brak dostępnego opisu'; @override String get noProfilesAvailable => 'Brak dostępnych profili'; - @override String get contactAdminForProfiles => 'Skontaktuj się z administratorem Plex, aby dodać profile'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Nie można określić sekcji biblioteki dla tego elementu'; @override String get logsCleared => 'Logi wyczyszczone'; @override String get logsCopied => 'Logi skopiowane do schowka'; @@ -636,8 +650,8 @@ class _TranslationsMessagesPl implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingPl implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingPl._(this._root); +class _TranslationsSubtitlingStylingPl extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingPl implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigPl implements TranslationsMpvConfigEn { - _TranslationsMpvConfigPl._(this._root); +class _TranslationsMpvConfigPl extends TranslationsMpvConfigEn { + _TranslationsMpvConfigPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigPl implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogPl implements TranslationsDialogEn { - _TranslationsDialogPl._(this._root); +class _TranslationsDialogPl extends TranslationsDialogEn { + _TranslationsDialogPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogPl implements TranslationsDialogEn { @override String get confirmAction => 'Potwierdź działanie'; } +// Path: profiles +class _TranslationsProfilesPl extends TranslationsProfilesEn { + _TranslationsProfilesPl._(TranslationsPl root) : this._root = root, super.internal(root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Dodaj profil Plezy'; + @override String get switchingProfile => 'Przełączanie profilu…'; + @override String get deleteThisProfileTitle => 'Usunąć ten profil?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} zostanie usunięty. Połączenia nie zostaną zmienione.'; + @override String get active => 'Aktywny'; + @override String get manage => 'Zarządzaj'; + @override String get delete => 'Usuń'; + @override String get signOut => 'Wyloguj się'; + @override String get signOutPlexTitle => 'Wylogować się z Plex?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} oraz wszyscy użytkownicy Plex Home na tym koncie zostaną usunięci z tego urządzenia. W każdej chwili możesz się zalogować ponownie.'; + @override String get signedOutPlex => 'Wylogowano z Plex.'; + @override String get signOutFailed => 'Wylogowanie nie powiodło się.'; + @override String get sectionTitle => 'Profile'; + @override String get summarySingle => 'Dodaj profile, aby łączyć zarządzanych użytkowników i tożsamości lokalne'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} profili · aktywny: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} profili'; + @override String get removeConnectionTitle => 'Usunąć połączenie?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} straci dostęp do ${connectionLabel}. Samo połączenie pozostanie dostępne dla innych profili.'; + @override String get deleteProfileTitle => 'Usunąć profil?'; + @override String deleteProfileMessage({required Object displayName}) => 'Spowoduje to usunięcie ${displayName} i wszystkich jego połączeń z tego urządzenia. Nie wpłynie to na same serwery Plex/Jellyfin.'; + @override String get profileNameLabel => 'Nazwa profilu'; + @override String get pinProtectionLabel => 'Ochrona PIN-em'; + @override String get pinManagedByPlex => 'PIN zarządzany przez Plex. Edytuj na plex.tv.'; + @override String get noPinSetEditOnPlex => 'Nie ustawiono PIN-u. Aby go wymagać, edytuj użytkownika Home na plex.tv.'; + @override String get setPin => 'Ustaw PIN'; + @override String get connectionsLabel => 'Połączenia'; + @override String get add => 'Dodaj'; + @override String get deleteProfileButton => 'Usuń profil'; + @override String get noConnectionsHint => 'Brak połączeń — dodaj jedno, aby używać tego profilu.'; + @override String get plexHomeAccount => 'Konto Plex Home'; + @override String get connectionDefault => 'Domyślne'; + @override String get makeDefault => 'Ustaw jako domyślne'; + @override String get removeConnection => 'Usuń'; + @override String borrowAddTo({required Object displayName}) => 'Dodaj do ${displayName}'; + @override String get borrowExplain => 'Pożycz połączenie z innego profilu. Profile źródłowe chronione PIN-em proszą o PIN przed udostępnieniem.'; + @override String get borrowEmpty => 'Nic do pożyczenia.'; + @override String get borrowEmptySubtitle => 'Podłącz najpierw konto Plex lub serwer Jellyfin do innego profilu i wróć tutaj.'; + @override String get newProfile => 'Nowy profil'; + @override String get profileNameHint => 'np. Goście, Dzieci, Salon'; + @override String get pinProtectionOptional => 'Ochrona PIN-em (opcjonalnie)'; + @override String get pinExplain => '4-cyfrowy PIN wymagany do przełączenia się na ten profil. Miękka bariera — każdy kto może wyczyścić dane aplikacji, może ją obejść.'; + @override String get continueButton => 'Kontynuuj'; + @override String get pinsDontMatch => 'PIN-y nie pasują'; +} + +// Path: connections +class _TranslationsConnectionsPl extends TranslationsConnectionsEn { + _TranslationsConnectionsPl._(TranslationsPl root) : this._root = root, super.internal(root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Połączenia'; + @override String get addConnection => 'Dodaj połączenie'; + @override String get addConnectionSubtitleNoProfile => 'Zaloguj się przez Plex lub połącz serwer Jellyfin'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Dodaj do ${displayName} — konto Plex, serwer Jellyfin lub pożycz z innego profilu'; + @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'; +} + // Path: discover -class _TranslationsDiscoverPl implements TranslationsDiscoverEn { - _TranslationsDiscoverPl._(this._root); +class _TranslationsDiscoverPl extends TranslationsDiscoverEn { + _TranslationsDiscoverPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverPl implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Brak dostępnych treści'; @override String get addMediaToLibraries => 'Dodaj multimedia do swoich bibliotek'; @override String get continueWatching => 'Kontynuuj oglądanie'; + @override String get nextUp => 'Następny odcinek'; + @override String get recentlyAdded => 'Ostatnio dodane'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Opis'; @override String get cast => 'Obsada'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverPl implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsPl implements TranslationsErrorsEn { - _TranslationsErrorsPl._(this._root); +class _TranslationsErrorsPl extends TranslationsErrorsEn { + _TranslationsErrorsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Wyszukiwanie nie powiodło się: ${error}'; @override String connectionTimeout({required Object context}) => 'Limit czasu połączenia przy ładowaniu ${context}'; - @override String get connectionFailed => 'Nie można połączyć z serwerem Plex'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Nie udało się załadować ${context}: ${error}'; @override String get noClientAvailable => 'Brak dostępnego klienta'; @override String authenticationFailed({required Object error}) => 'Uwierzytelnienie nie powiodło się: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsPl implements TranslationsErrorsEn { @override String get invalidToken => 'Nieprawidłowy token'; @override String failedToVerifyToken({required Object error}) => 'Nie udało się zweryfikować tokena: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Nie udało się przełączyć na ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Nie udało się usunąć ${displayName}'; + @override String get failedToRate => 'Nie udało się zaktualizować oceny'; } // Path: libraries -class _TranslationsLibrariesPl implements TranslationsLibrariesEn { - _TranslationsLibrariesPl._(this._root); +class _TranslationsLibrariesPl extends TranslationsLibrariesEn { + _TranslationsLibrariesPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesPl implements TranslationsLibrariesEn { @override String get folders => 'foldery'; @override late final _TranslationsLibrariesTabsPl tabs = _TranslationsLibrariesTabsPl._(_root); @override late final _TranslationsLibrariesGroupingsPl groupings = _TranslationsLibrariesGroupingsPl._(_root); + @override late final _TranslationsLibrariesFilterCategoriesPl filterCategories = _TranslationsLibrariesFilterCategoriesPl._(_root); + @override late final _TranslationsLibrariesSortLabelsPl sortLabels = _TranslationsLibrariesSortLabelsPl._(_root); } // Path: about -class _TranslationsAboutPl implements TranslationsAboutEn { - _TranslationsAboutPl._(this._root); +class _TranslationsAboutPl extends TranslationsAboutEn { + _TranslationsAboutPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutPl implements TranslationsAboutEn { @override String get title => 'O aplikacji'; @override String get openSourceLicenses => 'Licencje open source'; @override String versionLabel({required Object version}) => 'Wersja ${version}'; - @override String get appDescription => 'Piękny klient Plex na Flutter'; + @override String get appDescription => 'Piękny klient Plex i Jellyfin na Flutter'; @override String get viewLicensesDescription => 'Zobacz licencje bibliotek zewnętrznych'; } // Path: serverSelection -class _TranslationsServerSelectionPl implements TranslationsServerSelectionEn { - _TranslationsServerSelectionPl._(this._root); +class _TranslationsServerSelectionPl extends TranslationsServerSelectionEn { + _TranslationsServerSelectionPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionPl implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailPl implements TranslationsHubDetailEn { - _TranslationsHubDetailPl._(this._root); +class _TranslationsHubDetailPl extends TranslationsHubDetailEn { + _TranslationsHubDetailPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailPl implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsPl implements TranslationsLogsEn { - _TranslationsLogsPl._(this._root); +class _TranslationsLogsPl extends TranslationsLogsEn { + _TranslationsLogsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsPl implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesPl implements TranslationsLicensesEn { - _TranslationsLicensesPl._(this._root); +class _TranslationsLicensesPl extends TranslationsLicensesEn { + _TranslationsLicensesPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesPl implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationPl implements TranslationsNavigationEn { - _TranslationsNavigationPl._(this._root); +class _TranslationsNavigationPl extends TranslationsNavigationEn { + _TranslationsNavigationPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationPl implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvPl implements TranslationsLiveTvEn { - _TranslationsLiveTvPl._(this._root); +class _TranslationsLiveTvPl extends TranslationsLiveTvEn { + _TranslationsLiveTvPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvPl implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsPl implements TranslationsCollectionsEn { - _TranslationsCollectionsPl._(this._root); +class _TranslationsCollectionsPl extends TranslationsCollectionsEn { + _TranslationsCollectionsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsPl implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsPl implements TranslationsPlaylistsEn { - _TranslationsPlaylistsPl._(this._root); +class _TranslationsPlaylistsPl extends TranslationsPlaylistsEn { + _TranslationsPlaylistsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsPl implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherPl implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherPl._(this._root); +class _TranslationsWatchTogetherPl extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherPl implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Ostatnie pokoje'; @override String get renameRoom => 'Zmień nazwę pokoju'; @override String get removeRoom => 'Usuń'; + @override String get guestSwitchUnavailable => 'Nie można przełączyć — serwer niedostępny do synchronizacji'; + @override String get guestSwitchFailed => 'Nie można przełączyć — nie znaleziono treści na tym serwerze'; } // Path: downloads -class _TranslationsDownloadsPl implements TranslationsDownloadsEn { - _TranslationsDownloadsPl._(this._root); +class _TranslationsDownloadsPl extends TranslationsDownloadsEn { + _TranslationsDownloadsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsPl implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Filtr synchronizacji'; @override String get syncAllItems => 'Synchronizuję wszystkie elementy'; @override String get syncUnwatchedItems => 'Synchronizuję nieobejrzane elementy'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Serwer: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Dostępne'; + @override String get syncRuleOffline => 'Offline'; + @override String get syncRuleSignInRequired => 'Wymagane logowanie'; + @override String get syncRuleNotAvailableForProfile => 'Niedostępne dla bieżącego profilu'; + @override String get syncRuleUnknownServer => 'Nieznany serwer'; @override String get syncRuleListCreated => 'Utworzono regułę synchronizacji'; } // Path: shaders -class _TranslationsShadersPl implements TranslationsShadersEn { - _TranslationsShadersPl._(this._root); +class _TranslationsShadersPl extends TranslationsShadersEn { + _TranslationsShadersPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersPl implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemotePl implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemotePl._(this._root); +class _TranslationsCompanionRemotePl extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemotePl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemotePl implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsPl implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsPl._(this._root); +class _TranslationsVideoSettingsPl extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsPl implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerPl implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerPl._(this._root); +class _TranslationsExternalPlayerPl extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerPl implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditPl implements TranslationsMetadataEditEn { - _TranslationsMetadataEditPl._(this._root); +class _TranslationsMetadataEditPl extends TranslationsMetadataEditEn { + _TranslationsMetadataEditPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditPl implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenPl implements TranslationsMatchScreenEn { - _TranslationsMatchScreenPl._(this._root); +class _TranslationsMatchScreenPl extends TranslationsMatchScreenEn { + _TranslationsMatchScreenPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenPl implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksPl implements TranslationsServerTasksEn { - _TranslationsServerTasksPl._(this._root); +class _TranslationsServerTasksPl extends TranslationsServerTasksEn { + _TranslationsServerTasksPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksPl implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktPl implements TranslationsTraktEn { - _TranslationsTraktPl._(this._root); +class _TranslationsTraktPl extends TranslationsTraktEn { + _TranslationsTraktPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktPl implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersPl implements TranslationsTrackersEn { - _TranslationsTrackersPl._(this._root); +class _TranslationsTrackersPl extends TranslationsTrackersEn { + _TranslationsTrackersPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersPl implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterPl libraryFilter = _TranslationsTrackersLibraryFilterPl._(_root); } +// Path: addServer +class _TranslationsAddServerPl extends TranslationsAddServerEn { + _TranslationsAddServerPl._(TranslationsPl root) : this._root = root, super.internal(root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Dodaj serwer Jellyfin'; + @override String get jellyfinUrlIntro => 'Podaj URL serwera Jellyfin — np. `https://jellyfin.example.com`. Możesz się zalogować później.'; + @override String get serverUrl => 'URL serwera'; + @override String get findServer => 'Znajdź serwer'; + @override String get username => 'Nazwa użytkownika'; + @override String get password => 'Hasło'; + @override String get signIn => 'Zaloguj się'; + @override String get change => 'Zmień'; + @override String get required => 'Wymagane'; + @override String couldNotReachServer({required Object error}) => 'Nie udało się połączyć z serwerem: ${error}'; + @override String signInFailed({required Object error}) => 'Logowanie nie powiodło się: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect nie powiodło się: ${error}'; + @override String get addPlexTitle => 'Zaloguj się przez Plex'; + @override String get plexAuthIntro => 'Wybierz sposób logowania do Plex. Przeglądarka otwiera plex.tv, gdzie potwierdzasz połączenie; opcja QR jest wygodna dla TV i urządzeń zdalnych.'; + @override String get plexQRPrompt => 'Zeskanuj ten kod QR, aby się zalogować.'; + @override String get waitingForPlexConfirmation => 'Czekam na potwierdzenie logowania przez plex.tv…'; + @override String get pinExpired => 'PIN wygasł przed zalogowaniem. Spróbuj ponownie.'; + @override String get duplicatePlexAccount => 'To urządzenie jest już zalogowane do konta Plex. Wyloguj się w ustawieniach, aby zmienić konto.'; + @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 addConnectionIntroGlobal => 'Dodaj kolejny serwer multimediów. Możesz łączyć konta Plex i serwery Jellyfin — zawartość ze wszystkich podłączonych backendów pojawi się razem na ekranie głównym.'; + @override String get addConnectionIntroScoped => 'Dodaj nowy serwer lub pożycz z innego profilu.'; + @override String get signInWithPlexCard => 'Zaloguj się przez Plex'; + @override String get signInWithPlexCardSubtitle => 'Autoryzuj to urządzenie dla swojego konta Plex. Serwery udostępnione kontu zostaną dodane automatycznie.'; + @override String get signInWithPlexCardSubtitleScoped => 'Autoryzuj nowe konto Plex. Jego użytkownicy Home pojawią się jako profile.'; + @override String get connectToJellyfinCard => 'Połącz z Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Podaj URL serwera Jellyfin i zaloguj się nazwą użytkownika + hasłem (Quick Connect wkrótce).'; + @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 => 'Wykorzystaj ponownie połączenie przypisane już do innego profilu. Profile źródłowe chronione PIN-em poproszą o PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsPl implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsPl._(this._root); +class _TranslationsHotkeysActionsPl extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsPl implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsPl implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsPl._(this._root); +class _TranslationsVideoControlsPipErrorsPl extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsPl implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsPl implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsPl._(this._root); +class _TranslationsLibrariesTabsPl extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsPl implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsPl implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsPl._(this._root); +class _TranslationsLibrariesGroupingsPl extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsPl implements TranslationsLibrariesGrouping @override String get folders => 'Foldery'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesPl extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesPl._(TranslationsPl root) : this._root = root, super.internal(root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String get genre => 'Gatunek'; + @override String get year => 'Rok'; + @override String get contentRating => 'Klasyfikacja wiekowa'; + @override String get tag => 'Tag'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsPl extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsPl._(TranslationsPl root) : this._root = root, super.internal(root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String get title => 'Tytuł'; + @override String get dateAdded => 'Data dodania'; + @override String get releaseDate => 'Data premiery'; + @override String get rating => 'Ocena'; + @override String get lastPlayed => 'Ostatnio odtwarzane'; + @override String get playCount => 'Liczba odtworzeń'; + @override String get random => 'Losowo'; + @override String get dateShared => 'Data udostępnienia'; + @override String get latestEpisodeAirDate => 'Data emisji ostatniego odcinka'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionPl implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionPl._(this._root); +class _TranslationsCompanionRemoteSessionPl extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionPl implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingPl implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingPl._(this._root); +class _TranslationsCompanionRemotePairingPl extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingPl implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemotePl implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemotePl._(this._root); +class _TranslationsCompanionRemoteRemotePl extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemotePl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemotePl implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesPl implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesPl._(this._root); +class _TranslationsTrackersServicesPl extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesPl implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodePl implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodePl._(this._root); +class _TranslationsTrackersDeviceCodePl extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodePl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodePl implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyPl implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyPl._(this._root); +class _TranslationsTrackersOauthProxyPl extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyPl implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterPl implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterPl._(this._root); +class _TranslationsTrackersLibraryFilterPl extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterPl._(TranslationsPl root) : this._root = root, super.internal(root); final TranslationsPl _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsPl { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Zaloguj się', 'auth.signInWithPlex' => 'Zaloguj się przez Plex', 'auth.showQRCode' => 'Pokaż kod QR', 'auth.authenticate' => 'Uwierzytelnienie', @@ -1550,6 +1719,14 @@ extension on TranslationsPl { 'auth.scanQRToSignIn' => 'Zeskanuj ten kod QR, aby się zalogować', 'auth.waitingForAuth' => 'Oczekiwanie na uwierzytelnienie...\nDokończ logowanie 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.quickConnectCode' => 'Kod Quick Connect', + 'auth.quickConnectInstructions' => 'Otwórz swój serwer Jellyfin w przeglądarce, zaloguj się i wybierz Quick Connect z menu użytkownika. Wprowadź ten kod, aby zatwierdzić logowanie.', + 'auth.quickConnectWaiting' => 'Oczekiwanie na zatwierdzenie…', + 'auth.quickConnectCancel' => 'Anuluj', + 'auth.quickConnectExpired' => 'Kod Quick Connect wygasł przed zatwierdzeniem. Spróbuj ponownie.', 'common.cancel' => 'Anuluj', 'common.save' => 'Zapisz', 'common.close' => 'Zamknij', @@ -1636,12 +1813,12 @@ extension on TranslationsPl { 'settings.gridView' => 'Siatka', 'settings.listView' => 'Lista', 'settings.showHeroSection' => 'Pokaż sekcję wyróżnioną', - 'settings.useGlobalHubs' => 'Użyj układu Plex Home', - 'settings.useGlobalHubsDescription' => 'Pokaż huby strony głównej jak w oficjalnym kliencie Plex. Gdy wyłączone, pokazuje rekomendacje per biblioteka.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Pokaż nazwę serwera w hubach', 'settings.showServerNameOnHubsDescription' => 'Zawsze wyświetlaj nazwę serwera w tytułach hubów. Gdy wyłączone, pokazuje tylko dla zduplikowanych nazw.', 'settings.groupLibrariesByServer' => 'Grupuj biblioteki według serwera', - 'settings.groupLibrariesByServerDescription' => 'Pokazuj nagłówek dla każdego serwera Plex na pasku bocznym, gdy jesteś połączony z wieloma serwerami.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Zawsze utrzymuj panel boczny otwarty', 'settings.alwaysKeepSidebarOpenDescription' => 'Panel boczny jest rozwinięty, a obszar treści dostosowuje się', 'settings.showUnwatchedCount' => 'Pokaż liczbę nieobejrzanych', @@ -1962,7 +2139,7 @@ extension on TranslationsPl { 'messages.musicNotSupported' => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane', 'messages.noDescriptionAvailable' => 'Brak dostępnego opisu', 'messages.noProfilesAvailable' => 'Brak dostępnych profili', - 'messages.contactAdminForProfiles' => 'Skontaktuj się z administratorem Plex, aby dodać profile', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Nie można określić sekcji biblioteki dla tego elementu', 'messages.logsCleared' => 'Logi wyczyszczone', 'messages.logsCopied' => 'Logi skopiowane do schowka', @@ -2016,11 +2193,65 @@ extension on TranslationsPl { 'mpvConfig.confirmDeletePreset' => 'Czy na pewno chcesz usunąć ten preset?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Potwierdź działanie', + 'profiles.addPlezyProfile' => 'Dodaj profil Plezy', + 'profiles.switchingProfile' => 'Przełączanie profilu…', + 'profiles.deleteThisProfileTitle' => 'Usunąć ten profil?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} zostanie usunięty. Połączenia nie zostaną zmienione.', + 'profiles.active' => 'Aktywny', + 'profiles.manage' => 'Zarządzaj', + 'profiles.delete' => 'Usuń', + 'profiles.signOut' => 'Wyloguj się', + 'profiles.signOutPlexTitle' => 'Wylogować się z Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} oraz wszyscy użytkownicy Plex Home na tym koncie zostaną usunięci z tego urządzenia. W każdej chwili możesz się zalogować ponownie.', + 'profiles.signedOutPlex' => 'Wylogowano z Plex.', + 'profiles.signOutFailed' => 'Wylogowanie nie powiodło się.', + 'profiles.sectionTitle' => 'Profile', + 'profiles.summarySingle' => 'Dodaj profile, aby łączyć zarządzanych użytkowników i tożsamości lokalne', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} profili · aktywny: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} profili', + 'profiles.removeConnectionTitle' => 'Usunąć połączenie?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} straci dostęp do ${connectionLabel}. Samo połączenie pozostanie dostępne dla innych profili.', + 'profiles.deleteProfileTitle' => 'Usunąć profil?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Spowoduje to usunięcie ${displayName} i wszystkich jego połączeń z tego urządzenia. Nie wpłynie to na same serwery Plex/Jellyfin.', + 'profiles.profileNameLabel' => 'Nazwa profilu', + 'profiles.pinProtectionLabel' => 'Ochrona PIN-em', + 'profiles.pinManagedByPlex' => 'PIN zarządzany przez Plex. Edytuj na plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'Nie ustawiono PIN-u. Aby go wymagać, edytuj użytkownika Home na plex.tv.', + 'profiles.setPin' => 'Ustaw PIN', + 'profiles.connectionsLabel' => 'Połączenia', + 'profiles.add' => 'Dodaj', + 'profiles.deleteProfileButton' => 'Usuń profil', + 'profiles.noConnectionsHint' => 'Brak połączeń — dodaj jedno, aby używać tego profilu.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Konto Plex Home', + 'profiles.connectionDefault' => 'Domyślne', + 'profiles.makeDefault' => 'Ustaw jako domyślne', + 'profiles.removeConnection' => 'Usuń', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Dodaj do ${displayName}', + 'profiles.borrowExplain' => 'Pożycz połączenie z innego profilu. Profile źródłowe chronione PIN-em proszą o PIN przed udostępnieniem.', + 'profiles.borrowEmpty' => 'Nic do pożyczenia.', + 'profiles.borrowEmptySubtitle' => 'Podłącz najpierw konto Plex lub serwer Jellyfin do innego profilu i wróć tutaj.', + 'profiles.newProfile' => 'Nowy profil', + 'profiles.profileNameHint' => 'np. Goście, Dzieci, Salon', + 'profiles.pinProtectionOptional' => 'Ochrona PIN-em (opcjonalnie)', + 'profiles.pinExplain' => '4-cyfrowy PIN wymagany do przełączenia się na ten profil. Miękka bariera — każdy kto może wyczyścić dane aplikacji, może ją obejść.', + 'profiles.continueButton' => 'Kontynuuj', + 'profiles.pinsDontMatch' => 'PIN-y nie pasują', + 'connections.sectionTitle' => 'Połączenia', + 'connections.addConnection' => 'Dodaj połączenie', + 'connections.addConnectionSubtitleNoProfile' => 'Zaloguj się przez Plex lub połącz serwer Jellyfin', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Dodaj do ${displayName} — konto Plex, serwer Jellyfin lub pożycz z innego profilu', + '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', 'discover.title' => 'Odkryj', 'discover.switchProfile' => 'Zmień profil', 'discover.noContentAvailable' => 'Brak dostępnych treści', 'discover.addMediaToLibraries' => 'Dodaj multimedia do swoich bibliotek', 'discover.continueWatching' => 'Kontynuuj oglądanie', + 'discover.nextUp' => 'Następny odcinek', + 'discover.recentlyAdded' => 'Ostatnio dodane', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Opis', 'discover.cast' => 'Obsada', @@ -2032,7 +2263,7 @@ extension on TranslationsPl { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min pozostało', 'errors.searchFailed' => ({required Object error}) => 'Wyszukiwanie nie powiodło się: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Limit czasu połączenia przy ładowaniu ${context}', - 'errors.connectionFailed' => 'Nie można połączyć z serwerem Plex', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Nie udało się załadować ${context}: ${error}', 'errors.noClientAvailable' => 'Brak dostępnego klienta', 'errors.authenticationFailed' => ({required Object error}) => 'Uwierzytelnienie nie powiodło się: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsPl { 'errors.invalidToken' => 'Nieprawidłowy token', 'errors.failedToVerifyToken' => ({required Object error}) => 'Nie udało się zweryfikować tokena: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Nie udało się przełączyć na ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Nie udało się usunąć ${displayName}', + 'errors.failedToRate' => 'Nie udało się zaktualizować oceny', 'libraries.title' => 'Biblioteki', 'libraries.scanLibraryFiles' => 'Skanuj pliki biblioteki', 'libraries.scanLibrary' => 'Skanuj bibliotekę', @@ -2054,8 +2287,6 @@ extension on TranslationsPl { 'libraries.analyzing' => ({required Object title}) => 'Analizowanie "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Analiza rozpoczęta dla "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Nie udało się przeanalizować biblioteki: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Nie znaleziono bibliotek', 'libraries.allLibrariesHidden' => 'Wszystkie biblioteki są ukryte', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Ukryte biblioteki (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsPl { 'libraries.groupings.seasons' => 'Sezony', 'libraries.groupings.episodes' => 'Odcinki', 'libraries.groupings.folders' => 'Foldery', + 'libraries.filterCategories.genre' => 'Gatunek', + 'libraries.filterCategories.year' => 'Rok', + 'libraries.filterCategories.contentRating' => 'Klasyfikacja wiekowa', + 'libraries.filterCategories.tag' => 'Tag', + 'libraries.sortLabels.title' => 'Tytuł', + 'libraries.sortLabels.dateAdded' => 'Data dodania', + 'libraries.sortLabels.releaseDate' => 'Data premiery', + 'libraries.sortLabels.rating' => 'Ocena', + 'libraries.sortLabels.lastPlayed' => 'Ostatnio odtwarzane', + 'libraries.sortLabels.playCount' => 'Liczba odtworzeń', + 'libraries.sortLabels.random' => 'Losowo', + 'libraries.sortLabels.dateShared' => 'Data udostępnienia', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Data emisji ostatniego odcinka', 'about.title' => 'O aplikacji', 'about.openSourceLicenses' => 'Licencje open source', 'about.versionLabel' => ({required Object version}) => 'Wersja ${version}', - 'about.appDescription' => 'Piękny klient Plex na Flutter', + 'about.appDescription' => 'Piękny klient Plex i Jellyfin na Flutter', 'about.viewLicensesDescription' => 'Zobacz licencje bibliotek zewnętrznych', 'serverSelection.allServerConnectionsFailed' => 'Nie udało się połączyć z żadnym serwerem. Sprawdź sieć i spróbuj ponownie.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Nie znaleziono serwerów dla ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsPl { 'watchTogether.recentRooms' => 'Ostatnie pokoje', 'watchTogether.renameRoom' => 'Zmień nazwę pokoju', 'watchTogether.removeRoom' => 'Usuń', + 'watchTogether.guestSwitchUnavailable' => 'Nie można przełączyć — serwer niedostępny do synchronizacji', + 'watchTogether.guestSwitchFailed' => 'Nie można przełączyć — nie znaleziono treści na tym serwerze', 'downloads.title' => 'Pobrania', 'downloads.manage' => 'Zarządzaj', 'downloads.tvShows' => 'Seriale TV', @@ -2291,6 +2537,12 @@ extension on TranslationsPl { 'downloads.editSyncFilter' => 'Filtr synchronizacji', 'downloads.syncAllItems' => 'Synchronizuję wszystkie elementy', 'downloads.syncUnwatchedItems' => 'Synchronizuję nieobejrzane elementy', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Serwer: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Dostępne', + 'downloads.syncRuleOffline' => 'Offline', + 'downloads.syncRuleSignInRequired' => 'Wymagane logowanie', + 'downloads.syncRuleNotAvailableForProfile' => 'Niedostępne dla bieżącego profilu', + 'downloads.syncRuleUnknownServer' => 'Nieznany serwer', 'downloads.syncRuleListCreated' => 'Utworzono regułę synchronizacji', 'shaders.title' => 'Shadery', 'shaders.noShaderDescription' => 'Bez ulepszenia wideo', @@ -2484,6 +2736,8 @@ extension on TranslationsPl { 'trakt.disconnectConfirmBody' => 'Plezy przestanie wysyłać zdarzenia odtwarzania do Trakt. Możesz połączyć się ponownie w dowolnej chwili.', 'trakt.scrobble' => 'Scrobbling w czasie rzeczywistym', 'trakt.scrobbleDescription' => 'Wysyłaj zdarzenia odtwarzania, pauzy i zatrzymania do Trakt podczas odtwarzania.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Synchronizuj status obejrzane', 'trakt.watchedSyncDescription' => 'Gdy oznaczysz pozycje jako obejrzane w Plezy, zostaną też oznaczone w Trakt.', 'trackers.title' => 'Trackery', @@ -2519,6 +2773,38 @@ extension on TranslationsPl { 'trackers.libraryFilter.modeHintWhitelist' => 'Synchronizuj tylko biblioteki zaznaczone poniżej.', 'trackers.libraryFilter.libraries' => 'Biblioteki', 'trackers.libraryFilter.noLibraries' => 'Brak dostępnych bibliotek', + 'addServer.addJellyfinTitle' => 'Dodaj serwer Jellyfin', + 'addServer.jellyfinUrlIntro' => 'Podaj URL serwera Jellyfin — np. `https://jellyfin.example.com`. Możesz się zalogować później.', + 'addServer.serverUrl' => 'URL serwera', + 'addServer.findServer' => 'Znajdź serwer', + 'addServer.username' => 'Nazwa użytkownika', + 'addServer.password' => 'Hasło', + 'addServer.signIn' => 'Zaloguj się', + 'addServer.change' => 'Zmień', + 'addServer.required' => 'Wymagane', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Nie udało się połączyć z serwerem: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Logowanie nie powiodło się: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect nie powiodło się: ${error}', + 'addServer.addPlexTitle' => 'Zaloguj się przez Plex', + 'addServer.plexAuthIntro' => 'Wybierz sposób logowania do Plex. Przeglądarka otwiera plex.tv, gdzie potwierdzasz połączenie; opcja QR jest wygodna dla TV i urządzeń zdalnych.', + 'addServer.plexQRPrompt' => 'Zeskanuj ten kod QR, aby się zalogować.', + 'addServer.waitingForPlexConfirmation' => 'Czekam na potwierdzenie logowania przez plex.tv…', + 'addServer.pinExpired' => 'PIN wygasł przed zalogowaniem. Spróbuj ponownie.', + 'addServer.duplicatePlexAccount' => 'To urządzenie jest już zalogowane do konta Plex. Wyloguj się w ustawieniach, aby zmienić konto.', + '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.addConnectionIntroGlobal' => 'Dodaj kolejny serwer multimediów. Możesz łączyć konta Plex i serwery Jellyfin — zawartość ze wszystkich podłączonych backendów pojawi się razem na ekranie głównym.', + 'addServer.addConnectionIntroScoped' => 'Dodaj nowy serwer lub pożycz z innego profilu.', + 'addServer.signInWithPlexCard' => 'Zaloguj się przez Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Autoryzuj to urządzenie dla swojego konta Plex. Serwery udostępnione kontu zostaną dodane automatycznie.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Autoryzuj nowe konto Plex. Jego użytkownicy Home pojawią się jako profile.', + 'addServer.connectToJellyfinCard' => 'Połącz z Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Podaj URL serwera Jellyfin i zaloguj się nazwą użytkownika + hasłem (Quick Connect wkrótce).', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Zaloguj się do serwera Jellyfin. Powiązane z ${name}.', + 'addServer.borrowFromAnotherProfile' => 'Pożycz z innego profilu', + 'addServer.borrowFromAnotherProfileSubtitle' => 'Wykorzystaj ponownie połączenie przypisane już do innego profilu. Profile źródłowe chronione PIN-em poproszą o PIN.', _ => null, }; } diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index c565cf52..3aaf07ae 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsPt with BaseTranslations implements Translations { +class TranslationsPt extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsPt({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsPt with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsPt with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsPt _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsPt with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingPt subtitlingStyling = _TranslationsSubtitlingStylingPt._(_root); @override late final _TranslationsMpvConfigPt mpvConfig = _TranslationsMpvConfigPt._(_root); @override late final _TranslationsDialogPt dialog = _TranslationsDialogPt._(_root); + @override late final _TranslationsProfilesPt profiles = _TranslationsProfilesPt._(_root); + @override late final _TranslationsConnectionsPt connections = _TranslationsConnectionsPt._(_root); @override late final _TranslationsDiscoverPt discover = _TranslationsDiscoverPt._(_root); @override late final _TranslationsErrorsPt errors = _TranslationsErrorsPt._(_root); @override late final _TranslationsLibrariesPt libraries = _TranslationsLibrariesPt._(_root); @@ -78,11 +82,12 @@ class TranslationsPt with BaseTranslations implements T @override late final _TranslationsServerTasksPt serverTasks = _TranslationsServerTasksPt._(_root); @override late final _TranslationsTraktPt trakt = _TranslationsTraktPt._(_root); @override late final _TranslationsTrackersPt trackers = _TranslationsTrackersPt._(_root); + @override late final _TranslationsAddServerPt addServer = _TranslationsAddServerPt._(_root); } // Path: app -class _TranslationsAppPt implements TranslationsAppEn { - _TranslationsAppPt._(this._root); +class _TranslationsAppPt extends TranslationsAppEn { + _TranslationsAppPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppPt implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthPt implements TranslationsAuthEn { - _TranslationsAuthPt._(this._root); +class _TranslationsAuthPt extends TranslationsAuthEn { + _TranslationsAuthPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field // Translations + @override String get signIn => 'Entrar'; @override String get signInWithPlex => 'Entrar com Plex'; @override String get showQRCode => 'Mostrar QR Code'; @override String get authenticate => 'Autenticar'; @@ -104,11 +110,19 @@ class _TranslationsAuthPt implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Escaneie este QR code para entrar'; @override String get waitingForAuth => 'Aguardando autenticação...\nConclua o login no seu 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 quickConnectCode => 'Código do Quick Connect'; + @override String get quickConnectInstructions => 'Abra o seu servidor Jellyfin num navegador, inicie sessão e escolha Quick Connect no menu do utilizador. Insira este código para aprovar o início de sessão.'; + @override String get quickConnectWaiting => 'A aguardar aprovação…'; + @override String get quickConnectCancel => 'Cancelar'; + @override String get quickConnectExpired => 'O código do Quick Connect expirou antes da aprovação. Tente novamente.'; } // Path: common -class _TranslationsCommonPt implements TranslationsCommonEn { - _TranslationsCommonPt._(this._root); +class _TranslationsCommonPt extends TranslationsCommonEn { + _TranslationsCommonPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonPt implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensPt implements TranslationsScreensEn { - _TranslationsScreensPt._(this._root); +class _TranslationsScreensPt extends TranslationsScreensEn { + _TranslationsScreensPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensPt implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdatePt implements TranslationsUpdateEn { - _TranslationsUpdatePt._(this._root); +class _TranslationsUpdatePt extends TranslationsUpdateEn { + _TranslationsUpdatePt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdatePt implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsPt implements TranslationsSettingsEn { - _TranslationsSettingsPt._(this._root); +class _TranslationsSettingsPt extends TranslationsSettingsEn { + _TranslationsSettingsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsPt implements TranslationsSettingsEn { @override String get gridView => 'Grade'; @override String get listView => 'Lista'; @override String get showHeroSection => 'Mostrar Seção de Destaque'; - @override String get useGlobalHubs => 'Usar Layout Plex Home'; - @override String get useGlobalHubsDescription => 'Mostrar hubs da página inicial como o cliente oficial Plex. Quando desativado, mostra recomendações por biblioteca.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Mostrar Nome do Servidor nos Hubs'; @override String get showServerNameOnHubsDescription => 'Sempre exibir o nome do servidor nos títulos dos hubs. Quando desativado, mostra apenas para nomes duplicados.'; @override String get groupLibrariesByServer => 'Agrupar Bibliotecas por Servidor'; - @override String get groupLibrariesByServerDescription => 'Mostra um cabeçalho para cada servidor Plex na barra lateral quando você está conectado a vários servidores.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Manter Barra Lateral Sempre Aberta'; @override String get alwaysKeepSidebarOpenDescription => 'A barra lateral fica expandida e a área de conteúdo se ajusta'; @override String get showUnwatchedCount => 'Mostrar Contagem de Não Assistidos'; @@ -385,8 +399,8 @@ class _TranslationsSettingsPt implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchPt implements TranslationsSearchEn { - _TranslationsSearchPt._(this._root); +class _TranslationsSearchPt extends TranslationsSearchEn { + _TranslationsSearchPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchPt implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysPt implements TranslationsHotkeysEn { - _TranslationsHotkeysPt._(this._root); +class _TranslationsHotkeysPt extends TranslationsHotkeysEn { + _TranslationsHotkeysPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysPt implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoPt implements TranslationsFileInfoEn { - _TranslationsFileInfoPt._(this._root); +class _TranslationsFileInfoPt extends TranslationsFileInfoEn { + _TranslationsFileInfoPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoPt implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuPt implements TranslationsMediaMenuEn { - _TranslationsMediaMenuPt._(this._root); +class _TranslationsMediaMenuPt extends TranslationsMediaMenuEn { + _TranslationsMediaMenuPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuPt implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityPt implements TranslationsAccessibilityEn { - _TranslationsAccessibilityPt._(this._root); +class _TranslationsAccessibilityPt extends TranslationsAccessibilityEn { + _TranslationsAccessibilityPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityPt implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsPt implements TranslationsTooltipsEn { - _TranslationsTooltipsPt._(this._root); +class _TranslationsTooltipsPt extends TranslationsTooltipsEn { + _TranslationsTooltipsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsPt implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsPt implements TranslationsVideoControlsEn { - _TranslationsVideoControlsPt._(this._root); +class _TranslationsVideoControlsPt extends TranslationsVideoControlsEn { + _TranslationsVideoControlsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsPt implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusPt implements TranslationsUserStatusEn { - _TranslationsUserStatusPt._(this._root); +class _TranslationsUserStatusPt extends TranslationsUserStatusEn { + _TranslationsUserStatusPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusPt implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesPt implements TranslationsMessagesEn { - _TranslationsMessagesPt._(this._root); +class _TranslationsMessagesPt extends TranslationsMessagesEn { + _TranslationsMessagesPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesPt implements TranslationsMessagesEn { @override String get musicNotSupported => 'Reprodução de música ainda não é suportada'; @override String get noDescriptionAvailable => 'Nenhuma descrição disponível'; @override String get noProfilesAvailable => 'Nenhum perfil disponível'; - @override String get contactAdminForProfiles => 'Contacte o seu administrador Plex para adicionar perfis'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Não é possível determinar a secção da biblioteca para este item'; @override String get logsCleared => 'Logs limpos'; @override String get logsCopied => 'Logs copiados para a área de transferência'; @@ -636,8 +650,8 @@ class _TranslationsMessagesPt implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingPt implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingPt._(this._root); +class _TranslationsSubtitlingStylingPt extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingPt implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigPt implements TranslationsMpvConfigEn { - _TranslationsMpvConfigPt._(this._root); +class _TranslationsMpvConfigPt extends TranslationsMpvConfigEn { + _TranslationsMpvConfigPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigPt implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogPt implements TranslationsDialogEn { - _TranslationsDialogPt._(this._root); +class _TranslationsDialogPt extends TranslationsDialogEn { + _TranslationsDialogPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogPt implements TranslationsDialogEn { @override String get confirmAction => 'Confirmar Ação'; } +// Path: profiles +class _TranslationsProfilesPt extends TranslationsProfilesEn { + _TranslationsProfilesPt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Adicionar perfil Plezy'; + @override String get switchingProfile => 'Mudando perfil…'; + @override String get deleteThisProfileTitle => 'Excluir este perfil?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} será removido. As conexões não serão afetadas.'; + @override String get active => 'Ativo'; + @override String get manage => 'Gerenciar'; + @override String get delete => 'Excluir'; + @override String get signOut => 'Sair'; + @override String get signOutPlexTitle => 'Sair do Plex?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} e todos os usuários do Plex Home desta conta serão removidos deste dispositivo. Você pode entrar novamente a qualquer momento.'; + @override String get signedOutPlex => 'Saiu do Plex.'; + @override String get signOutFailed => 'Falha ao sair.'; + @override String get sectionTitle => 'Perfis'; + @override String get summarySingle => 'Adicione perfis para mesclar usuários gerenciados e identidades locais'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} perfis · ativo: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} perfis'; + @override String get removeConnectionTitle => 'Remover conexão?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} perderá o acesso a ${connectionLabel}. A conexão em si continua disponível para outros perfis.'; + @override String get deleteProfileTitle => 'Excluir perfil?'; + @override String deleteProfileMessage({required Object displayName}) => 'Isso remove ${displayName} e todas as suas conexões deste dispositivo. Os servidores Plex/Jellyfin subjacentes não são afetados.'; + @override String get profileNameLabel => 'Nome do perfil'; + @override String get pinProtectionLabel => 'Proteção por PIN'; + @override String get pinManagedByPlex => 'PIN gerenciado pelo Plex. Edite em plex.tv.'; + @override String get noPinSetEditOnPlex => 'Nenhum PIN definido. Para exigir um, edite o usuário Home em plex.tv.'; + @override String get setPin => 'Definir PIN'; + @override String get connectionsLabel => 'Conexões'; + @override String get add => 'Adicionar'; + @override String get deleteProfileButton => 'Excluir perfil'; + @override String get noConnectionsHint => 'Sem conexões — adicione uma para usar este perfil.'; + @override String get plexHomeAccount => 'Conta Plex Home'; + @override String get connectionDefault => 'Padrão'; + @override String get makeDefault => 'Definir como padrão'; + @override String get removeConnection => 'Remover'; + @override String borrowAddTo({required Object displayName}) => 'Adicionar a ${displayName}'; + @override String get borrowExplain => 'Tome emprestada uma conexão de outro perfil. Perfis de origem protegidos por PIN pedem o PIN antes de compartilhar.'; + @override String get borrowEmpty => 'Nada para emprestar ainda.'; + @override String get borrowEmptySubtitle => 'Conecte primeiro uma conta Plex ou servidor Jellyfin a outro perfil e volte aqui.'; + @override String get newProfile => 'Novo perfil'; + @override String get profileNameHint => 'ex.: Visitantes, Crianças, Sala de família'; + @override String get pinProtectionOptional => 'Proteção por PIN (opcional)'; + @override String get pinExplain => 'PIN de 4 dígitos necessário para alternar para este perfil. Barreira suave — quem puder limpar os dados do app pode contorná-la.'; + @override String get continueButton => 'Continuar'; + @override String get pinsDontMatch => 'Os PINs não correspondem'; +} + +// Path: connections +class _TranslationsConnectionsPt extends TranslationsConnectionsEn { + _TranslationsConnectionsPt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Conexões'; + @override String get addConnection => 'Adicionar conexão'; + @override String get addConnectionSubtitleNoProfile => 'Faça login com Plex ou conecte um servidor Jellyfin'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Adicionar a ${displayName} — conta Plex, servidor Jellyfin ou emprestar de outro perfil'; + @override String sessionExpiredOne({required Object name}) => 'Sessão expirada para ${name}'; + @override String sessionExpiredMany({required Object count}) => 'Sessão expirada para ${count} servidores'; + @override String get signInAgain => 'Entrar novamente'; +} + // Path: discover -class _TranslationsDiscoverPt implements TranslationsDiscoverEn { - _TranslationsDiscoverPt._(this._root); +class _TranslationsDiscoverPt extends TranslationsDiscoverEn { + _TranslationsDiscoverPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverPt implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Nenhum conteúdo disponível'; @override String get addMediaToLibraries => 'Adicione mídias às suas bibliotecas'; @override String get continueWatching => 'Continuar Assistindo'; + @override String get nextUp => 'A seguir'; + @override String get recentlyAdded => 'Adicionados recentemente'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Sinopse'; @override String get cast => 'Elenco'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverPt implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsPt implements TranslationsErrorsEn { - _TranslationsErrorsPt._(this._root); +class _TranslationsErrorsPt extends TranslationsErrorsEn { + _TranslationsErrorsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Falha na busca: ${error}'; @override String connectionTimeout({required Object context}) => 'Tempo de conexão esgotado ao carregar ${context}'; - @override String get connectionFailed => 'Não foi possível conectar ao servidor Plex'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Falha ao carregar ${context}: ${error}'; @override String get noClientAvailable => 'Nenhum cliente disponível'; @override String authenticationFailed({required Object error}) => 'Falha na autenticação: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsPt implements TranslationsErrorsEn { @override String get invalidToken => 'Token inválido'; @override String failedToVerifyToken({required Object error}) => 'Falha ao verificar token: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Falha ao trocar para ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Falha ao excluir ${displayName}'; + @override String get failedToRate => 'Não foi possível atualizar a classificação'; } // Path: libraries -class _TranslationsLibrariesPt implements TranslationsLibrariesEn { - _TranslationsLibrariesPt._(this._root); +class _TranslationsLibrariesPt extends TranslationsLibrariesEn { + _TranslationsLibrariesPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesPt implements TranslationsLibrariesEn { @override String get folders => 'pastas'; @override late final _TranslationsLibrariesTabsPt tabs = _TranslationsLibrariesTabsPt._(_root); @override late final _TranslationsLibrariesGroupingsPt groupings = _TranslationsLibrariesGroupingsPt._(_root); + @override late final _TranslationsLibrariesFilterCategoriesPt filterCategories = _TranslationsLibrariesFilterCategoriesPt._(_root); + @override late final _TranslationsLibrariesSortLabelsPt sortLabels = _TranslationsLibrariesSortLabelsPt._(_root); } // Path: about -class _TranslationsAboutPt implements TranslationsAboutEn { - _TranslationsAboutPt._(this._root); +class _TranslationsAboutPt extends TranslationsAboutEn { + _TranslationsAboutPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutPt implements TranslationsAboutEn { @override String get title => 'Sobre'; @override String get openSourceLicenses => 'Licenças Open Source'; @override String versionLabel({required Object version}) => 'Versão ${version}'; - @override String get appDescription => 'Um belo cliente Plex para Flutter'; + @override String get appDescription => 'Um belo cliente Plex e Jellyfin para Flutter'; @override String get viewLicensesDescription => 'Ver licenças de bibliotecas de terceiros'; } // Path: serverSelection -class _TranslationsServerSelectionPt implements TranslationsServerSelectionEn { - _TranslationsServerSelectionPt._(this._root); +class _TranslationsServerSelectionPt extends TranslationsServerSelectionEn { + _TranslationsServerSelectionPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionPt implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailPt implements TranslationsHubDetailEn { - _TranslationsHubDetailPt._(this._root); +class _TranslationsHubDetailPt extends TranslationsHubDetailEn { + _TranslationsHubDetailPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailPt implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsPt implements TranslationsLogsEn { - _TranslationsLogsPt._(this._root); +class _TranslationsLogsPt extends TranslationsLogsEn { + _TranslationsLogsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsPt implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesPt implements TranslationsLicensesEn { - _TranslationsLicensesPt._(this._root); +class _TranslationsLicensesPt extends TranslationsLicensesEn { + _TranslationsLicensesPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesPt implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationPt implements TranslationsNavigationEn { - _TranslationsNavigationPt._(this._root); +class _TranslationsNavigationPt extends TranslationsNavigationEn { + _TranslationsNavigationPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationPt implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvPt implements TranslationsLiveTvEn { - _TranslationsLiveTvPt._(this._root); +class _TranslationsLiveTvPt extends TranslationsLiveTvEn { + _TranslationsLiveTvPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvPt implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsPt implements TranslationsCollectionsEn { - _TranslationsCollectionsPt._(this._root); +class _TranslationsCollectionsPt extends TranslationsCollectionsEn { + _TranslationsCollectionsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsPt implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsPt implements TranslationsPlaylistsEn { - _TranslationsPlaylistsPt._(this._root); +class _TranslationsPlaylistsPt extends TranslationsPlaylistsEn { + _TranslationsPlaylistsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsPt implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherPt implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherPt._(this._root); +class _TranslationsWatchTogetherPt extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherPt implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Salas recentes'; @override String get renameRoom => 'Renomear sala'; @override String get removeRoom => 'Remover'; + @override String get guestSwitchUnavailable => 'Não foi possível trocar — servidor indisponível para sincronização'; + @override String get guestSwitchFailed => 'Não foi possível trocar — conteúdo não encontrado neste servidor'; } // Path: downloads -class _TranslationsDownloadsPt implements TranslationsDownloadsEn { - _TranslationsDownloadsPt._(this._root); +class _TranslationsDownloadsPt extends TranslationsDownloadsEn { + _TranslationsDownloadsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsPt implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Filtro de sincronização'; @override String get syncAllItems => 'Sincronizando todos os itens'; @override String get syncUnwatchedItems => 'Sincronizando itens não vistos'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Servidor: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Disponível'; + @override String get syncRuleOffline => 'Offline'; + @override String get syncRuleSignInRequired => 'Início de sessão necessário'; + @override String get syncRuleNotAvailableForProfile => 'Indisponível para o perfil atual'; + @override String get syncRuleUnknownServer => 'Servidor desconhecido'; @override String get syncRuleListCreated => 'Regra de sincronização criada'; } // Path: shaders -class _TranslationsShadersPt implements TranslationsShadersEn { - _TranslationsShadersPt._(this._root); +class _TranslationsShadersPt extends TranslationsShadersEn { + _TranslationsShadersPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersPt implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemotePt implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemotePt._(this._root); +class _TranslationsCompanionRemotePt extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemotePt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemotePt implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsPt implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsPt._(this._root); +class _TranslationsVideoSettingsPt extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsPt implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerPt implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerPt._(this._root); +class _TranslationsExternalPlayerPt extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerPt implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditPt implements TranslationsMetadataEditEn { - _TranslationsMetadataEditPt._(this._root); +class _TranslationsMetadataEditPt extends TranslationsMetadataEditEn { + _TranslationsMetadataEditPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditPt implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenPt implements TranslationsMatchScreenEn { - _TranslationsMatchScreenPt._(this._root); +class _TranslationsMatchScreenPt extends TranslationsMatchScreenEn { + _TranslationsMatchScreenPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenPt implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksPt implements TranslationsServerTasksEn { - _TranslationsServerTasksPt._(this._root); +class _TranslationsServerTasksPt extends TranslationsServerTasksEn { + _TranslationsServerTasksPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksPt implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktPt implements TranslationsTraktEn { - _TranslationsTraktPt._(this._root); +class _TranslationsTraktPt extends TranslationsTraktEn { + _TranslationsTraktPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktPt implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersPt implements TranslationsTrackersEn { - _TranslationsTrackersPt._(this._root); +class _TranslationsTrackersPt extends TranslationsTrackersEn { + _TranslationsTrackersPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersPt implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterPt libraryFilter = _TranslationsTrackersLibraryFilterPt._(_root); } +// Path: addServer +class _TranslationsAddServerPt extends TranslationsAddServerEn { + _TranslationsAddServerPt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Adicionar servidor Jellyfin'; + @override String get jellyfinUrlIntro => 'Insira a URL do seu servidor Jellyfin — ex.: `https://jellyfin.example.com`. Você pode entrar em seguida.'; + @override String get serverUrl => 'URL do servidor'; + @override String get findServer => 'Encontrar servidor'; + @override String get username => 'Usuário'; + @override String get password => 'Senha'; + @override String get signIn => 'Entrar'; + @override String get change => 'Alterar'; + @override String get required => 'Obrigatório'; + @override String couldNotReachServer({required Object error}) => 'Não foi possível conectar ao servidor: ${error}'; + @override String signInFailed({required Object error}) => 'Falha ao entrar: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect falhou: ${error}'; + @override String get addPlexTitle => 'Entrar com Plex'; + @override String get plexAuthIntro => 'Escolha como entrar no Plex. O fluxo do navegador abre plex.tv onde você confirma a conexão; a opção QR é prática para TV / dispositivos remotos.'; + @override String get plexQRPrompt => 'Escaneie este código QR para entrar.'; + @override String get waitingForPlexConfirmation => 'Aguardando o plex.tv confirmar o login…'; + @override String get pinExpired => 'O PIN expirou antes do login. Tente novamente.'; + @override String get duplicatePlexAccount => 'Este dispositivo já está conectado a uma conta Plex. Saia nas configurações para trocar de conta.'; + @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 addConnectionIntroGlobal => 'Adicione outro servidor de mídia. Você pode misturar contas Plex e servidores Jellyfin — itens de cada backend conectado aparecem juntos na tela inicial.'; + @override String get addConnectionIntroScoped => 'Adicione um novo servidor ou pegue um emprestado de outro perfil.'; + @override String get signInWithPlexCard => 'Entrar com Plex'; + @override String get signInWithPlexCardSubtitle => 'Autorize este dispositivo na sua conta Plex. Servidores compartilhados com a conta vêm junto automaticamente.'; + @override String get signInWithPlexCardSubtitleScoped => 'Autorize uma nova conta Plex. Seus usuários Home aparecem como perfis.'; + @override String get connectToJellyfinCard => 'Conectar ao Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Insira a URL do seu servidor Jellyfin e entre com usuário + senha (Quick Connect em breve).'; + @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Entre em um servidor Jellyfin. Vinculado a ${name}.'; + @override String get borrowFromAnotherProfile => 'Pegar emprestado de outro perfil'; + @override String get borrowFromAnotherProfileSubtitle => 'Reutilize uma conexão já associada a outro perfil. Perfis de origem protegidos por PIN solicitarão o PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsPt implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsPt._(this._root); +class _TranslationsHotkeysActionsPt extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsPt implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsPt implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsPt._(this._root); +class _TranslationsVideoControlsPipErrorsPt extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsPt implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsPt implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsPt._(this._root); +class _TranslationsLibrariesTabsPt extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsPt implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsPt implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsPt._(this._root); +class _TranslationsLibrariesGroupingsPt extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsPt implements TranslationsLibrariesGrouping @override String get folders => 'Pastas'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesPt extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesPt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get genre => 'Gênero'; + @override String get year => 'Ano'; + @override String get contentRating => 'Classificação'; + @override String get tag => 'Tag'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsPt extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsPt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Título'; + @override String get dateAdded => 'Data de adição'; + @override String get releaseDate => 'Data de lançamento'; + @override String get rating => 'Avaliação'; + @override String get lastPlayed => 'Última reprodução'; + @override String get playCount => 'Reproduções'; + @override String get random => 'Aleatório'; + @override String get dateShared => 'Data de compartilhamento'; + @override String get latestEpisodeAirDate => 'Última data de exibição do episódio'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionPt implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionPt._(this._root); +class _TranslationsCompanionRemoteSessionPt extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionPt implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingPt implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingPt._(this._root); +class _TranslationsCompanionRemotePairingPt extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingPt implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemotePt implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemotePt._(this._root); +class _TranslationsCompanionRemoteRemotePt extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemotePt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemotePt implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesPt implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesPt._(this._root); +class _TranslationsTrackersServicesPt extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesPt implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodePt implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodePt._(this._root); +class _TranslationsTrackersDeviceCodePt extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodePt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodePt implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyPt implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyPt._(this._root); +class _TranslationsTrackersOauthProxyPt extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyPt implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterPt implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterPt._(this._root); +class _TranslationsTrackersLibraryFilterPt extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterPt._(TranslationsPt root) : this._root = root, super.internal(root); final TranslationsPt _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsPt { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Entrar', 'auth.signInWithPlex' => 'Entrar com Plex', 'auth.showQRCode' => 'Mostrar QR Code', 'auth.authenticate' => 'Autenticar', @@ -1550,6 +1719,14 @@ extension on TranslationsPt { 'auth.scanQRToSignIn' => 'Escaneie este QR code para entrar', 'auth.waitingForAuth' => 'Aguardando autenticação...\nConclua o login no seu navegador.', 'auth.useBrowser' => 'Usar navegador', + 'auth.or' => 'ou', + 'auth.connectToJellyfin' => 'Conectar ao Jellyfin', + 'auth.useQuickConnect' => 'Usar Quick Connect', + 'auth.quickConnectCode' => 'Código do Quick Connect', + 'auth.quickConnectInstructions' => 'Abra o seu servidor Jellyfin num navegador, inicie sessão e escolha Quick Connect no menu do utilizador. Insira este código para aprovar o início de sessão.', + 'auth.quickConnectWaiting' => 'A aguardar aprovação…', + 'auth.quickConnectCancel' => 'Cancelar', + 'auth.quickConnectExpired' => 'O código do Quick Connect expirou antes da aprovação. Tente novamente.', 'common.cancel' => 'Cancelar', 'common.save' => 'Salvar', 'common.close' => 'Fechar', @@ -1636,12 +1813,12 @@ extension on TranslationsPt { 'settings.gridView' => 'Grade', 'settings.listView' => 'Lista', 'settings.showHeroSection' => 'Mostrar Seção de Destaque', - 'settings.useGlobalHubs' => 'Usar Layout Plex Home', - 'settings.useGlobalHubsDescription' => 'Mostrar hubs da página inicial como o cliente oficial Plex. Quando desativado, mostra recomendações por biblioteca.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Mostrar Nome do Servidor nos Hubs', 'settings.showServerNameOnHubsDescription' => 'Sempre exibir o nome do servidor nos títulos dos hubs. Quando desativado, mostra apenas para nomes duplicados.', 'settings.groupLibrariesByServer' => 'Agrupar Bibliotecas por Servidor', - 'settings.groupLibrariesByServerDescription' => 'Mostra um cabeçalho para cada servidor Plex na barra lateral quando você está conectado a vários servidores.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Manter Barra Lateral Sempre Aberta', 'settings.alwaysKeepSidebarOpenDescription' => 'A barra lateral fica expandida e a área de conteúdo se ajusta', 'settings.showUnwatchedCount' => 'Mostrar Contagem de Não Assistidos', @@ -1962,7 +2139,7 @@ extension on TranslationsPt { 'messages.musicNotSupported' => 'Reprodução de música ainda não é suportada', 'messages.noDescriptionAvailable' => 'Nenhuma descrição disponível', 'messages.noProfilesAvailable' => 'Nenhum perfil disponível', - 'messages.contactAdminForProfiles' => 'Contacte o seu administrador Plex para adicionar perfis', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Não é possível determinar a secção da biblioteca para este item', 'messages.logsCleared' => 'Logs limpos', 'messages.logsCopied' => 'Logs copiados para a área de transferência', @@ -2016,11 +2193,65 @@ extension on TranslationsPt { 'mpvConfig.confirmDeletePreset' => 'Tem certeza que deseja excluir esta predefinição?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Confirmar Ação', + 'profiles.addPlezyProfile' => 'Adicionar perfil Plezy', + 'profiles.switchingProfile' => 'Mudando perfil…', + 'profiles.deleteThisProfileTitle' => 'Excluir este perfil?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} será removido. As conexões não serão afetadas.', + 'profiles.active' => 'Ativo', + 'profiles.manage' => 'Gerenciar', + 'profiles.delete' => 'Excluir', + 'profiles.signOut' => 'Sair', + 'profiles.signOutPlexTitle' => 'Sair do Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} e todos os usuários do Plex Home desta conta serão removidos deste dispositivo. Você pode entrar novamente a qualquer momento.', + 'profiles.signedOutPlex' => 'Saiu do Plex.', + 'profiles.signOutFailed' => 'Falha ao sair.', + 'profiles.sectionTitle' => 'Perfis', + 'profiles.summarySingle' => 'Adicione perfis para mesclar usuários gerenciados e identidades locais', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} perfis · ativo: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} perfis', + 'profiles.removeConnectionTitle' => 'Remover conexão?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} perderá o acesso a ${connectionLabel}. A conexão em si continua disponível para outros perfis.', + 'profiles.deleteProfileTitle' => 'Excluir perfil?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Isso remove ${displayName} e todas as suas conexões deste dispositivo. Os servidores Plex/Jellyfin subjacentes não são afetados.', + 'profiles.profileNameLabel' => 'Nome do perfil', + 'profiles.pinProtectionLabel' => 'Proteção por PIN', + 'profiles.pinManagedByPlex' => 'PIN gerenciado pelo Plex. Edite em plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'Nenhum PIN definido. Para exigir um, edite o usuário Home em plex.tv.', + 'profiles.setPin' => 'Definir PIN', + 'profiles.connectionsLabel' => 'Conexões', + 'profiles.add' => 'Adicionar', + 'profiles.deleteProfileButton' => 'Excluir perfil', + 'profiles.noConnectionsHint' => 'Sem conexões — adicione uma para usar este perfil.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Conta Plex Home', + 'profiles.connectionDefault' => 'Padrão', + 'profiles.makeDefault' => 'Definir como padrão', + 'profiles.removeConnection' => 'Remover', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Adicionar a ${displayName}', + 'profiles.borrowExplain' => 'Tome emprestada uma conexão de outro perfil. Perfis de origem protegidos por PIN pedem o PIN antes de compartilhar.', + 'profiles.borrowEmpty' => 'Nada para emprestar ainda.', + 'profiles.borrowEmptySubtitle' => 'Conecte primeiro uma conta Plex ou servidor Jellyfin a outro perfil e volte aqui.', + 'profiles.newProfile' => 'Novo perfil', + 'profiles.profileNameHint' => 'ex.: Visitantes, Crianças, Sala de família', + 'profiles.pinProtectionOptional' => 'Proteção por PIN (opcional)', + 'profiles.pinExplain' => 'PIN de 4 dígitos necessário para alternar para este perfil. Barreira suave — quem puder limpar os dados do app pode contorná-la.', + 'profiles.continueButton' => 'Continuar', + 'profiles.pinsDontMatch' => 'Os PINs não correspondem', + 'connections.sectionTitle' => 'Conexões', + 'connections.addConnection' => 'Adicionar conexão', + 'connections.addConnectionSubtitleNoProfile' => 'Faça login com Plex ou conecte um servidor Jellyfin', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Adicionar a ${displayName} — conta Plex, servidor Jellyfin ou emprestar de outro perfil', + 'connections.sessionExpiredOne' => ({required Object name}) => 'Sessão expirada para ${name}', + 'connections.sessionExpiredMany' => ({required Object count}) => 'Sessão expirada para ${count} servidores', + 'connections.signInAgain' => 'Entrar novamente', 'discover.title' => 'Descobrir', 'discover.switchProfile' => 'Trocar Perfil', 'discover.noContentAvailable' => 'Nenhum conteúdo disponível', 'discover.addMediaToLibraries' => 'Adicione mídias às suas bibliotecas', 'discover.continueWatching' => 'Continuar Assistindo', + 'discover.nextUp' => 'A seguir', + 'discover.recentlyAdded' => 'Adicionados recentemente', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Sinopse', 'discover.cast' => 'Elenco', @@ -2032,7 +2263,7 @@ extension on TranslationsPt { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min restantes', 'errors.searchFailed' => ({required Object error}) => 'Falha na busca: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Tempo de conexão esgotado ao carregar ${context}', - 'errors.connectionFailed' => 'Não foi possível conectar ao servidor Plex', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Falha ao carregar ${context}: ${error}', 'errors.noClientAvailable' => 'Nenhum cliente disponível', 'errors.authenticationFailed' => ({required Object error}) => 'Falha na autenticação: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsPt { 'errors.invalidToken' => 'Token inválido', 'errors.failedToVerifyToken' => ({required Object error}) => 'Falha ao verificar token: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Falha ao trocar para ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Falha ao excluir ${displayName}', + 'errors.failedToRate' => 'Não foi possível atualizar a classificação', 'libraries.title' => 'Bibliotecas', 'libraries.scanLibraryFiles' => 'Escanear Arquivos da Biblioteca', 'libraries.scanLibrary' => 'Escanear Biblioteca', @@ -2054,8 +2287,6 @@ extension on TranslationsPt { 'libraries.analyzing' => ({required Object title}) => 'Analisando "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Análise iniciada para "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Falha ao analisar biblioteca: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Nenhuma biblioteca encontrada', 'libraries.allLibrariesHidden' => 'Todas as bibliotecas estão ocultas', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Bibliotecas ocultas (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsPt { 'libraries.groupings.seasons' => 'Temporadas', 'libraries.groupings.episodes' => 'Episódios', 'libraries.groupings.folders' => 'Pastas', + 'libraries.filterCategories.genre' => 'Gênero', + 'libraries.filterCategories.year' => 'Ano', + 'libraries.filterCategories.contentRating' => 'Classificação', + 'libraries.filterCategories.tag' => 'Tag', + 'libraries.sortLabels.title' => 'Título', + 'libraries.sortLabels.dateAdded' => 'Data de adição', + 'libraries.sortLabels.releaseDate' => 'Data de lançamento', + 'libraries.sortLabels.rating' => 'Avaliação', + 'libraries.sortLabels.lastPlayed' => 'Última reprodução', + 'libraries.sortLabels.playCount' => 'Reproduções', + 'libraries.sortLabels.random' => 'Aleatório', + 'libraries.sortLabels.dateShared' => 'Data de compartilhamento', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Última data de exibição do episódio', 'about.title' => 'Sobre', 'about.openSourceLicenses' => 'Licenças Open Source', 'about.versionLabel' => ({required Object version}) => 'Versão ${version}', - 'about.appDescription' => 'Um belo cliente Plex para Flutter', + 'about.appDescription' => 'Um belo cliente Plex e Jellyfin para Flutter', 'about.viewLicensesDescription' => 'Ver licenças de bibliotecas de terceiros', 'serverSelection.allServerConnectionsFailed' => 'Falha ao conectar a qualquer servidor. Verifique sua rede e tente novamente.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Nenhum servidor encontrado para ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsPt { 'watchTogether.recentRooms' => 'Salas recentes', 'watchTogether.renameRoom' => 'Renomear sala', 'watchTogether.removeRoom' => 'Remover', + 'watchTogether.guestSwitchUnavailable' => 'Não foi possível trocar — servidor indisponível para sincronização', + 'watchTogether.guestSwitchFailed' => 'Não foi possível trocar — conteúdo não encontrado neste servidor', 'downloads.title' => 'Downloads', 'downloads.manage' => 'Gerenciar', 'downloads.tvShows' => 'Séries de TV', @@ -2291,6 +2537,12 @@ extension on TranslationsPt { 'downloads.editSyncFilter' => 'Filtro de sincronização', 'downloads.syncAllItems' => 'Sincronizando todos os itens', 'downloads.syncUnwatchedItems' => 'Sincronizando itens não vistos', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Servidor: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Disponível', + 'downloads.syncRuleOffline' => 'Offline', + 'downloads.syncRuleSignInRequired' => 'Início de sessão necessário', + 'downloads.syncRuleNotAvailableForProfile' => 'Indisponível para o perfil atual', + 'downloads.syncRuleUnknownServer' => 'Servidor desconhecido', 'downloads.syncRuleListCreated' => 'Regra de sincronização criada', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Sem aprimoramento de vídeo', @@ -2484,6 +2736,8 @@ extension on TranslationsPt { 'trakt.disconnectConfirmBody' => 'O Plezy deixará de enviar eventos de reprodução ao Trakt. Você pode reconectar a qualquer momento.', 'trakt.scrobble' => 'Scrobbling em tempo real', 'trakt.scrobbleDescription' => 'Envia eventos de reprodução, pausa e parada ao Trakt durante a exibição.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Sincronizar status de assistido', 'trakt.watchedSyncDescription' => 'Ao marcar itens como assistidos no Plezy, eles também serão marcados no Trakt.', 'trackers.title' => 'Rastreadores', @@ -2519,6 +2773,38 @@ extension on TranslationsPt { 'trackers.libraryFilter.modeHintWhitelist' => 'Sincronizar apenas as bibliotecas marcadas abaixo.', 'trackers.libraryFilter.libraries' => 'Bibliotecas', 'trackers.libraryFilter.noLibraries' => 'Nenhuma biblioteca disponível', + 'addServer.addJellyfinTitle' => 'Adicionar servidor Jellyfin', + 'addServer.jellyfinUrlIntro' => 'Insira a URL do seu servidor Jellyfin — ex.: `https://jellyfin.example.com`. Você pode entrar em seguida.', + 'addServer.serverUrl' => 'URL do servidor', + 'addServer.findServer' => 'Encontrar servidor', + 'addServer.username' => 'Usuário', + 'addServer.password' => 'Senha', + 'addServer.signIn' => 'Entrar', + 'addServer.change' => 'Alterar', + 'addServer.required' => 'Obrigatório', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Não foi possível conectar ao servidor: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Falha ao entrar: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect falhou: ${error}', + 'addServer.addPlexTitle' => 'Entrar com Plex', + 'addServer.plexAuthIntro' => 'Escolha como entrar no Plex. O fluxo do navegador abre plex.tv onde você confirma a conexão; a opção QR é prática para TV / dispositivos remotos.', + 'addServer.plexQRPrompt' => 'Escaneie este código QR para entrar.', + 'addServer.waitingForPlexConfirmation' => 'Aguardando o plex.tv confirmar o login…', + 'addServer.pinExpired' => 'O PIN expirou antes do login. Tente novamente.', + 'addServer.duplicatePlexAccount' => 'Este dispositivo já está conectado a uma conta Plex. Saia nas configurações para trocar de conta.', + '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.addConnectionIntroGlobal' => 'Adicione outro servidor de mídia. Você pode misturar contas Plex e servidores Jellyfin — itens de cada backend conectado aparecem juntos na tela inicial.', + 'addServer.addConnectionIntroScoped' => 'Adicione um novo servidor ou pegue um emprestado de outro perfil.', + 'addServer.signInWithPlexCard' => 'Entrar com Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Autorize este dispositivo na sua conta Plex. Servidores compartilhados com a conta vêm junto automaticamente.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Autorize uma nova conta Plex. Seus usuários Home aparecem como perfis.', + 'addServer.connectToJellyfinCard' => 'Conectar ao Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Insira a URL do seu servidor Jellyfin e entre com usuário + senha (Quick Connect em breve).', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Entre em um servidor Jellyfin. Vinculado a ${name}.', + 'addServer.borrowFromAnotherProfile' => 'Pegar emprestado de outro perfil', + 'addServer.borrowFromAnotherProfileSubtitle' => 'Reutilize uma conexão já associada a outro perfil. Perfis de origem protegidos por PIN solicitarão o PIN.', _ => null, }; } diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 6a04f2fd..4bf63de9 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsRu with BaseTranslations implements Translations { +class TranslationsRu extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsRu({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsRu with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsRu with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsRu _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsRu with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingRu subtitlingStyling = _TranslationsSubtitlingStylingRu._(_root); @override late final _TranslationsMpvConfigRu mpvConfig = _TranslationsMpvConfigRu._(_root); @override late final _TranslationsDialogRu dialog = _TranslationsDialogRu._(_root); + @override late final _TranslationsProfilesRu profiles = _TranslationsProfilesRu._(_root); + @override late final _TranslationsConnectionsRu connections = _TranslationsConnectionsRu._(_root); @override late final _TranslationsDiscoverRu discover = _TranslationsDiscoverRu._(_root); @override late final _TranslationsErrorsRu errors = _TranslationsErrorsRu._(_root); @override late final _TranslationsLibrariesRu libraries = _TranslationsLibrariesRu._(_root); @@ -78,11 +82,12 @@ class TranslationsRu with BaseTranslations implements T @override late final _TranslationsServerTasksRu serverTasks = _TranslationsServerTasksRu._(_root); @override late final _TranslationsTraktRu trakt = _TranslationsTraktRu._(_root); @override late final _TranslationsTrackersRu trackers = _TranslationsTrackersRu._(_root); + @override late final _TranslationsAddServerRu addServer = _TranslationsAddServerRu._(_root); } // Path: app -class _TranslationsAppRu implements TranslationsAppEn { - _TranslationsAppRu._(this._root); +class _TranslationsAppRu extends TranslationsAppEn { + _TranslationsAppRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppRu implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthRu implements TranslationsAuthEn { - _TranslationsAuthRu._(this._root); +class _TranslationsAuthRu extends TranslationsAuthEn { + _TranslationsAuthRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field // Translations + @override String get signIn => 'Войти'; @override String get signInWithPlex => 'Войти через Plex'; @override String get showQRCode => 'Показать QR-код'; @override String get authenticate => 'Аутентификация'; @@ -104,11 +110,19 @@ class _TranslationsAuthRu implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Отсканируйте QR-код для входа'; @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 quickConnectCode => 'Код Quick Connect'; + @override String get quickConnectInstructions => 'Откройте сервер Jellyfin в браузере, войдите и выберите Quick Connect в меню пользователя. Введите этот код, чтобы подтвердить вход.'; + @override String get quickConnectWaiting => 'Ожидание подтверждения…'; + @override String get quickConnectCancel => 'Отмена'; + @override String get quickConnectExpired => 'Срок действия кода Quick Connect истёк до подтверждения. Повторите попытку.'; } // Path: common -class _TranslationsCommonRu implements TranslationsCommonEn { - _TranslationsCommonRu._(this._root); +class _TranslationsCommonRu extends TranslationsCommonEn { + _TranslationsCommonRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonRu implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensRu implements TranslationsScreensEn { - _TranslationsScreensRu._(this._root); +class _TranslationsScreensRu extends TranslationsScreensEn { + _TranslationsScreensRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensRu implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateRu implements TranslationsUpdateEn { - _TranslationsUpdateRu._(this._root); +class _TranslationsUpdateRu extends TranslationsUpdateEn { + _TranslationsUpdateRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateRu implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsRu implements TranslationsSettingsEn { - _TranslationsSettingsRu._(this._root); +class _TranslationsSettingsRu extends TranslationsSettingsEn { + _TranslationsSettingsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsRu implements TranslationsSettingsEn { @override String get gridView => 'Сетка'; @override String get listView => 'Список'; @override String get showHeroSection => 'Показать раздел избранного'; - @override String get useGlobalHubs => 'Использовать макет Plex Home'; - @override String get useGlobalHubsDescription => 'Показывать хабы главной страницы как в официальном клиенте Plex. При выключении показывает рекомендации по библиотекам.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Показывать имя сервера в хабах'; @override String get showServerNameOnHubsDescription => 'Всегда показывать имя сервера в заголовках хабов. При выключении показывает только для дублирующихся имён.'; @override String get groupLibrariesByServer => 'Группировать библиотеки по серверам'; - @override String get groupLibrariesByServerDescription => 'Показывать заголовок для каждого сервера Plex на боковой панели при подключении к нескольким серверам.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Всегда держать боковую панель открытой'; @override String get alwaysKeepSidebarOpenDescription => 'Боковая панель остаётся развёрнутой, область контента подстраивается'; @override String get showUnwatchedCount => 'Показывать количество непросмотренных'; @@ -385,8 +399,8 @@ class _TranslationsSettingsRu implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchRu implements TranslationsSearchEn { - _TranslationsSearchRu._(this._root); +class _TranslationsSearchRu extends TranslationsSearchEn { + _TranslationsSearchRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchRu implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysRu implements TranslationsHotkeysEn { - _TranslationsHotkeysRu._(this._root); +class _TranslationsHotkeysRu extends TranslationsHotkeysEn { + _TranslationsHotkeysRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysRu implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoRu implements TranslationsFileInfoEn { - _TranslationsFileInfoRu._(this._root); +class _TranslationsFileInfoRu extends TranslationsFileInfoEn { + _TranslationsFileInfoRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoRu implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuRu implements TranslationsMediaMenuEn { - _TranslationsMediaMenuRu._(this._root); +class _TranslationsMediaMenuRu extends TranslationsMediaMenuEn { + _TranslationsMediaMenuRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuRu implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityRu implements TranslationsAccessibilityEn { - _TranslationsAccessibilityRu._(this._root); +class _TranslationsAccessibilityRu extends TranslationsAccessibilityEn { + _TranslationsAccessibilityRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityRu implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsRu implements TranslationsTooltipsEn { - _TranslationsTooltipsRu._(this._root); +class _TranslationsTooltipsRu extends TranslationsTooltipsEn { + _TranslationsTooltipsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsRu implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsRu implements TranslationsVideoControlsEn { - _TranslationsVideoControlsRu._(this._root); +class _TranslationsVideoControlsRu extends TranslationsVideoControlsEn { + _TranslationsVideoControlsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsRu implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusRu implements TranslationsUserStatusEn { - _TranslationsUserStatusRu._(this._root); +class _TranslationsUserStatusRu extends TranslationsUserStatusEn { + _TranslationsUserStatusRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusRu implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesRu implements TranslationsMessagesEn { - _TranslationsMessagesRu._(this._root); +class _TranslationsMessagesRu extends TranslationsMessagesEn { + _TranslationsMessagesRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesRu implements TranslationsMessagesEn { @override String get musicNotSupported => 'Воспроизведение музыки пока не поддерживается'; @override String get noDescriptionAvailable => 'Описание недоступно'; @override String get noProfilesAvailable => 'Профили недоступны'; - @override String get contactAdminForProfiles => 'Обратитесь к администратору Plex, чтобы добавить профили'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Не удаётся определить раздел библиотеки для этого элемента'; @override String get logsCleared => 'Логи очищены'; @override String get logsCopied => 'Логи скопированы в буфер обмена'; @@ -636,8 +650,8 @@ class _TranslationsMessagesRu implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingRu implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingRu._(this._root); +class _TranslationsSubtitlingStylingRu extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingRu implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigRu implements TranslationsMpvConfigEn { - _TranslationsMpvConfigRu._(this._root); +class _TranslationsMpvConfigRu extends TranslationsMpvConfigEn { + _TranslationsMpvConfigRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigRu implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogRu implements TranslationsDialogEn { - _TranslationsDialogRu._(this._root); +class _TranslationsDialogRu extends TranslationsDialogEn { + _TranslationsDialogRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogRu implements TranslationsDialogEn { @override String get confirmAction => 'Подтвердить действие'; } +// Path: profiles +class _TranslationsProfilesRu extends TranslationsProfilesEn { + _TranslationsProfilesRu._(TranslationsRu root) : this._root = root, super.internal(root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Добавить профиль Plezy'; + @override String get switchingProfile => 'Переключение профиля…'; + @override String get deleteThisProfileTitle => 'Удалить этот профиль?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} будет удалён. Подключения не пострадают.'; + @override String get active => 'Активный'; + @override String get manage => 'Управление'; + @override String get delete => 'Удалить'; + @override String get signOut => 'Выйти'; + @override String get signOutPlexTitle => 'Выйти из Plex?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} и все пользователи Plex Home этой учётной записи будут удалены с этого устройства. Вы можете войти снова в любое время.'; + @override String get signedOutPlex => 'Вы вышли из Plex.'; + @override String get signOutFailed => 'Не удалось выйти.'; + @override String get sectionTitle => 'Профили'; + @override String get summarySingle => 'Добавьте профили, чтобы смешать управляемых пользователей и локальные идентификаторы'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} профилей · активный: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} профилей'; + @override String get removeConnectionTitle => 'Удалить соединение?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} потеряет доступ к ${connectionLabel}. Само соединение останется доступным для других профилей.'; + @override String get deleteProfileTitle => 'Удалить профиль?'; + @override String deleteProfileMessage({required Object displayName}) => 'Это удалит ${displayName} и все его соединения с этого устройства. Сами серверы Plex/Jellyfin не будут затронуты.'; + @override String get profileNameLabel => 'Имя профиля'; + @override String get pinProtectionLabel => 'Защита PIN-кодом'; + @override String get pinManagedByPlex => 'PIN управляется Plex. Редактируйте на plex.tv.'; + @override String get noPinSetEditOnPlex => 'PIN не установлен. Чтобы требовать его, отредактируйте пользователя Home на plex.tv.'; + @override String get setPin => 'Установить PIN'; + @override String get connectionsLabel => 'Соединения'; + @override String get add => 'Добавить'; + @override String get deleteProfileButton => 'Удалить профиль'; + @override String get noConnectionsHint => 'Нет соединений — добавьте одно, чтобы использовать этот профиль.'; + @override String get plexHomeAccount => 'Аккаунт Plex Home'; + @override String get connectionDefault => 'По умолчанию'; + @override String get makeDefault => 'Сделать по умолчанию'; + @override String get removeConnection => 'Удалить'; + @override String borrowAddTo({required Object displayName}) => 'Добавить в ${displayName}'; + @override String get borrowExplain => 'Заимствуйте соединение из другого профиля. Защищённые PIN-кодом исходные профили запросят PIN перед предоставлением доступа.'; + @override String get borrowEmpty => 'Пока нечего заимствовать.'; + @override String get borrowEmptySubtitle => 'Сначала подключите аккаунт Plex или сервер Jellyfin к другому профилю, а затем вернитесь сюда.'; + @override String get newProfile => 'Новый профиль'; + @override String get profileNameHint => 'например, Гости, Дети, Семейная комната'; + @override String get pinProtectionOptional => 'Защита PIN-кодом (необязательно)'; + @override String get pinExplain => 'Для переключения на этот профиль требуется 4-значный PIN. Мягкий барьер — любой, кто может очистить данные приложения, может его обойти.'; + @override String get continueButton => 'Продолжить'; + @override String get pinsDontMatch => 'PIN-коды не совпадают'; +} + +// Path: connections +class _TranslationsConnectionsRu extends TranslationsConnectionsEn { + _TranslationsConnectionsRu._(TranslationsRu root) : this._root = root, super.internal(root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Подключения'; + @override String get addConnection => 'Добавить подключение'; + @override String get addConnectionSubtitleNoProfile => 'Войдите через Plex или подключите сервер Jellyfin'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Добавить к ${displayName} — учётная запись Plex, сервер Jellyfin или заимствовать из другого профиля'; + @override String sessionExpiredOne({required Object name}) => 'Сессия истекла для ${name}'; + @override String sessionExpiredMany({required Object count}) => 'Сессия истекла для ${count} серверов'; + @override String get signInAgain => 'Войти снова'; +} + // Path: discover -class _TranslationsDiscoverRu implements TranslationsDiscoverEn { - _TranslationsDiscoverRu._(this._root); +class _TranslationsDiscoverRu extends TranslationsDiscoverEn { + _TranslationsDiscoverRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverRu implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Контент недоступен'; @override String get addMediaToLibraries => 'Добавьте медиафайлы в ваши библиотеки'; @override String get continueWatching => 'Продолжить просмотр'; + @override String get nextUp => 'Далее'; + @override String get recentlyAdded => 'Недавно добавленное'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Обзор'; @override String get cast => 'В ролях'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverRu implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsRu implements TranslationsErrorsEn { - _TranslationsErrorsRu._(this._root); +class _TranslationsErrorsRu extends TranslationsErrorsEn { + _TranslationsErrorsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Ошибка поиска: ${error}'; @override String connectionTimeout({required Object context}) => 'Таймаут подключения при загрузке ${context}'; - @override String get connectionFailed => 'Не удаётся подключиться к серверу Plex'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Не удалось загрузить ${context}: ${error}'; @override String get noClientAvailable => 'Клиент недоступен'; @override String authenticationFailed({required Object error}) => 'Ошибка аутентификации: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsRu implements TranslationsErrorsEn { @override String get invalidToken => 'Недействительный токен'; @override String failedToVerifyToken({required Object error}) => 'Не удалось проверить токен: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Не удалось переключиться на ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Не удалось удалить ${displayName}'; + @override String get failedToRate => 'Не удалось обновить оценку'; } // Path: libraries -class _TranslationsLibrariesRu implements TranslationsLibrariesEn { - _TranslationsLibrariesRu._(this._root); +class _TranslationsLibrariesRu extends TranslationsLibrariesEn { + _TranslationsLibrariesRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesRu implements TranslationsLibrariesEn { @override String get folders => 'папки'; @override late final _TranslationsLibrariesTabsRu tabs = _TranslationsLibrariesTabsRu._(_root); @override late final _TranslationsLibrariesGroupingsRu groupings = _TranslationsLibrariesGroupingsRu._(_root); + @override late final _TranslationsLibrariesFilterCategoriesRu filterCategories = _TranslationsLibrariesFilterCategoriesRu._(_root); + @override late final _TranslationsLibrariesSortLabelsRu sortLabels = _TranslationsLibrariesSortLabelsRu._(_root); } // Path: about -class _TranslationsAboutRu implements TranslationsAboutEn { - _TranslationsAboutRu._(this._root); +class _TranslationsAboutRu extends TranslationsAboutEn { + _TranslationsAboutRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutRu implements TranslationsAboutEn { @override String get title => 'О приложении'; @override String get openSourceLicenses => 'Лицензии открытого ПО'; @override String versionLabel({required Object version}) => 'Версия ${version}'; - @override String get appDescription => 'Красивый клиент Plex на Flutter'; + @override String get appDescription => 'Красивый клиент Plex и Jellyfin на Flutter'; @override String get viewLicensesDescription => 'Просмотр лицензий сторонних библиотек'; } // Path: serverSelection -class _TranslationsServerSelectionRu implements TranslationsServerSelectionEn { - _TranslationsServerSelectionRu._(this._root); +class _TranslationsServerSelectionRu extends TranslationsServerSelectionEn { + _TranslationsServerSelectionRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionRu implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailRu implements TranslationsHubDetailEn { - _TranslationsHubDetailRu._(this._root); +class _TranslationsHubDetailRu extends TranslationsHubDetailEn { + _TranslationsHubDetailRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailRu implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsRu implements TranslationsLogsEn { - _TranslationsLogsRu._(this._root); +class _TranslationsLogsRu extends TranslationsLogsEn { + _TranslationsLogsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsRu implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesRu implements TranslationsLicensesEn { - _TranslationsLicensesRu._(this._root); +class _TranslationsLicensesRu extends TranslationsLicensesEn { + _TranslationsLicensesRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesRu implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationRu implements TranslationsNavigationEn { - _TranslationsNavigationRu._(this._root); +class _TranslationsNavigationRu extends TranslationsNavigationEn { + _TranslationsNavigationRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationRu implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvRu implements TranslationsLiveTvEn { - _TranslationsLiveTvRu._(this._root); +class _TranslationsLiveTvRu extends TranslationsLiveTvEn { + _TranslationsLiveTvRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvRu implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsRu implements TranslationsCollectionsEn { - _TranslationsCollectionsRu._(this._root); +class _TranslationsCollectionsRu extends TranslationsCollectionsEn { + _TranslationsCollectionsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsRu implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsRu implements TranslationsPlaylistsEn { - _TranslationsPlaylistsRu._(this._root); +class _TranslationsPlaylistsRu extends TranslationsPlaylistsEn { + _TranslationsPlaylistsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsRu implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherRu implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherRu._(this._root); +class _TranslationsWatchTogetherRu extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherRu implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Недавние комнаты'; @override String get renameRoom => 'Переименовать комнату'; @override String get removeRoom => 'Удалить'; + @override String get guestSwitchUnavailable => 'Не удалось переключиться — сервер недоступен для синхронизации'; + @override String get guestSwitchFailed => 'Не удалось переключиться — содержимое не найдено на этом сервере'; } // Path: downloads -class _TranslationsDownloadsRu implements TranslationsDownloadsEn { - _TranslationsDownloadsRu._(this._root); +class _TranslationsDownloadsRu extends TranslationsDownloadsEn { + _TranslationsDownloadsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsRu implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Фильтр синхронизации'; @override String get syncAllItems => 'Синхронизация всех элементов'; @override String get syncUnwatchedItems => 'Синхронизация непросмотренных элементов'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Сервер: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Доступен'; + @override String get syncRuleOffline => 'Офлайн'; + @override String get syncRuleSignInRequired => 'Требуется вход'; + @override String get syncRuleNotAvailableForProfile => 'Недоступно для текущего профиля'; + @override String get syncRuleUnknownServer => 'Неизвестный сервер'; @override String get syncRuleListCreated => 'Правило синхронизации создано'; } // Path: shaders -class _TranslationsShadersRu implements TranslationsShadersEn { - _TranslationsShadersRu._(this._root); +class _TranslationsShadersRu extends TranslationsShadersEn { + _TranslationsShadersRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersRu implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteRu implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteRu._(this._root); +class _TranslationsCompanionRemoteRu extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteRu implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsRu implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsRu._(this._root); +class _TranslationsVideoSettingsRu extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsRu implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerRu implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerRu._(this._root); +class _TranslationsExternalPlayerRu extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerRu implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditRu implements TranslationsMetadataEditEn { - _TranslationsMetadataEditRu._(this._root); +class _TranslationsMetadataEditRu extends TranslationsMetadataEditEn { + _TranslationsMetadataEditRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditRu implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenRu implements TranslationsMatchScreenEn { - _TranslationsMatchScreenRu._(this._root); +class _TranslationsMatchScreenRu extends TranslationsMatchScreenEn { + _TranslationsMatchScreenRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenRu implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksRu implements TranslationsServerTasksEn { - _TranslationsServerTasksRu._(this._root); +class _TranslationsServerTasksRu extends TranslationsServerTasksEn { + _TranslationsServerTasksRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksRu implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktRu implements TranslationsTraktEn { - _TranslationsTraktRu._(this._root); +class _TranslationsTraktRu extends TranslationsTraktEn { + _TranslationsTraktRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktRu implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersRu implements TranslationsTrackersEn { - _TranslationsTrackersRu._(this._root); +class _TranslationsTrackersRu extends TranslationsTrackersEn { + _TranslationsTrackersRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersRu implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterRu libraryFilter = _TranslationsTrackersLibraryFilterRu._(_root); } +// Path: addServer +class _TranslationsAddServerRu extends TranslationsAddServerEn { + _TranslationsAddServerRu._(TranslationsRu root) : this._root = root, super.internal(root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Добавить сервер Jellyfin'; + @override String get jellyfinUrlIntro => 'Введите URL вашего сервера Jellyfin — например, `https://jellyfin.example.com`. Войти можно после.'; + @override String get serverUrl => 'URL сервера'; + @override String get findServer => 'Найти сервер'; + @override String get username => 'Имя пользователя'; + @override String get password => 'Пароль'; + @override String get signIn => 'Войти'; + @override String get change => 'Изменить'; + @override String get required => 'Обязательно'; + @override String couldNotReachServer({required Object error}) => 'Не удалось связаться с сервером: ${error}'; + @override String signInFailed({required Object error}) => 'Не удалось войти: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect не удался: ${error}'; + @override String get addPlexTitle => 'Войти через Plex'; + @override String get plexAuthIntro => 'Выберите способ входа в Plex. Поток в браузере открывает plex.tv, где вы подтверждаете подключение; QR-вариант удобен для TV или удалённых устройств.'; + @override String get plexQRPrompt => 'Отсканируйте этот QR-код, чтобы войти.'; + @override String get waitingForPlexConfirmation => 'Ожидание подтверждения от plex.tv…'; + @override String get pinExpired => 'Срок действия PIN истёк до входа. Попробуйте снова.'; + @override String get duplicatePlexAccount => 'Это устройство уже подключено к учётной записи Plex. Выйдите в настройках, чтобы сменить учётную запись.'; + @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 addConnectionIntroGlobal => 'Добавьте ещё один медиасервер. Можно сочетать учётные записи Plex и серверы Jellyfin — элементы со всех подключённых бэкендов появятся вместе на главном экране.'; + @override String get addConnectionIntroScoped => 'Добавьте новый сервер или одолжите из другого профиля.'; + @override String get signInWithPlexCard => 'Войти через Plex'; + @override String get signInWithPlexCardSubtitle => 'Авторизуйте это устройство в вашей учётной записи Plex. Серверы, общие с учётной записью, добавятся автоматически.'; + @override String get signInWithPlexCardSubtitleScoped => 'Авторизуйте новую учётную запись Plex. Её Home-пользователи появятся как профили.'; + @override String get connectToJellyfinCard => 'Подключиться к Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Введите URL сервера Jellyfin и войдите с именем пользователя и паролем (Quick Connect — скоро).'; + @override String connectToJellyfinCardSubtitleScoped({required Object name}) => 'Войдите на сервер Jellyfin. Привязывается к ${name}.'; + @override String get borrowFromAnotherProfile => 'Одолжить из другого профиля'; + @override String get borrowFromAnotherProfileSubtitle => 'Повторно используйте подключение, уже привязанное к другому профилю. PIN-защищённые исходные профили запрашивают PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsRu implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsRu._(this._root); +class _TranslationsHotkeysActionsRu extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsRu implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsRu implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsRu._(this._root); +class _TranslationsVideoControlsPipErrorsRu extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsRu implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsRu implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsRu._(this._root); +class _TranslationsLibrariesTabsRu extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsRu implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsRu implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsRu._(this._root); +class _TranslationsLibrariesGroupingsRu extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsRu implements TranslationsLibrariesGrouping @override String get folders => 'Папки'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesRu extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesRu._(TranslationsRu root) : this._root = root, super.internal(root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String get genre => 'Жанр'; + @override String get year => 'Год'; + @override String get contentRating => 'Возрастной рейтинг'; + @override String get tag => 'Тег'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsRu extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsRu._(TranslationsRu root) : this._root = root, super.internal(root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String get title => 'Название'; + @override String get dateAdded => 'Дата добавления'; + @override String get releaseDate => 'Дата выхода'; + @override String get rating => 'Рейтинг'; + @override String get lastPlayed => 'Последний просмотр'; + @override String get playCount => 'Количество просмотров'; + @override String get random => 'Случайно'; + @override String get dateShared => 'Дата открытия доступа'; + @override String get latestEpisodeAirDate => 'Дата выхода последнего эпизода'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionRu implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionRu._(this._root); +class _TranslationsCompanionRemoteSessionRu extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionRu implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingRu implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingRu._(this._root); +class _TranslationsCompanionRemotePairingRu extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingRu implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteRu implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteRu._(this._root); +class _TranslationsCompanionRemoteRemoteRu extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteRu implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesRu implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesRu._(this._root); +class _TranslationsTrackersServicesRu extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesRu implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeRu implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeRu._(this._root); +class _TranslationsTrackersDeviceCodeRu extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeRu implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyRu implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyRu._(this._root); +class _TranslationsTrackersOauthProxyRu extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyRu implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterRu implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterRu._(this._root); +class _TranslationsTrackersLibraryFilterRu extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterRu._(TranslationsRu root) : this._root = root, super.internal(root); final TranslationsRu _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsRu { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Войти', 'auth.signInWithPlex' => 'Войти через Plex', 'auth.showQRCode' => 'Показать QR-код', 'auth.authenticate' => 'Аутентификация', @@ -1550,6 +1719,14 @@ extension on TranslationsRu { 'auth.scanQRToSignIn' => 'Отсканируйте QR-код для входа', 'auth.waitingForAuth' => 'Ожидание аутентификации...\nЗавершите вход в браузере.', 'auth.useBrowser' => 'Использовать браузер', + 'auth.or' => 'или', + 'auth.connectToJellyfin' => 'Подключиться к Jellyfin', + 'auth.useQuickConnect' => 'Использовать Quick Connect', + 'auth.quickConnectCode' => 'Код Quick Connect', + 'auth.quickConnectInstructions' => 'Откройте сервер Jellyfin в браузере, войдите и выберите Quick Connect в меню пользователя. Введите этот код, чтобы подтвердить вход.', + 'auth.quickConnectWaiting' => 'Ожидание подтверждения…', + 'auth.quickConnectCancel' => 'Отмена', + 'auth.quickConnectExpired' => 'Срок действия кода Quick Connect истёк до подтверждения. Повторите попытку.', 'common.cancel' => 'Отмена', 'common.save' => 'Сохранить', 'common.close' => 'Закрыть', @@ -1636,12 +1813,12 @@ extension on TranslationsRu { 'settings.gridView' => 'Сетка', 'settings.listView' => 'Список', 'settings.showHeroSection' => 'Показать раздел избранного', - 'settings.useGlobalHubs' => 'Использовать макет Plex Home', - 'settings.useGlobalHubsDescription' => 'Показывать хабы главной страницы как в официальном клиенте Plex. При выключении показывает рекомендации по библиотекам.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Показывать имя сервера в хабах', 'settings.showServerNameOnHubsDescription' => 'Всегда показывать имя сервера в заголовках хабов. При выключении показывает только для дублирующихся имён.', 'settings.groupLibrariesByServer' => 'Группировать библиотеки по серверам', - 'settings.groupLibrariesByServerDescription' => 'Показывать заголовок для каждого сервера Plex на боковой панели при подключении к нескольким серверам.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Всегда держать боковую панель открытой', 'settings.alwaysKeepSidebarOpenDescription' => 'Боковая панель остаётся развёрнутой, область контента подстраивается', 'settings.showUnwatchedCount' => 'Показывать количество непросмотренных', @@ -1962,7 +2139,7 @@ extension on TranslationsRu { 'messages.musicNotSupported' => 'Воспроизведение музыки пока не поддерживается', 'messages.noDescriptionAvailable' => 'Описание недоступно', 'messages.noProfilesAvailable' => 'Профили недоступны', - 'messages.contactAdminForProfiles' => 'Обратитесь к администратору Plex, чтобы добавить профили', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Не удаётся определить раздел библиотеки для этого элемента', 'messages.logsCleared' => 'Логи очищены', 'messages.logsCopied' => 'Логи скопированы в буфер обмена', @@ -2016,11 +2193,65 @@ extension on TranslationsRu { 'mpvConfig.confirmDeletePreset' => 'Вы уверены, что хотите удалить этот пресет?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Подтвердить действие', + 'profiles.addPlezyProfile' => 'Добавить профиль Plezy', + 'profiles.switchingProfile' => 'Переключение профиля…', + 'profiles.deleteThisProfileTitle' => 'Удалить этот профиль?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} будет удалён. Подключения не пострадают.', + 'profiles.active' => 'Активный', + 'profiles.manage' => 'Управление', + 'profiles.delete' => 'Удалить', + 'profiles.signOut' => 'Выйти', + 'profiles.signOutPlexTitle' => 'Выйти из Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} и все пользователи Plex Home этой учётной записи будут удалены с этого устройства. Вы можете войти снова в любое время.', + 'profiles.signedOutPlex' => 'Вы вышли из Plex.', + 'profiles.signOutFailed' => 'Не удалось выйти.', + 'profiles.sectionTitle' => 'Профили', + 'profiles.summarySingle' => 'Добавьте профили, чтобы смешать управляемых пользователей и локальные идентификаторы', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} профилей · активный: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} профилей', + 'profiles.removeConnectionTitle' => 'Удалить соединение?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} потеряет доступ к ${connectionLabel}. Само соединение останется доступным для других профилей.', + 'profiles.deleteProfileTitle' => 'Удалить профиль?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Это удалит ${displayName} и все его соединения с этого устройства. Сами серверы Plex/Jellyfin не будут затронуты.', + 'profiles.profileNameLabel' => 'Имя профиля', + 'profiles.pinProtectionLabel' => 'Защита PIN-кодом', + 'profiles.pinManagedByPlex' => 'PIN управляется Plex. Редактируйте на plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'PIN не установлен. Чтобы требовать его, отредактируйте пользователя Home на plex.tv.', + 'profiles.setPin' => 'Установить PIN', + 'profiles.connectionsLabel' => 'Соединения', + 'profiles.add' => 'Добавить', + 'profiles.deleteProfileButton' => 'Удалить профиль', + 'profiles.noConnectionsHint' => 'Нет соединений — добавьте одно, чтобы использовать этот профиль.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Аккаунт Plex Home', + 'profiles.connectionDefault' => 'По умолчанию', + 'profiles.makeDefault' => 'Сделать по умолчанию', + 'profiles.removeConnection' => 'Удалить', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Добавить в ${displayName}', + 'profiles.borrowExplain' => 'Заимствуйте соединение из другого профиля. Защищённые PIN-кодом исходные профили запросят PIN перед предоставлением доступа.', + 'profiles.borrowEmpty' => 'Пока нечего заимствовать.', + 'profiles.borrowEmptySubtitle' => 'Сначала подключите аккаунт Plex или сервер Jellyfin к другому профилю, а затем вернитесь сюда.', + 'profiles.newProfile' => 'Новый профиль', + 'profiles.profileNameHint' => 'например, Гости, Дети, Семейная комната', + 'profiles.pinProtectionOptional' => 'Защита PIN-кодом (необязательно)', + 'profiles.pinExplain' => 'Для переключения на этот профиль требуется 4-значный PIN. Мягкий барьер — любой, кто может очистить данные приложения, может его обойти.', + 'profiles.continueButton' => 'Продолжить', + 'profiles.pinsDontMatch' => 'PIN-коды не совпадают', + 'connections.sectionTitle' => 'Подключения', + 'connections.addConnection' => 'Добавить подключение', + 'connections.addConnectionSubtitleNoProfile' => 'Войдите через Plex или подключите сервер Jellyfin', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Добавить к ${displayName} — учётная запись Plex, сервер Jellyfin или заимствовать из другого профиля', + 'connections.sessionExpiredOne' => ({required Object name}) => 'Сессия истекла для ${name}', + 'connections.sessionExpiredMany' => ({required Object count}) => 'Сессия истекла для ${count} серверов', + 'connections.signInAgain' => 'Войти снова', 'discover.title' => 'Обзор', 'discover.switchProfile' => 'Сменить профиль', 'discover.noContentAvailable' => 'Контент недоступен', 'discover.addMediaToLibraries' => 'Добавьте медиафайлы в ваши библиотеки', 'discover.continueWatching' => 'Продолжить просмотр', + 'discover.nextUp' => 'Далее', + 'discover.recentlyAdded' => 'Недавно добавленное', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Обзор', 'discover.cast' => 'В ролях', @@ -2032,7 +2263,7 @@ extension on TranslationsRu { 'discover.minutesLeft' => ({required Object minutes}) => 'Осталось ${minutes} мин', 'errors.searchFailed' => ({required Object error}) => 'Ошибка поиска: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Таймаут подключения при загрузке ${context}', - 'errors.connectionFailed' => 'Не удаётся подключиться к серверу Plex', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Не удалось загрузить ${context}: ${error}', 'errors.noClientAvailable' => 'Клиент недоступен', 'errors.authenticationFailed' => ({required Object error}) => 'Ошибка аутентификации: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsRu { 'errors.invalidToken' => 'Недействительный токен', 'errors.failedToVerifyToken' => ({required Object error}) => 'Не удалось проверить токен: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Не удалось переключиться на ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Не удалось удалить ${displayName}', + 'errors.failedToRate' => 'Не удалось обновить оценку', 'libraries.title' => 'Библиотеки', 'libraries.scanLibraryFiles' => 'Сканировать файлы библиотеки', 'libraries.scanLibrary' => 'Сканировать библиотеку', @@ -2054,8 +2287,6 @@ extension on TranslationsRu { 'libraries.analyzing' => ({required Object title}) => 'Анализ "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Анализ начат для "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Не удалось проанализировать библиотеку: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Библиотеки не найдены', 'libraries.allLibrariesHidden' => 'Все библиотеки скрыты', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Скрытые библиотеки (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsRu { 'libraries.groupings.seasons' => 'Сезоны', 'libraries.groupings.episodes' => 'Эпизоды', 'libraries.groupings.folders' => 'Папки', + 'libraries.filterCategories.genre' => 'Жанр', + 'libraries.filterCategories.year' => 'Год', + 'libraries.filterCategories.contentRating' => 'Возрастной рейтинг', + 'libraries.filterCategories.tag' => 'Тег', + 'libraries.sortLabels.title' => 'Название', + 'libraries.sortLabels.dateAdded' => 'Дата добавления', + 'libraries.sortLabels.releaseDate' => 'Дата выхода', + 'libraries.sortLabels.rating' => 'Рейтинг', + 'libraries.sortLabels.lastPlayed' => 'Последний просмотр', + 'libraries.sortLabels.playCount' => 'Количество просмотров', + 'libraries.sortLabels.random' => 'Случайно', + 'libraries.sortLabels.dateShared' => 'Дата открытия доступа', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Дата выхода последнего эпизода', 'about.title' => 'О приложении', 'about.openSourceLicenses' => 'Лицензии открытого ПО', 'about.versionLabel' => ({required Object version}) => 'Версия ${version}', - 'about.appDescription' => 'Красивый клиент Plex на Flutter', + 'about.appDescription' => 'Красивый клиент Plex и Jellyfin на Flutter', 'about.viewLicensesDescription' => 'Просмотр лицензий сторонних библиотек', 'serverSelection.allServerConnectionsFailed' => 'Не удалось подключиться ни к одному серверу. Проверьте сеть и попробуйте снова.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Серверы не найдены для ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsRu { 'watchTogether.recentRooms' => 'Недавние комнаты', 'watchTogether.renameRoom' => 'Переименовать комнату', 'watchTogether.removeRoom' => 'Удалить', + 'watchTogether.guestSwitchUnavailable' => 'Не удалось переключиться — сервер недоступен для синхронизации', + 'watchTogether.guestSwitchFailed' => 'Не удалось переключиться — содержимое не найдено на этом сервере', 'downloads.title' => 'Загрузки', 'downloads.manage' => 'Управление', 'downloads.tvShows' => 'Сериалы', @@ -2291,6 +2537,12 @@ extension on TranslationsRu { 'downloads.editSyncFilter' => 'Фильтр синхронизации', 'downloads.syncAllItems' => 'Синхронизация всех элементов', 'downloads.syncUnwatchedItems' => 'Синхронизация непросмотренных элементов', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Сервер: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Доступен', + 'downloads.syncRuleOffline' => 'Офлайн', + 'downloads.syncRuleSignInRequired' => 'Требуется вход', + 'downloads.syncRuleNotAvailableForProfile' => 'Недоступно для текущего профиля', + 'downloads.syncRuleUnknownServer' => 'Неизвестный сервер', 'downloads.syncRuleListCreated' => 'Правило синхронизации создано', 'shaders.title' => 'Шейдеры', 'shaders.noShaderDescription' => 'Без улучшения видео', @@ -2484,6 +2736,8 @@ extension on TranslationsRu { 'trakt.disconnectConfirmBody' => 'Plezy перестанет отправлять события воспроизведения в Trakt. Вы можете подключиться снова в любое время.', 'trakt.scrobble' => 'Скробблинг в реальном времени', 'trakt.scrobbleDescription' => 'Отправлять события воспроизведения, паузы и остановки в Trakt во время просмотра.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Синхронизация статуса просмотра', 'trakt.watchedSyncDescription' => 'Когда вы отмечаете элементы как просмотренные в Plezy, они отмечаются и в Trakt.', 'trackers.title' => 'Трекеры', @@ -2519,6 +2773,38 @@ extension on TranslationsRu { 'trackers.libraryFilter.modeHintWhitelist' => 'Синхронизировать только библиотеки, отмеченные ниже.', 'trackers.libraryFilter.libraries' => 'Библиотеки', 'trackers.libraryFilter.noLibraries' => 'Библиотеки недоступны', + 'addServer.addJellyfinTitle' => 'Добавить сервер Jellyfin', + 'addServer.jellyfinUrlIntro' => 'Введите URL вашего сервера Jellyfin — например, `https://jellyfin.example.com`. Войти можно после.', + 'addServer.serverUrl' => 'URL сервера', + 'addServer.findServer' => 'Найти сервер', + 'addServer.username' => 'Имя пользователя', + 'addServer.password' => 'Пароль', + 'addServer.signIn' => 'Войти', + 'addServer.change' => 'Изменить', + 'addServer.required' => 'Обязательно', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Не удалось связаться с сервером: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Не удалось войти: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect не удался: ${error}', + 'addServer.addPlexTitle' => 'Войти через Plex', + 'addServer.plexAuthIntro' => 'Выберите способ входа в Plex. Поток в браузере открывает plex.tv, где вы подтверждаете подключение; QR-вариант удобен для TV или удалённых устройств.', + 'addServer.plexQRPrompt' => 'Отсканируйте этот QR-код, чтобы войти.', + 'addServer.waitingForPlexConfirmation' => 'Ожидание подтверждения от plex.tv…', + 'addServer.pinExpired' => 'Срок действия PIN истёк до входа. Попробуйте снова.', + 'addServer.duplicatePlexAccount' => 'Это устройство уже подключено к учётной записи Plex. Выйдите в настройках, чтобы сменить учётную запись.', + 'addServer.failedToRegisterAccount' => ({required Object error}) => 'Не удалось зарегистрировать учётную запись: ${error}', + 'addServer.enterJellyfinUrlError' => 'Введите URL вашего сервера Jellyfin', + 'addServer.addConnectionTitle' => 'Добавить подключение', + 'addServer.addConnectionTitleScoped' => ({required Object name}) => 'Добавить в ${name}', + 'addServer.addConnectionIntroGlobal' => 'Добавьте ещё один медиасервер. Можно сочетать учётные записи Plex и серверы Jellyfin — элементы со всех подключённых бэкендов появятся вместе на главном экране.', + 'addServer.addConnectionIntroScoped' => 'Добавьте новый сервер или одолжите из другого профиля.', + 'addServer.signInWithPlexCard' => 'Войти через Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Авторизуйте это устройство в вашей учётной записи Plex. Серверы, общие с учётной записью, добавятся автоматически.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Авторизуйте новую учётную запись Plex. Её Home-пользователи появятся как профили.', + 'addServer.connectToJellyfinCard' => 'Подключиться к Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Введите URL сервера Jellyfin и войдите с именем пользователя и паролем (Quick Connect — скоро).', + 'addServer.connectToJellyfinCardSubtitleScoped' => ({required Object name}) => 'Войдите на сервер Jellyfin. Привязывается к ${name}.', + 'addServer.borrowFromAnotherProfile' => 'Одолжить из другого профиля', + 'addServer.borrowFromAnotherProfileSubtitle' => 'Повторно используйте подключение, уже привязанное к другому профилю. PIN-защищённые исходные профили запрашивают PIN.', _ => null, }; } diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 208c0852..17769f8f 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsSv with BaseTranslations implements Translations { +class TranslationsSv extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsSv({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsSv with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsSv with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsSv _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsSv with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingSv subtitlingStyling = _TranslationsSubtitlingStylingSv._(_root); @override late final _TranslationsMpvConfigSv mpvConfig = _TranslationsMpvConfigSv._(_root); @override late final _TranslationsDialogSv dialog = _TranslationsDialogSv._(_root); + @override late final _TranslationsProfilesSv profiles = _TranslationsProfilesSv._(_root); + @override late final _TranslationsConnectionsSv connections = _TranslationsConnectionsSv._(_root); @override late final _TranslationsDiscoverSv discover = _TranslationsDiscoverSv._(_root); @override late final _TranslationsErrorsSv errors = _TranslationsErrorsSv._(_root); @override late final _TranslationsLibrariesSv libraries = _TranslationsLibrariesSv._(_root); @@ -78,11 +82,12 @@ class TranslationsSv with BaseTranslations implements T @override late final _TranslationsServerTasksSv serverTasks = _TranslationsServerTasksSv._(_root); @override late final _TranslationsTraktSv trakt = _TranslationsTraktSv._(_root); @override late final _TranslationsTrackersSv trackers = _TranslationsTrackersSv._(_root); + @override late final _TranslationsAddServerSv addServer = _TranslationsAddServerSv._(_root); } // Path: app -class _TranslationsAppSv implements TranslationsAppEn { - _TranslationsAppSv._(this._root); +class _TranslationsAppSv extends TranslationsAppEn { + _TranslationsAppSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppSv implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthSv implements TranslationsAuthEn { - _TranslationsAuthSv._(this._root); +class _TranslationsAuthSv extends TranslationsAuthEn { + _TranslationsAuthSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field // Translations + @override String get signIn => 'Logga in'; @override String get signInWithPlex => 'Logga in med Plex'; @override String get showQRCode => 'Visa QR-kod'; @override String get authenticate => 'Autentisera'; @@ -104,11 +110,19 @@ class _TranslationsAuthSv implements TranslationsAuthEn { @override String get scanQRToSignIn => 'Skanna QR-koden för att logga in'; @override String get waitingForAuth => 'Väntar på autentisering...\nVänligen slutför inloggning i 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 quickConnectCode => 'Quick Connect-kod'; + @override String get quickConnectInstructions => 'Öppna din Jellyfin-server i en webbläsare, logga in och välj Quick Connect i användarmenyn. Ange denna kod för att godkänna inloggningen.'; + @override String get quickConnectWaiting => 'Väntar på godkännande…'; + @override String get quickConnectCancel => 'Avbryt'; + @override String get quickConnectExpired => 'Quick Connect-koden gick ut innan den godkändes. Försök igen.'; } // Path: common -class _TranslationsCommonSv implements TranslationsCommonEn { - _TranslationsCommonSv._(this._root); +class _TranslationsCommonSv extends TranslationsCommonEn { + _TranslationsCommonSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonSv implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensSv implements TranslationsScreensEn { - _TranslationsScreensSv._(this._root); +class _TranslationsScreensSv extends TranslationsScreensEn { + _TranslationsScreensSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensSv implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateSv implements TranslationsUpdateEn { - _TranslationsUpdateSv._(this._root); +class _TranslationsUpdateSv extends TranslationsUpdateEn { + _TranslationsUpdateSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateSv implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsSv implements TranslationsSettingsEn { - _TranslationsSettingsSv._(this._root); +class _TranslationsSettingsSv extends TranslationsSettingsEn { + _TranslationsSettingsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn { @override String get gridView => 'Rutnät'; @override String get listView => 'Lista'; @override String get showHeroSection => 'Visa hjältesektion'; - @override String get useGlobalHubs => 'Använd Plex hem-layout'; - @override String get useGlobalHubsDescription => 'Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => 'Visa servernamn på hubbar'; @override String get showServerNameOnHubsDescription => 'Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.'; @override String get groupLibrariesByServer => 'Gruppera bibliotek efter server'; - @override String get groupLibrariesByServerDescription => 'Visa en rubrik för varje Plex-server i sidofältet när du är ansluten till flera servrar.'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => 'Håll sidofältet alltid öppet'; @override String get alwaysKeepSidebarOpenDescription => 'Sidofältet förblir expanderat och innehållsytan anpassas'; @override String get showUnwatchedCount => 'Visa antal osedda'; @@ -385,8 +399,8 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchSv implements TranslationsSearchEn { - _TranslationsSearchSv._(this._root); +class _TranslationsSearchSv extends TranslationsSearchEn { + _TranslationsSearchSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchSv implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysSv implements TranslationsHotkeysEn { - _TranslationsHotkeysSv._(this._root); +class _TranslationsHotkeysSv extends TranslationsHotkeysEn { + _TranslationsHotkeysSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysSv implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoSv implements TranslationsFileInfoEn { - _TranslationsFileInfoSv._(this._root); +class _TranslationsFileInfoSv extends TranslationsFileInfoEn { + _TranslationsFileInfoSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoSv implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuSv implements TranslationsMediaMenuEn { - _TranslationsMediaMenuSv._(this._root); +class _TranslationsMediaMenuSv extends TranslationsMediaMenuEn { + _TranslationsMediaMenuSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuSv implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilitySv implements TranslationsAccessibilityEn { - _TranslationsAccessibilitySv._(this._root); +class _TranslationsAccessibilitySv extends TranslationsAccessibilityEn { + _TranslationsAccessibilitySv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilitySv implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsSv implements TranslationsTooltipsEn { - _TranslationsTooltipsSv._(this._root); +class _TranslationsTooltipsSv extends TranslationsTooltipsEn { + _TranslationsTooltipsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsSv implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn { - _TranslationsVideoControlsSv._(this._root); +class _TranslationsVideoControlsSv extends TranslationsVideoControlsEn { + _TranslationsVideoControlsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsSv implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusSv implements TranslationsUserStatusEn { - _TranslationsUserStatusSv._(this._root); +class _TranslationsUserStatusSv extends TranslationsUserStatusEn { + _TranslationsUserStatusSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusSv implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesSv implements TranslationsMessagesEn { - _TranslationsMessagesSv._(this._root); +class _TranslationsMessagesSv extends TranslationsMessagesEn { + _TranslationsMessagesSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesSv implements TranslationsMessagesEn { @override String get musicNotSupported => 'Musikuppspelning stöds inte ännu'; @override String get noDescriptionAvailable => 'Ingen beskrivning tillgänglig'; @override String get noProfilesAvailable => 'Inga profiler tillgängliga'; - @override String get contactAdminForProfiles => 'Kontakta din Plex-administratör för att lägga till profiler'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => 'Kan inte avgöra biblioteksavdelningen för detta objekt'; @override String get logsCleared => 'Loggar rensade'; @override String get logsCopied => 'Loggar kopierade till urklipp'; @@ -636,8 +650,8 @@ class _TranslationsMessagesSv implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingSv implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingSv._(this._root); +class _TranslationsSubtitlingStylingSv extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingSv implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigSv implements TranslationsMpvConfigEn { - _TranslationsMpvConfigSv._(this._root); +class _TranslationsMpvConfigSv extends TranslationsMpvConfigEn { + _TranslationsMpvConfigSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigSv implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogSv implements TranslationsDialogEn { - _TranslationsDialogSv._(this._root); +class _TranslationsDialogSv extends TranslationsDialogEn { + _TranslationsDialogSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogSv implements TranslationsDialogEn { @override String get confirmAction => 'Bekräfta åtgärd'; } +// Path: profiles +class _TranslationsProfilesSv extends TranslationsProfilesEn { + _TranslationsProfilesSv._(TranslationsSv root) : this._root = root, super.internal(root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => 'Lägg till Plezy-profil'; + @override String get switchingProfile => 'Byter profil…'; + @override String get deleteThisProfileTitle => 'Ta bort denna profil?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} tas bort. Anslutningar påverkas inte.'; + @override String get active => 'Aktiv'; + @override String get manage => 'Hantera'; + @override String get delete => 'Ta bort'; + @override String get signOut => 'Logga ut'; + @override String get signOutPlexTitle => 'Logga ut från Plex?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} och alla Plex Home-användare på det här kontot tas bort från den här enheten. Du kan logga in igen när som helst.'; + @override String get signedOutPlex => 'Utloggad från Plex.'; + @override String get signOutFailed => 'Utloggningen misslyckades.'; + @override String get sectionTitle => 'Profiler'; + @override String get summarySingle => 'Lägg till profiler för att blanda hanterade användare och lokala identiteter'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} profiler · aktiv: ${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} profiler'; + @override String get removeConnectionTitle => 'Ta bort anslutning?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} förlorar tillgång till ${connectionLabel}. Anslutningen är fortfarande tillgänglig för andra profiler.'; + @override String get deleteProfileTitle => 'Ta bort profil?'; + @override String deleteProfileMessage({required Object displayName}) => 'Detta tar bort ${displayName} och alla dess anslutningar från den här enheten. De underliggande Plex/Jellyfin-servrarna påverkas inte.'; + @override String get profileNameLabel => 'Profilnamn'; + @override String get pinProtectionLabel => 'PIN-skydd'; + @override String get pinManagedByPlex => 'PIN hanteras av Plex. Redigera på plex.tv.'; + @override String get noPinSetEditOnPlex => 'Ingen PIN angiven. För att kräva en, redigera Home-användaren på plex.tv.'; + @override String get setPin => 'Ange PIN'; + @override String get connectionsLabel => 'Anslutningar'; + @override String get add => 'Lägg till'; + @override String get deleteProfileButton => 'Ta bort profil'; + @override String get noConnectionsHint => 'Inga anslutningar — lägg till en för att använda den här profilen.'; + @override String get plexHomeAccount => 'Plex Home-konto'; + @override String get connectionDefault => 'Standard'; + @override String get makeDefault => 'Gör till standard'; + @override String get removeConnection => 'Ta bort'; + @override String borrowAddTo({required Object displayName}) => 'Lägg till i ${displayName}'; + @override String get borrowExplain => 'Låna en anslutning från en annan profil. PIN-skyddade källprofiler ber om PIN före delning.'; + @override String get borrowEmpty => 'Inget att låna ännu.'; + @override String get borrowEmptySubtitle => 'Anslut först ett Plex-konto eller en Jellyfin-server till en annan profil och kom sedan tillbaka hit.'; + @override String get newProfile => 'Ny profil'; + @override String get profileNameHint => 't.ex. Gäster, Barn, Familjerum'; + @override String get pinProtectionOptional => 'PIN-skydd (valfritt)'; + @override String get pinExplain => '4-siffrig PIN krävs för att växla till den här profilen. Mjuk barriär — vem som helst som kan rensa appdata kan kringgå den.'; + @override String get continueButton => 'Fortsätt'; + @override String get pinsDontMatch => 'PIN-koderna stämmer inte överens'; +} + +// Path: connections +class _TranslationsConnectionsSv extends TranslationsConnectionsEn { + _TranslationsConnectionsSv._(TranslationsSv root) : this._root = root, super.internal(root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => 'Anslutningar'; + @override String get addConnection => 'Lägg till anslutning'; + @override String get addConnectionSubtitleNoProfile => 'Logga in med Plex eller anslut en Jellyfin-server'; + @override String addConnectionSubtitleScoped({required Object displayName}) => 'Lägg till ${displayName} — Plex-konto, Jellyfin-server eller låna från en annan profil'; + @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'; +} + // Path: discover -class _TranslationsDiscoverSv implements TranslationsDiscoverEn { - _TranslationsDiscoverSv._(this._root); +class _TranslationsDiscoverSv extends TranslationsDiscoverEn { + _TranslationsDiscoverSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverSv implements TranslationsDiscoverEn { @override String get noContentAvailable => 'Inget innehåll tillgängligt'; @override String get addMediaToLibraries => 'Lägg till media till dina bibliotek'; @override String get continueWatching => 'Fortsätt titta'; + @override String get nextUp => 'Nästa'; + @override String get recentlyAdded => 'Nyligen tillagda'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => 'Översikt'; @override String get cast => 'Rollbesättning'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverSv implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsSv implements TranslationsErrorsEn { - _TranslationsErrorsSv._(this._root); +class _TranslationsErrorsSv extends TranslationsErrorsEn { + _TranslationsErrorsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => 'Sökning misslyckades: ${error}'; @override String connectionTimeout({required Object context}) => 'Anslutnings-timeout vid laddning ${context}'; - @override String get connectionFailed => 'Kan inte ansluta till Plex-server'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => 'Misslyckades att ladda ${context}: ${error}'; @override String get noClientAvailable => 'Ingen klient tillgänglig'; @override String authenticationFailed({required Object error}) => 'Autentisering misslyckades: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsSv implements TranslationsErrorsEn { @override String get invalidToken => 'Ogiltig token'; @override String failedToVerifyToken({required Object error}) => 'Misslyckades att verifiera token: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => 'Misslyckades att byta till ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => 'Misslyckades att ta bort ${displayName}'; + @override String get failedToRate => 'Det gick inte att uppdatera betyget'; } // Path: libraries -class _TranslationsLibrariesSv implements TranslationsLibrariesEn { - _TranslationsLibrariesSv._(this._root); +class _TranslationsLibrariesSv extends TranslationsLibrariesEn { + _TranslationsLibrariesSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesSv implements TranslationsLibrariesEn { @override String get folders => 'mappar'; @override late final _TranslationsLibrariesTabsSv tabs = _TranslationsLibrariesTabsSv._(_root); @override late final _TranslationsLibrariesGroupingsSv groupings = _TranslationsLibrariesGroupingsSv._(_root); + @override late final _TranslationsLibrariesFilterCategoriesSv filterCategories = _TranslationsLibrariesFilterCategoriesSv._(_root); + @override late final _TranslationsLibrariesSortLabelsSv sortLabels = _TranslationsLibrariesSortLabelsSv._(_root); } // Path: about -class _TranslationsAboutSv implements TranslationsAboutEn { - _TranslationsAboutSv._(this._root); +class _TranslationsAboutSv extends TranslationsAboutEn { + _TranslationsAboutSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutSv implements TranslationsAboutEn { @override String get title => 'Om'; @override String get openSourceLicenses => 'Öppen källkod-licenser'; @override String versionLabel({required Object version}) => 'Version ${version}'; - @override String get appDescription => 'En vacker Plex-klient för Flutter'; + @override String get appDescription => 'En vacker Plex- och Jellyfin-klient för Flutter'; @override String get viewLicensesDescription => 'Visa licenser för tredjepartsbibliotek'; } // Path: serverSelection -class _TranslationsServerSelectionSv implements TranslationsServerSelectionEn { - _TranslationsServerSelectionSv._(this._root); +class _TranslationsServerSelectionSv extends TranslationsServerSelectionEn { + _TranslationsServerSelectionSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionSv implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailSv implements TranslationsHubDetailEn { - _TranslationsHubDetailSv._(this._root); +class _TranslationsHubDetailSv extends TranslationsHubDetailEn { + _TranslationsHubDetailSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailSv implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsSv implements TranslationsLogsEn { - _TranslationsLogsSv._(this._root); +class _TranslationsLogsSv extends TranslationsLogsEn { + _TranslationsLogsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsSv implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesSv implements TranslationsLicensesEn { - _TranslationsLicensesSv._(this._root); +class _TranslationsLicensesSv extends TranslationsLicensesEn { + _TranslationsLicensesSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesSv implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationSv implements TranslationsNavigationEn { - _TranslationsNavigationSv._(this._root); +class _TranslationsNavigationSv extends TranslationsNavigationEn { + _TranslationsNavigationSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationSv implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvSv implements TranslationsLiveTvEn { - _TranslationsLiveTvSv._(this._root); +class _TranslationsLiveTvSv extends TranslationsLiveTvEn { + _TranslationsLiveTvSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvSv implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsSv implements TranslationsCollectionsEn { - _TranslationsCollectionsSv._(this._root); +class _TranslationsCollectionsSv extends TranslationsCollectionsEn { + _TranslationsCollectionsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsSv implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsSv implements TranslationsPlaylistsEn { - _TranslationsPlaylistsSv._(this._root); +class _TranslationsPlaylistsSv extends TranslationsPlaylistsEn { + _TranslationsPlaylistsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsSv implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherSv implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherSv._(this._root); +class _TranslationsWatchTogetherSv extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherSv implements TranslationsWatchTogetherEn { @override String get recentRooms => 'Senaste rum'; @override String get renameRoom => 'Byt namn på rum'; @override String get removeRoom => 'Ta bort'; + @override String get guestSwitchUnavailable => 'Kunde inte byta — server inte tillgänglig för synkronisering'; + @override String get guestSwitchFailed => 'Kunde inte byta — innehåll hittades inte på denna server'; } // Path: downloads -class _TranslationsDownloadsSv implements TranslationsDownloadsEn { - _TranslationsDownloadsSv._(this._root); +class _TranslationsDownloadsSv extends TranslationsDownloadsEn { + _TranslationsDownloadsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsSv implements TranslationsDownloadsEn { @override String get editSyncFilter => 'Synkroniseringsfilter'; @override String get syncAllItems => 'Synkroniserar alla objekt'; @override String get syncUnwatchedItems => 'Synkroniserar osedda objekt'; + @override String syncRuleServerContext({required Object server, required Object status}) => 'Server: ${server} • ${status}'; + @override String get syncRuleAvailable => 'Tillgänglig'; + @override String get syncRuleOffline => 'Offline'; + @override String get syncRuleSignInRequired => 'Inloggning krävs'; + @override String get syncRuleNotAvailableForProfile => 'Inte tillgänglig för aktuell profil'; + @override String get syncRuleUnknownServer => 'Okänd server'; @override String get syncRuleListCreated => 'Synkroniseringsregel skapad'; } // Path: shaders -class _TranslationsShadersSv implements TranslationsShadersEn { - _TranslationsShadersSv._(this._root); +class _TranslationsShadersSv extends TranslationsShadersEn { + _TranslationsShadersSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersSv implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteSv implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteSv._(this._root); +class _TranslationsCompanionRemoteSv extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteSv implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsSv implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsSv._(this._root); +class _TranslationsVideoSettingsSv extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsSv implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerSv implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerSv._(this._root); +class _TranslationsExternalPlayerSv extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerSv implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditSv implements TranslationsMetadataEditEn { - _TranslationsMetadataEditSv._(this._root); +class _TranslationsMetadataEditSv extends TranslationsMetadataEditEn { + _TranslationsMetadataEditSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditSv implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenSv implements TranslationsMatchScreenEn { - _TranslationsMatchScreenSv._(this._root); +class _TranslationsMatchScreenSv extends TranslationsMatchScreenEn { + _TranslationsMatchScreenSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenSv implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksSv implements TranslationsServerTasksEn { - _TranslationsServerTasksSv._(this._root); +class _TranslationsServerTasksSv extends TranslationsServerTasksEn { + _TranslationsServerTasksSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksSv implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktSv implements TranslationsTraktEn { - _TranslationsTraktSv._(this._root); +class _TranslationsTraktSv extends TranslationsTraktEn { + _TranslationsTraktSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktSv implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersSv implements TranslationsTrackersEn { - _TranslationsTrackersSv._(this._root); +class _TranslationsTrackersSv extends TranslationsTrackersEn { + _TranslationsTrackersSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersSv implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterSv libraryFilter = _TranslationsTrackersLibraryFilterSv._(_root); } +// Path: addServer +class _TranslationsAddServerSv extends TranslationsAddServerEn { + _TranslationsAddServerSv._(TranslationsSv root) : this._root = root, super.internal(root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => 'Lägg till Jellyfin-server'; + @override String get jellyfinUrlIntro => 'Ange URL till din Jellyfin-server — t.ex. `https://jellyfin.example.com`. Du kan logga in efteråt.'; + @override String get serverUrl => 'Server-URL'; + @override String get findServer => 'Hitta server'; + @override String get username => 'Användarnamn'; + @override String get password => 'Lösenord'; + @override String get signIn => 'Logga in'; + @override String get change => 'Ändra'; + @override String get required => 'Krävs'; + @override String couldNotReachServer({required Object error}) => 'Kunde inte nå servern: ${error}'; + @override String signInFailed({required Object error}) => 'Inloggning misslyckades: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect misslyckades: ${error}'; + @override String get addPlexTitle => 'Logga in med Plex'; + @override String get plexAuthIntro => 'Välj hur du vill logga in på Plex. Webbläsarflödet öppnar plex.tv där du bekräftar anslutningen; QR-alternativet är praktiskt för TV / fjärrenheter.'; + @override String get plexQRPrompt => 'Skanna denna QR-kod för att logga in.'; + @override String get waitingForPlexConfirmation => 'Väntar på att plex.tv ska bekräfta inloggningen…'; + @override String get pinExpired => 'PIN-koden gick ut innan inloggning. Försök igen.'; + @override String get duplicatePlexAccount => 'Den här enheten är redan inloggad på ett Plex-konto. Logga ut från inställningarna för att byta konto.'; + @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 addConnectionIntroGlobal => 'Lägg till ytterligare en medieserver. Du kan blanda Plex-konton och Jellyfin-servrar — innehåll från alla anslutna backends visas tillsammans på startskärmen.'; + @override String get addConnectionIntroScoped => 'Lägg till en ny server, eller låna en från en annan profil.'; + @override String get signInWithPlexCard => 'Logga in med Plex'; + @override String get signInWithPlexCardSubtitle => 'Auktorisera den här enheten mot ditt Plex-konto. Servrar delade med kontot följer med automatiskt.'; + @override String get signInWithPlexCardSubtitleScoped => 'Auktorisera ett nytt Plex-konto. Dess Home-användare visas som profiler.'; + @override String get connectToJellyfinCard => 'Anslut till Jellyfin'; + @override String get connectToJellyfinCardSubtitle => 'Ange URL till din Jellyfin-server och logga in med användarnamn + lösenord (Quick Connect kommer snart).'; + @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 anslutning som redan är kopplad till en annan profil. PIN-skyddade källprofiler ber om PIN.'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsSv implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsSv._(this._root); +class _TranslationsHotkeysActionsSv extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsSv implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsSv implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsSv._(this._root); +class _TranslationsVideoControlsPipErrorsSv extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsSv implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsSv implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsSv._(this._root); +class _TranslationsLibrariesTabsSv extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsSv implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsSv implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsSv._(this._root); +class _TranslationsLibrariesGroupingsSv extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsSv implements TranslationsLibrariesGrouping @override String get folders => 'Mappar'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesSv extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesSv._(TranslationsSv root) : this._root = root, super.internal(root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String get genre => 'Genre'; + @override String get year => 'År'; + @override String get contentRating => 'Åldersgräns'; + @override String get tag => 'Tagg'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsSv extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsSv._(TranslationsSv root) : this._root = root, super.internal(root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Titel'; + @override String get dateAdded => 'Tillagd'; + @override String get releaseDate => 'Releasedatum'; + @override String get rating => 'Betyg'; + @override String get lastPlayed => 'Senast spelad'; + @override String get playCount => 'Antal spelningar'; + @override String get random => 'Slumpmässigt'; + @override String get dateShared => 'Delningsdatum'; + @override String get latestEpisodeAirDate => 'Senaste avsnittets sändningsdatum'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionSv implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionSv._(this._root); +class _TranslationsCompanionRemoteSessionSv extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionSv implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingSv implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingSv._(this._root); +class _TranslationsCompanionRemotePairingSv extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingSv implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteSv implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteSv._(this._root); +class _TranslationsCompanionRemoteRemoteSv extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteSv implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesSv implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesSv._(this._root); +class _TranslationsTrackersServicesSv extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesSv implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeSv implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeSv._(this._root); +class _TranslationsTrackersDeviceCodeSv extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeSv implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxySv implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxySv._(this._root); +class _TranslationsTrackersOauthProxySv extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxySv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxySv implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterSv implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterSv._(this._root); +class _TranslationsTrackersLibraryFilterSv extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterSv._(TranslationsSv root) : this._root = root, super.internal(root); final TranslationsSv _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsSv { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => 'Logga in', 'auth.signInWithPlex' => 'Logga in med Plex', 'auth.showQRCode' => 'Visa QR-kod', 'auth.authenticate' => 'Autentisera', @@ -1550,6 +1719,14 @@ extension on TranslationsSv { 'auth.scanQRToSignIn' => 'Skanna QR-koden för att logga in', 'auth.waitingForAuth' => 'Väntar på autentisering...\nVänligen slutför inloggning i din webbläsare.', 'auth.useBrowser' => 'Använd webbläsare', + 'auth.or' => 'eller', + 'auth.connectToJellyfin' => 'Anslut till Jellyfin', + 'auth.useQuickConnect' => 'Använd Quick Connect', + 'auth.quickConnectCode' => 'Quick Connect-kod', + 'auth.quickConnectInstructions' => 'Öppna din Jellyfin-server i en webbläsare, logga in och välj Quick Connect i användarmenyn. Ange denna kod för att godkänna inloggningen.', + 'auth.quickConnectWaiting' => 'Väntar på godkännande…', + 'auth.quickConnectCancel' => 'Avbryt', + 'auth.quickConnectExpired' => 'Quick Connect-koden gick ut innan den godkändes. Försök igen.', 'common.cancel' => 'Avbryt', 'common.save' => 'Spara', 'common.close' => 'Stäng', @@ -1636,12 +1813,12 @@ extension on TranslationsSv { 'settings.gridView' => 'Rutnät', 'settings.listView' => 'Lista', 'settings.showHeroSection' => 'Visa hjältesektion', - 'settings.useGlobalHubs' => 'Använd Plex hem-layout', - 'settings.useGlobalHubsDescription' => 'Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => 'Visa servernamn på hubbar', 'settings.showServerNameOnHubsDescription' => 'Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.', 'settings.groupLibrariesByServer' => 'Gruppera bibliotek efter server', - 'settings.groupLibrariesByServerDescription' => 'Visa en rubrik för varje Plex-server i sidofältet när du är ansluten till flera servrar.', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => 'Håll sidofältet alltid öppet', 'settings.alwaysKeepSidebarOpenDescription' => 'Sidofältet förblir expanderat och innehållsytan anpassas', 'settings.showUnwatchedCount' => 'Visa antal osedda', @@ -1962,7 +2139,7 @@ extension on TranslationsSv { 'messages.musicNotSupported' => 'Musikuppspelning stöds inte ännu', 'messages.noDescriptionAvailable' => 'Ingen beskrivning tillgänglig', 'messages.noProfilesAvailable' => 'Inga profiler tillgängliga', - 'messages.contactAdminForProfiles' => 'Kontakta din Plex-administratör för att lägga till profiler', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => 'Kan inte avgöra biblioteksavdelningen för detta objekt', 'messages.logsCleared' => 'Loggar rensade', 'messages.logsCopied' => 'Loggar kopierade till urklipp', @@ -2016,11 +2193,65 @@ extension on TranslationsSv { 'mpvConfig.confirmDeletePreset' => 'Är du säker på att du vill ta bort detta förval?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => 'Bekräfta åtgärd', + 'profiles.addPlezyProfile' => 'Lägg till Plezy-profil', + 'profiles.switchingProfile' => 'Byter profil…', + 'profiles.deleteThisProfileTitle' => 'Ta bort denna profil?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} tas bort. Anslutningar påverkas inte.', + 'profiles.active' => 'Aktiv', + 'profiles.manage' => 'Hantera', + 'profiles.delete' => 'Ta bort', + 'profiles.signOut' => 'Logga ut', + 'profiles.signOutPlexTitle' => 'Logga ut från Plex?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} och alla Plex Home-användare på det här kontot tas bort från den här enheten. Du kan logga in igen när som helst.', + 'profiles.signedOutPlex' => 'Utloggad från Plex.', + 'profiles.signOutFailed' => 'Utloggningen misslyckades.', + 'profiles.sectionTitle' => 'Profiler', + 'profiles.summarySingle' => 'Lägg till profiler för att blanda hanterade användare och lokala identiteter', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} profiler · aktiv: ${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} profiler', + 'profiles.removeConnectionTitle' => 'Ta bort anslutning?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} förlorar tillgång till ${connectionLabel}. Anslutningen är fortfarande tillgänglig för andra profiler.', + 'profiles.deleteProfileTitle' => 'Ta bort profil?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Detta tar bort ${displayName} och alla dess anslutningar från den här enheten. De underliggande Plex/Jellyfin-servrarna påverkas inte.', + 'profiles.profileNameLabel' => 'Profilnamn', + 'profiles.pinProtectionLabel' => 'PIN-skydd', + 'profiles.pinManagedByPlex' => 'PIN hanteras av Plex. Redigera på plex.tv.', + 'profiles.noPinSetEditOnPlex' => 'Ingen PIN angiven. För att kräva en, redigera Home-användaren på plex.tv.', + 'profiles.setPin' => 'Ange PIN', + 'profiles.connectionsLabel' => 'Anslutningar', + 'profiles.add' => 'Lägg till', + 'profiles.deleteProfileButton' => 'Ta bort profil', + 'profiles.noConnectionsHint' => 'Inga anslutningar — lägg till en för att använda den här profilen.', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Plex Home-konto', + 'profiles.connectionDefault' => 'Standard', + 'profiles.makeDefault' => 'Gör till standard', + 'profiles.removeConnection' => 'Ta bort', + 'profiles.borrowAddTo' => ({required Object displayName}) => 'Lägg till i ${displayName}', + 'profiles.borrowExplain' => 'Låna en anslutning från en annan profil. PIN-skyddade källprofiler ber om PIN före delning.', + 'profiles.borrowEmpty' => 'Inget att låna ännu.', + 'profiles.borrowEmptySubtitle' => 'Anslut först ett Plex-konto eller en Jellyfin-server till en annan profil och kom sedan tillbaka hit.', + 'profiles.newProfile' => 'Ny profil', + 'profiles.profileNameHint' => 't.ex. Gäster, Barn, Familjerum', + 'profiles.pinProtectionOptional' => 'PIN-skydd (valfritt)', + 'profiles.pinExplain' => '4-siffrig PIN krävs för att växla till den här profilen. Mjuk barriär — vem som helst som kan rensa appdata kan kringgå den.', + 'profiles.continueButton' => 'Fortsätt', + 'profiles.pinsDontMatch' => 'PIN-koderna stämmer inte överens', + 'connections.sectionTitle' => 'Anslutningar', + 'connections.addConnection' => 'Lägg till anslutning', + 'connections.addConnectionSubtitleNoProfile' => 'Logga in med Plex eller anslut en Jellyfin-server', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => 'Lägg till ${displayName} — Plex-konto, Jellyfin-server eller låna från en annan profil', + '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', 'discover.title' => 'Upptäck', 'discover.switchProfile' => 'Byt profil', 'discover.noContentAvailable' => 'Inget innehåll tillgängligt', 'discover.addMediaToLibraries' => 'Lägg till media till dina bibliotek', 'discover.continueWatching' => 'Fortsätt titta', + 'discover.nextUp' => 'Nästa', + 'discover.recentlyAdded' => 'Nyligen tillagda', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Översikt', 'discover.cast' => 'Rollbesättning', @@ -2032,7 +2263,7 @@ extension on TranslationsSv { 'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min kvar', 'errors.searchFailed' => ({required Object error}) => 'Sökning misslyckades: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Anslutnings-timeout vid laddning ${context}', - 'errors.connectionFailed' => 'Kan inte ansluta till Plex-server', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Misslyckades att ladda ${context}: ${error}', 'errors.noClientAvailable' => 'Ingen klient tillgänglig', 'errors.authenticationFailed' => ({required Object error}) => 'Autentisering misslyckades: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsSv { 'errors.invalidToken' => 'Ogiltig token', 'errors.failedToVerifyToken' => ({required Object error}) => 'Misslyckades att verifiera token: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => 'Misslyckades att byta till ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => 'Misslyckades att ta bort ${displayName}', + 'errors.failedToRate' => 'Det gick inte att uppdatera betyget', 'libraries.title' => 'Bibliotek', 'libraries.scanLibraryFiles' => 'Skanna biblioteksfiler', 'libraries.scanLibrary' => 'Skanna bibliotek', @@ -2054,8 +2287,6 @@ extension on TranslationsSv { 'libraries.analyzing' => ({required Object title}) => 'Analyserar "${title}"...', 'libraries.analysisStarted' => ({required Object title}) => 'Analys startad för "${title}"', 'libraries.failedToAnalyze' => ({required Object error}) => 'Misslyckades att analysera bibliotek: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => 'Inga bibliotek hittades', 'libraries.allLibrariesHidden' => 'Alla bibliotek är dolda', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Dolda bibliotek (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsSv { 'libraries.groupings.seasons' => 'Säsonger', 'libraries.groupings.episodes' => 'Avsnitt', 'libraries.groupings.folders' => 'Mappar', + 'libraries.filterCategories.genre' => 'Genre', + 'libraries.filterCategories.year' => 'År', + 'libraries.filterCategories.contentRating' => 'Åldersgräns', + 'libraries.filterCategories.tag' => 'Tagg', + 'libraries.sortLabels.title' => 'Titel', + 'libraries.sortLabels.dateAdded' => 'Tillagd', + 'libraries.sortLabels.releaseDate' => 'Releasedatum', + 'libraries.sortLabels.rating' => 'Betyg', + 'libraries.sortLabels.lastPlayed' => 'Senast spelad', + 'libraries.sortLabels.playCount' => 'Antal spelningar', + 'libraries.sortLabels.random' => 'Slumpmässigt', + 'libraries.sortLabels.dateShared' => 'Delningsdatum', + 'libraries.sortLabels.latestEpisodeAirDate' => 'Senaste avsnittets sändningsdatum', 'about.title' => 'Om', 'about.openSourceLicenses' => 'Öppen källkod-licenser', 'about.versionLabel' => ({required Object version}) => 'Version ${version}', - 'about.appDescription' => 'En vacker Plex-klient för Flutter', + 'about.appDescription' => 'En vacker Plex- och Jellyfin-klient för Flutter', 'about.viewLicensesDescription' => 'Visa licenser för tredjepartsbibliotek', 'serverSelection.allServerConnectionsFailed' => 'Misslyckades att ansluta till servrar. Kontrollera ditt nätverk och försök igen.', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => 'Inga servrar hittades för ${username} (${email})', @@ -2243,6 +2487,8 @@ extension on TranslationsSv { 'watchTogether.recentRooms' => 'Senaste rum', 'watchTogether.renameRoom' => 'Byt namn på rum', 'watchTogether.removeRoom' => 'Ta bort', + 'watchTogether.guestSwitchUnavailable' => 'Kunde inte byta — server inte tillgänglig för synkronisering', + 'watchTogether.guestSwitchFailed' => 'Kunde inte byta — innehåll hittades inte på denna server', 'downloads.title' => 'Nedladdningar', 'downloads.manage' => 'Hantera', 'downloads.tvShows' => 'TV-serier', @@ -2291,6 +2537,12 @@ extension on TranslationsSv { 'downloads.editSyncFilter' => 'Synkroniseringsfilter', 'downloads.syncAllItems' => 'Synkroniserar alla objekt', 'downloads.syncUnwatchedItems' => 'Synkroniserar osedda objekt', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', + 'downloads.syncRuleAvailable' => 'Tillgänglig', + 'downloads.syncRuleOffline' => 'Offline', + 'downloads.syncRuleSignInRequired' => 'Inloggning krävs', + 'downloads.syncRuleNotAvailableForProfile' => 'Inte tillgänglig för aktuell profil', + 'downloads.syncRuleUnknownServer' => 'Okänd server', 'downloads.syncRuleListCreated' => 'Synkroniseringsregel skapad', 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'Ingen videoförbättring', @@ -2484,6 +2736,8 @@ extension on TranslationsSv { 'trakt.disconnectConfirmBody' => 'Plezy slutar skicka uppspelningshändelser till Trakt. Du kan ansluta igen när som helst.', 'trakt.scrobble' => 'Realtids-scrobbling', 'trakt.scrobbleDescription' => 'Skicka uppspelnings-, paus- och stopphändelser till Trakt under uppspelning.', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => 'Synkronisera tittad-status', 'trakt.watchedSyncDescription' => 'När du markerar något som tittat i Plezy markeras det också på Trakt.', 'trackers.title' => 'Spårare', @@ -2519,6 +2773,38 @@ extension on TranslationsSv { 'trackers.libraryFilter.modeHintWhitelist' => 'Synkronisera endast de bibliotek som markerats nedan.', 'trackers.libraryFilter.libraries' => 'Bibliotek', 'trackers.libraryFilter.noLibraries' => 'Inga bibliotek tillgängliga', + 'addServer.addJellyfinTitle' => 'Lägg till Jellyfin-server', + 'addServer.jellyfinUrlIntro' => 'Ange URL till din Jellyfin-server — t.ex. `https://jellyfin.example.com`. Du kan logga in efteråt.', + 'addServer.serverUrl' => 'Server-URL', + 'addServer.findServer' => 'Hitta server', + 'addServer.username' => 'Användarnamn', + 'addServer.password' => 'Lösenord', + 'addServer.signIn' => 'Logga in', + 'addServer.change' => 'Ändra', + 'addServer.required' => 'Krävs', + 'addServer.couldNotReachServer' => ({required Object error}) => 'Kunde inte nå servern: ${error}', + 'addServer.signInFailed' => ({required Object error}) => 'Inloggning misslyckades: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect misslyckades: ${error}', + 'addServer.addPlexTitle' => 'Logga in med Plex', + 'addServer.plexAuthIntro' => 'Välj hur du vill logga in på Plex. Webbläsarflödet öppnar plex.tv där du bekräftar anslutningen; QR-alternativet är praktiskt för TV / fjärrenheter.', + 'addServer.plexQRPrompt' => 'Skanna denna QR-kod för att logga in.', + 'addServer.waitingForPlexConfirmation' => 'Väntar på att plex.tv ska bekräfta inloggningen…', + 'addServer.pinExpired' => 'PIN-koden gick ut innan inloggning. Försök igen.', + 'addServer.duplicatePlexAccount' => 'Den här enheten är redan inloggad på ett Plex-konto. Logga ut från inställningarna för att byta konto.', + '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.addConnectionIntroGlobal' => 'Lägg till ytterligare en medieserver. Du kan blanda Plex-konton och Jellyfin-servrar — innehåll från alla anslutna backends visas tillsammans på startskärmen.', + 'addServer.addConnectionIntroScoped' => 'Lägg till en ny server, eller låna en från en annan profil.', + 'addServer.signInWithPlexCard' => 'Logga in med Plex', + 'addServer.signInWithPlexCardSubtitle' => 'Auktorisera den här enheten mot ditt Plex-konto. Servrar delade med kontot följer med automatiskt.', + 'addServer.signInWithPlexCardSubtitleScoped' => 'Auktorisera ett nytt Plex-konto. Dess Home-användare visas som profiler.', + 'addServer.connectToJellyfinCard' => 'Anslut till Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => 'Ange URL till din Jellyfin-server och logga in med användarnamn + lösenord (Quick Connect kommer snart).', + '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 anslutning som redan är kopplad till en annan profil. PIN-skyddade källprofiler ber om PIN.', _ => null, }; } diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 707c6a28..5dc034ff 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -11,7 +11,7 @@ import 'package:slang/generated.dart'; import 'strings.g.dart'; // Path: -class TranslationsZh with BaseTranslations implements Translations { +class TranslationsZh extends Translations with BaseTranslations { /// You can call this constructor and build your own translation instance of this locale. /// Constructing via the enum [AppLocale.build] is preferred. TranslationsZh({Map? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata? meta}) @@ -21,7 +21,9 @@ class TranslationsZh with BaseTranslations implements T overrides: overrides ?? {}, cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver, - ) { + ), + super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) { + super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta $meta.setFlatMapFunction(_flatMapFunction); } @@ -29,7 +31,7 @@ class TranslationsZh with BaseTranslations implements T @override final TranslationMetadata $meta; /// Access flat map - @override dynamic operator[](String key) => $meta.getTranslation(key); + @override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key); late final TranslationsZh _root = this; // ignore: unused_field @@ -55,6 +57,8 @@ class TranslationsZh with BaseTranslations implements T @override late final _TranslationsSubtitlingStylingZh subtitlingStyling = _TranslationsSubtitlingStylingZh._(_root); @override late final _TranslationsMpvConfigZh mpvConfig = _TranslationsMpvConfigZh._(_root); @override late final _TranslationsDialogZh dialog = _TranslationsDialogZh._(_root); + @override late final _TranslationsProfilesZh profiles = _TranslationsProfilesZh._(_root); + @override late final _TranslationsConnectionsZh connections = _TranslationsConnectionsZh._(_root); @override late final _TranslationsDiscoverZh discover = _TranslationsDiscoverZh._(_root); @override late final _TranslationsErrorsZh errors = _TranslationsErrorsZh._(_root); @override late final _TranslationsLibrariesZh libraries = _TranslationsLibrariesZh._(_root); @@ -78,11 +82,12 @@ class TranslationsZh with BaseTranslations implements T @override late final _TranslationsServerTasksZh serverTasks = _TranslationsServerTasksZh._(_root); @override late final _TranslationsTraktZh trakt = _TranslationsTraktZh._(_root); @override late final _TranslationsTrackersZh trackers = _TranslationsTrackersZh._(_root); + @override late final _TranslationsAddServerZh addServer = _TranslationsAddServerZh._(_root); } // Path: app -class _TranslationsAppZh implements TranslationsAppEn { - _TranslationsAppZh._(this._root); +class _TranslationsAppZh extends TranslationsAppEn { + _TranslationsAppZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -91,12 +96,13 @@ class _TranslationsAppZh implements TranslationsAppEn { } // Path: auth -class _TranslationsAuthZh implements TranslationsAuthEn { - _TranslationsAuthZh._(this._root); +class _TranslationsAuthZh extends TranslationsAuthEn { + _TranslationsAuthZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field // Translations + @override String get signIn => '登录'; @override String get signInWithPlex => '使用 Plex 登录'; @override String get showQRCode => '显示二维码'; @override String get authenticate => '验证'; @@ -104,11 +110,19 @@ class _TranslationsAuthZh implements TranslationsAuthEn { @override String get scanQRToSignIn => '扫描二维码登录'; @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 quickConnectCode => 'Quick Connect 代码'; + @override String get quickConnectInstructions => '在浏览器中打开你的 Jellyfin 服务器,登录后从用户菜单中选择 Quick Connect。输入此代码以批准登录。'; + @override String get quickConnectWaiting => '等待批准…'; + @override String get quickConnectCancel => '取消'; + @override String get quickConnectExpired => 'Quick Connect 代码在批准前已过期。请重试。'; } // Path: common -class _TranslationsCommonZh implements TranslationsCommonEn { - _TranslationsCommonZh._(this._root); +class _TranslationsCommonZh extends TranslationsCommonEn { + _TranslationsCommonZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -160,8 +174,8 @@ class _TranslationsCommonZh implements TranslationsCommonEn { } // Path: screens -class _TranslationsScreensZh implements TranslationsScreensEn { - _TranslationsScreensZh._(this._root); +class _TranslationsScreensZh extends TranslationsScreensEn { + _TranslationsScreensZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -174,8 +188,8 @@ class _TranslationsScreensZh implements TranslationsScreensEn { } // Path: update -class _TranslationsUpdateZh implements TranslationsUpdateEn { - _TranslationsUpdateZh._(this._root); +class _TranslationsUpdateZh extends TranslationsUpdateEn { + _TranslationsUpdateZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -190,8 +204,8 @@ class _TranslationsUpdateZh implements TranslationsUpdateEn { } // Path: settings -class _TranslationsSettingsZh implements TranslationsSettingsEn { - _TranslationsSettingsZh._(this._root); +class _TranslationsSettingsZh extends TranslationsSettingsEn { + _TranslationsSettingsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -226,12 +240,12 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn { @override String get gridView => '网格视图'; @override String get listView => '列表视图'; @override String get showHeroSection => '显示主要精选区'; - @override String get useGlobalHubs => '使用 Plex 主页布局'; - @override String get useGlobalHubsDescription => '显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。'; + @override String get useGlobalHubs => 'Use Home Layout'; + @override String get useGlobalHubsDescription => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.'; @override String get showServerNameOnHubs => '在推荐栏显示服务器名称'; @override String get showServerNameOnHubsDescription => '始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。'; @override String get groupLibrariesByServer => '按服务器分组媒体库'; - @override String get groupLibrariesByServerDescription => '当您连接到多个服务器时,在侧边栏中为每个 Plex 服务器显示一个标题。'; + @override String get groupLibrariesByServerDescription => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.'; @override String get alwaysKeepSidebarOpen => '始终保持侧边栏展开'; @override String get alwaysKeepSidebarOpenDescription => '侧边栏保持展开状态,内容区域自动调整'; @override String get showUnwatchedCount => '显示未观看数量'; @@ -385,8 +399,8 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn { } // Path: search -class _TranslationsSearchZh implements TranslationsSearchEn { - _TranslationsSearchZh._(this._root); +class _TranslationsSearchZh extends TranslationsSearchEn { + _TranslationsSearchZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -398,8 +412,8 @@ class _TranslationsSearchZh implements TranslationsSearchEn { } // Path: hotkeys -class _TranslationsHotkeysZh implements TranslationsHotkeysEn { - _TranslationsHotkeysZh._(this._root); +class _TranslationsHotkeysZh extends TranslationsHotkeysEn { + _TranslationsHotkeysZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -410,8 +424,8 @@ class _TranslationsHotkeysZh implements TranslationsHotkeysEn { } // Path: fileInfo -class _TranslationsFileInfoZh implements TranslationsFileInfoEn { - _TranslationsFileInfoZh._(this._root); +class _TranslationsFileInfoZh extends TranslationsFileInfoEn { + _TranslationsFileInfoZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -444,8 +458,8 @@ class _TranslationsFileInfoZh implements TranslationsFileInfoEn { } // Path: mediaMenu -class _TranslationsMediaMenuZh implements TranslationsMediaMenuEn { - _TranslationsMediaMenuZh._(this._root); +class _TranslationsMediaMenuZh extends TranslationsMediaMenuEn { + _TranslationsMediaMenuZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -468,8 +482,8 @@ class _TranslationsMediaMenuZh implements TranslationsMediaMenuEn { } // Path: accessibility -class _TranslationsAccessibilityZh implements TranslationsAccessibilityEn { - _TranslationsAccessibilityZh._(this._root); +class _TranslationsAccessibilityZh extends TranslationsAccessibilityEn { + _TranslationsAccessibilityZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -485,8 +499,8 @@ class _TranslationsAccessibilityZh implements TranslationsAccessibilityEn { } // Path: tooltips -class _TranslationsTooltipsZh implements TranslationsTooltipsEn { - _TranslationsTooltipsZh._(this._root); +class _TranslationsTooltipsZh extends TranslationsTooltipsEn { + _TranslationsTooltipsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -498,8 +512,8 @@ class _TranslationsTooltipsZh implements TranslationsTooltipsEn { } // Path: videoControls -class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn { - _TranslationsVideoControlsZh._(this._root); +class _TranslationsVideoControlsZh extends TranslationsVideoControlsEn { + _TranslationsVideoControlsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -574,8 +588,8 @@ class _TranslationsVideoControlsZh implements TranslationsVideoControlsEn { } // Path: userStatus -class _TranslationsUserStatusZh implements TranslationsUserStatusEn { - _TranslationsUserStatusZh._(this._root); +class _TranslationsUserStatusZh extends TranslationsUserStatusEn { + _TranslationsUserStatusZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -587,8 +601,8 @@ class _TranslationsUserStatusZh implements TranslationsUserStatusEn { } // Path: messages -class _TranslationsMessagesZh implements TranslationsMessagesEn { - _TranslationsMessagesZh._(this._root); +class _TranslationsMessagesZh extends TranslationsMessagesEn { + _TranslationsMessagesZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -607,7 +621,7 @@ class _TranslationsMessagesZh implements TranslationsMessagesEn { @override String get musicNotSupported => '尚不支持播放音乐'; @override String get noDescriptionAvailable => '暂无描述'; @override String get noProfilesAvailable => '没有可用的用户'; - @override String get contactAdminForProfiles => '请联系您的 Plex 管理员添加用户'; + @override String get contactAdminForProfiles => 'Contact your server administrator to add profiles'; @override String get unableToDetermineLibrarySection => '无法确定此项目的库分区'; @override String get logsCleared => '日志已清除'; @override String get logsCopied => '日志已复制到剪贴板'; @@ -636,8 +650,8 @@ class _TranslationsMessagesZh implements TranslationsMessagesEn { } // Path: subtitlingStyling -class _TranslationsSubtitlingStylingZh implements TranslationsSubtitlingStylingEn { - _TranslationsSubtitlingStylingZh._(this._root); +class _TranslationsSubtitlingStylingZh extends TranslationsSubtitlingStylingEn { + _TranslationsSubtitlingStylingZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -658,8 +672,8 @@ class _TranslationsSubtitlingStylingZh implements TranslationsSubtitlingStylingE } // Path: mpvConfig -class _TranslationsMpvConfigZh implements TranslationsMpvConfigEn { - _TranslationsMpvConfigZh._(this._root); +class _TranslationsMpvConfigZh extends TranslationsMpvConfigEn { + _TranslationsMpvConfigZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -681,8 +695,8 @@ class _TranslationsMpvConfigZh implements TranslationsMpvConfigEn { } // Path: dialog -class _TranslationsDialogZh implements TranslationsDialogEn { - _TranslationsDialogZh._(this._root); +class _TranslationsDialogZh extends TranslationsDialogEn { + _TranslationsDialogZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -690,9 +704,77 @@ class _TranslationsDialogZh implements TranslationsDialogEn { @override String get confirmAction => '确认操作'; } +// Path: profiles +class _TranslationsProfilesZh extends TranslationsProfilesEn { + _TranslationsProfilesZh._(TranslationsZh root) : this._root = root, super.internal(root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String get addPlezyProfile => '添加 Plezy 配置文件'; + @override String get switchingProfile => '切换配置文件中…'; + @override String get deleteThisProfileTitle => '删除此配置文件?'; + @override String deleteThisProfileMessage({required Object displayName}) => '${displayName} 将被移除。连接本身不受影响。'; + @override String get active => '活跃'; + @override String get manage => '管理'; + @override String get delete => '删除'; + @override String get signOut => '退出登录'; + @override String get signOutPlexTitle => '退出 Plex 登录?'; + @override String signOutPlexMessage({required Object displayName}) => '${displayName} 以及该账户下的所有 Plex Home 用户将从此设备移除。您可以随时重新登录。'; + @override String get signedOutPlex => '已退出 Plex 登录。'; + @override String get signOutFailed => '退出登录失败。'; + @override String get sectionTitle => '配置文件'; + @override String get summarySingle => '添加配置文件以混合托管用户和本地身份'; + @override String summaryMultipleWithActive({required Object count, required Object activeName}) => '${count} 个配置文件 · 活跃:${activeName}'; + @override String summaryMultiple({required Object count}) => '${count} 个配置文件'; + @override String get removeConnectionTitle => '移除连接?'; + @override String removeConnectionMessage({required Object displayName, required Object connectionLabel}) => '${displayName} 将失去对 ${connectionLabel} 的访问权限。连接本身仍可供其他配置文件使用。'; + @override String get deleteProfileTitle => '删除配置文件?'; + @override String deleteProfileMessage({required Object displayName}) => '这将从此设备中删除 ${displayName} 及其所有连接。底层的 Plex/Jellyfin 服务器不会受到影响。'; + @override String get profileNameLabel => '配置文件名称'; + @override String get pinProtectionLabel => 'PIN 保护'; + @override String get pinManagedByPlex => 'PIN 由 Plex 管理。在 plex.tv 上编辑。'; + @override String get noPinSetEditOnPlex => '未设置 PIN。如需要求 PIN,请在 plex.tv 上编辑 Home 用户。'; + @override String get setPin => '设置 PIN'; + @override String get connectionsLabel => '连接'; + @override String get add => '添加'; + @override String get deleteProfileButton => '删除配置文件'; + @override String get noConnectionsHint => '没有连接 — 添加一个以使用此配置文件。'; + @override String get plexHomeAccount => 'Plex Home 账户'; + @override String get connectionDefault => '默认'; + @override String get makeDefault => '设为默认'; + @override String get removeConnection => '移除'; + @override String borrowAddTo({required Object displayName}) => '添加到 ${displayName}'; + @override String get borrowExplain => '从另一个配置文件借用连接。PIN 保护的源配置文件在共享前会要求输入 PIN。'; + @override String get borrowEmpty => '暂无可借用的内容。'; + @override String get borrowEmptySubtitle => '请先将 Plex 账户或 Jellyfin 服务器连接到另一个配置文件,然后回到这里。'; + @override String get newProfile => '新建配置文件'; + @override String get profileNameHint => '例如:访客、儿童、家庭房'; + @override String get pinProtectionOptional => 'PIN 保护(可选)'; + @override String get pinExplain => '切换到此配置文件需要 4 位 PIN。软屏障 — 任何能清除应用数据的人都可以绕过它。'; + @override String get continueButton => '继续'; + @override String get pinsDontMatch => 'PIN 不匹配'; +} + +// Path: connections +class _TranslationsConnectionsZh extends TranslationsConnectionsEn { + _TranslationsConnectionsZh._(TranslationsZh root) : this._root = root, super.internal(root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String get sectionTitle => '连接'; + @override String get addConnection => '添加连接'; + @override String get addConnectionSubtitleNoProfile => '使用 Plex 登录或连接 Jellyfin 服务器'; + @override String addConnectionSubtitleScoped({required Object displayName}) => '添加到 ${displayName} — Plex 帐户、Jellyfin 服务器或从其他配置文件借用'; + @override String sessionExpiredOne({required Object name}) => '${name} 的会话已过期'; + @override String sessionExpiredMany({required Object count}) => '${count} 个服务器的会话已过期'; + @override String get signInAgain => '重新登录'; +} + // Path: discover -class _TranslationsDiscoverZh implements TranslationsDiscoverEn { - _TranslationsDiscoverZh._(this._root); +class _TranslationsDiscoverZh extends TranslationsDiscoverEn { + _TranslationsDiscoverZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -702,6 +784,8 @@ class _TranslationsDiscoverZh implements TranslationsDiscoverEn { @override String get noContentAvailable => '没有可用内容'; @override String get addMediaToLibraries => '请向你的媒体库添加一些媒体'; @override String get continueWatching => '继续观看'; + @override String get nextUp => '接下来'; + @override String get recentlyAdded => '最近添加'; @override String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @override String get overview => '概述'; @override String get cast => '演员表'; @@ -714,15 +798,15 @@ class _TranslationsDiscoverZh implements TranslationsDiscoverEn { } // Path: errors -class _TranslationsErrorsZh implements TranslationsErrorsEn { - _TranslationsErrorsZh._(this._root); +class _TranslationsErrorsZh extends TranslationsErrorsEn { + _TranslationsErrorsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field // Translations @override String searchFailed({required Object error}) => '搜索失败: ${error}'; @override String connectionTimeout({required Object context}) => '加载 ${context} 时连接超时'; - @override String get connectionFailed => '无法连接到 Plex 服务器'; + @override String get connectionFailed => 'Unable to connect to media server'; @override String failedToLoad({required Object context, required Object error}) => '无法加载 ${context}: ${error}'; @override String get noClientAvailable => '没有可用客户端'; @override String authenticationFailed({required Object error}) => '验证失败: ${error}'; @@ -731,11 +815,13 @@ class _TranslationsErrorsZh implements TranslationsErrorsEn { @override String get invalidToken => '令牌无效'; @override String failedToVerifyToken({required Object error}) => '无法验证令牌: ${error}'; @override String failedToSwitchProfile({required Object displayName}) => '无法切换到 ${displayName}'; + @override String failedToDeleteProfile({required Object displayName}) => '无法删除 ${displayName}'; + @override String get failedToRate => '无法更新评分'; } // Path: libraries -class _TranslationsLibrariesZh implements TranslationsLibrariesEn { - _TranslationsLibrariesZh._(this._root); +class _TranslationsLibrariesZh extends TranslationsLibrariesEn { + _TranslationsLibrariesZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -780,11 +866,13 @@ class _TranslationsLibrariesZh implements TranslationsLibrariesEn { @override String get folders => '文件夹'; @override late final _TranslationsLibrariesTabsZh tabs = _TranslationsLibrariesTabsZh._(_root); @override late final _TranslationsLibrariesGroupingsZh groupings = _TranslationsLibrariesGroupingsZh._(_root); + @override late final _TranslationsLibrariesFilterCategoriesZh filterCategories = _TranslationsLibrariesFilterCategoriesZh._(_root); + @override late final _TranslationsLibrariesSortLabelsZh sortLabels = _TranslationsLibrariesSortLabelsZh._(_root); } // Path: about -class _TranslationsAboutZh implements TranslationsAboutEn { - _TranslationsAboutZh._(this._root); +class _TranslationsAboutZh extends TranslationsAboutEn { + _TranslationsAboutZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -792,13 +880,13 @@ class _TranslationsAboutZh implements TranslationsAboutEn { @override String get title => '关于'; @override String get openSourceLicenses => '开源许可证'; @override String versionLabel({required Object version}) => '版本 ${version}'; - @override String get appDescription => '一款精美的 Flutter Plex 客户端'; + @override String get appDescription => '一款精美的 Flutter Plex 和 Jellyfin 客户端'; @override String get viewLicensesDescription => '查看第三方库的许可证'; } // Path: serverSelection -class _TranslationsServerSelectionZh implements TranslationsServerSelectionEn { - _TranslationsServerSelectionZh._(this._root); +class _TranslationsServerSelectionZh extends TranslationsServerSelectionEn { + _TranslationsServerSelectionZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -809,8 +897,8 @@ class _TranslationsServerSelectionZh implements TranslationsServerSelectionEn { } // Path: hubDetail -class _TranslationsHubDetailZh implements TranslationsHubDetailEn { - _TranslationsHubDetailZh._(this._root); +class _TranslationsHubDetailZh extends TranslationsHubDetailEn { + _TranslationsHubDetailZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -823,8 +911,8 @@ class _TranslationsHubDetailZh implements TranslationsHubDetailEn { } // Path: logs -class _TranslationsLogsZh implements TranslationsLogsEn { - _TranslationsLogsZh._(this._root); +class _TranslationsLogsZh extends TranslationsLogsEn { + _TranslationsLogsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -835,8 +923,8 @@ class _TranslationsLogsZh implements TranslationsLogsEn { } // Path: licenses -class _TranslationsLicensesZh implements TranslationsLicensesEn { - _TranslationsLicensesZh._(this._root); +class _TranslationsLicensesZh extends TranslationsLicensesEn { + _TranslationsLicensesZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -848,8 +936,8 @@ class _TranslationsLicensesZh implements TranslationsLicensesEn { } // Path: navigation -class _TranslationsNavigationZh implements TranslationsNavigationEn { - _TranslationsNavigationZh._(this._root); +class _TranslationsNavigationZh extends TranslationsNavigationEn { + _TranslationsNavigationZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -860,8 +948,8 @@ class _TranslationsNavigationZh implements TranslationsNavigationEn { } // Path: liveTv -class _TranslationsLiveTvZh implements TranslationsLiveTvEn { - _TranslationsLiveTvZh._(this._root); +class _TranslationsLiveTvZh extends TranslationsLiveTvEn { + _TranslationsLiveTvZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -892,8 +980,8 @@ class _TranslationsLiveTvZh implements TranslationsLiveTvEn { } // Path: collections -class _TranslationsCollectionsZh implements TranslationsCollectionsEn { - _TranslationsCollectionsZh._(this._root); +class _TranslationsCollectionsZh extends TranslationsCollectionsEn { + _TranslationsCollectionsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -923,8 +1011,8 @@ class _TranslationsCollectionsZh implements TranslationsCollectionsEn { } // Path: playlists -class _TranslationsPlaylistsZh implements TranslationsPlaylistsEn { - _TranslationsPlaylistsZh._(this._root); +class _TranslationsPlaylistsZh extends TranslationsPlaylistsEn { + _TranslationsPlaylistsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -957,8 +1045,8 @@ class _TranslationsPlaylistsZh implements TranslationsPlaylistsEn { } // Path: watchTogether -class _TranslationsWatchTogetherZh implements TranslationsWatchTogetherEn { - _TranslationsWatchTogetherZh._(this._root); +class _TranslationsWatchTogetherZh extends TranslationsWatchTogetherEn { + _TranslationsWatchTogetherZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1021,11 +1109,13 @@ class _TranslationsWatchTogetherZh implements TranslationsWatchTogetherEn { @override String get recentRooms => '最近的房间'; @override String get renameRoom => '重命名房间'; @override String get removeRoom => '移除'; + @override String get guestSwitchUnavailable => '无法切换 — 服务器无法同步'; + @override String get guestSwitchFailed => '无法切换 — 在此服务器上未找到内容'; } // Path: downloads -class _TranslationsDownloadsZh implements TranslationsDownloadsEn { - _TranslationsDownloadsZh._(this._root); +class _TranslationsDownloadsZh extends TranslationsDownloadsEn { + _TranslationsDownloadsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1078,12 +1168,18 @@ class _TranslationsDownloadsZh implements TranslationsDownloadsEn { @override String get editSyncFilter => '同步筛选'; @override String get syncAllItems => '同步所有项目'; @override String get syncUnwatchedItems => '同步未观看项目'; + @override String syncRuleServerContext({required Object server, required Object status}) => '服务器: ${server} • ${status}'; + @override String get syncRuleAvailable => '可用'; + @override String get syncRuleOffline => '离线'; + @override String get syncRuleSignInRequired => '需要登录'; + @override String get syncRuleNotAvailableForProfile => '当前个人资料不可用'; + @override String get syncRuleUnknownServer => '未知服务器'; @override String get syncRuleListCreated => '同步规则已创建'; } // Path: shaders -class _TranslationsShadersZh implements TranslationsShadersEn { - _TranslationsShadersZh._(this._root); +class _TranslationsShadersZh extends TranslationsShadersEn { + _TranslationsShadersZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1106,8 +1202,8 @@ class _TranslationsShadersZh implements TranslationsShadersEn { } // Path: companionRemote -class _TranslationsCompanionRemoteZh implements TranslationsCompanionRemoteEn { - _TranslationsCompanionRemoteZh._(this._root); +class _TranslationsCompanionRemoteZh extends TranslationsCompanionRemoteEn { + _TranslationsCompanionRemoteZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1120,8 +1216,8 @@ class _TranslationsCompanionRemoteZh implements TranslationsCompanionRemoteEn { } // Path: videoSettings -class _TranslationsVideoSettingsZh implements TranslationsVideoSettingsEn { - _TranslationsVideoSettingsZh._(this._root); +class _TranslationsVideoSettingsZh extends TranslationsVideoSettingsEn { + _TranslationsVideoSettingsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1139,8 +1235,8 @@ class _TranslationsVideoSettingsZh implements TranslationsVideoSettingsEn { } // Path: externalPlayer -class _TranslationsExternalPlayerZh implements TranslationsExternalPlayerEn { - _TranslationsExternalPlayerZh._(this._root); +class _TranslationsExternalPlayerZh extends TranslationsExternalPlayerEn { + _TranslationsExternalPlayerZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1163,8 +1259,8 @@ class _TranslationsExternalPlayerZh implements TranslationsExternalPlayerEn { } // Path: metadataEdit -class _TranslationsMetadataEditZh implements TranslationsMetadataEditEn { - _TranslationsMetadataEditZh._(this._root); +class _TranslationsMetadataEditZh extends TranslationsMetadataEditEn { + _TranslationsMetadataEditZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1246,8 +1342,8 @@ class _TranslationsMetadataEditZh implements TranslationsMetadataEditEn { } // Path: matchScreen -class _TranslationsMatchScreenZh implements TranslationsMatchScreenEn { - _TranslationsMatchScreenZh._(this._root); +class _TranslationsMatchScreenZh extends TranslationsMatchScreenEn { + _TranslationsMatchScreenZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1267,8 +1363,8 @@ class _TranslationsMatchScreenZh implements TranslationsMatchScreenEn { } // Path: serverTasks -class _TranslationsServerTasksZh implements TranslationsServerTasksEn { - _TranslationsServerTasksZh._(this._root); +class _TranslationsServerTasksZh extends TranslationsServerTasksEn { + _TranslationsServerTasksZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1279,8 +1375,8 @@ class _TranslationsServerTasksZh implements TranslationsServerTasksEn { } // Path: trakt -class _TranslationsTraktZh implements TranslationsTraktEn { - _TranslationsTraktZh._(this._root); +class _TranslationsTraktZh extends TranslationsTraktEn { + _TranslationsTraktZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1297,8 +1393,8 @@ class _TranslationsTraktZh implements TranslationsTraktEn { } // Path: trackers -class _TranslationsTrackersZh implements TranslationsTrackersEn { - _TranslationsTrackersZh._(this._root); +class _TranslationsTrackersZh extends TranslationsTrackersEn { + _TranslationsTrackersZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1318,9 +1414,50 @@ class _TranslationsTrackersZh implements TranslationsTrackersEn { @override late final _TranslationsTrackersLibraryFilterZh libraryFilter = _TranslationsTrackersLibraryFilterZh._(_root); } +// Path: addServer +class _TranslationsAddServerZh extends TranslationsAddServerEn { + _TranslationsAddServerZh._(TranslationsZh root) : this._root = root, super.internal(root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String get addJellyfinTitle => '添加 Jellyfin 服务器'; + @override String get jellyfinUrlIntro => '输入你的 Jellyfin 服务器 URL — 例如 `https://jellyfin.example.com`。可在之后登录。'; + @override String get serverUrl => '服务器 URL'; + @override String get findServer => '查找服务器'; + @override String get username => '用户名'; + @override String get password => '密码'; + @override String get signIn => '登录'; + @override String get change => '更改'; + @override String get required => '必填'; + @override String couldNotReachServer({required Object error}) => '无法连接到服务器: ${error}'; + @override String signInFailed({required Object error}) => '登录失败: ${error}'; + @override String quickConnectFailed({required Object error}) => 'Quick Connect 失败: ${error}'; + @override String get addPlexTitle => '使用 Plex 登录'; + @override String get plexAuthIntro => '选择登录 Plex 的方式。浏览器流程会打开 plex.tv 让你确认连接;QR 选项适合电视 / 远程设备。'; + @override String get plexQRPrompt => '扫描此 QR 码以登录。'; + @override String get waitingForPlexConfirmation => '等待 plex.tv 确认登录…'; + @override String get pinExpired => 'PIN 在登录前已过期。请重试。'; + @override String get duplicatePlexAccount => '此设备已登录到一个 Plex 帐户。请在设置中退出登录以切换帐户。'; + @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 addConnectionIntroGlobal => '添加另一台媒体服务器。你可以混合使用 Plex 帐户和 Jellyfin 服务器 — 所有已连接后端的项目会一起显示在主页。'; + @override String get addConnectionIntroScoped => '添加新服务器,或从另一个配置文件借用。'; + @override String get signInWithPlexCard => '使用 Plex 登录'; + @override String get signInWithPlexCardSubtitle => '为你的 Plex 帐户授权此设备。与该帐户共享的服务器会自动加入。'; + @override String get signInWithPlexCardSubtitleScoped => '授权一个新的 Plex 帐户。其 Home 用户会显示为配置文件。'; + @override String get connectToJellyfinCard => '连接到 Jellyfin'; + @override String get connectToJellyfinCardSubtitle => '输入你的 Jellyfin 服务器 URL,使用用户名 + 密码登录(Quick Connect 即将支持)。'; + @override String connectToJellyfinCardSubtitleScoped({required Object name}) => '登录到 Jellyfin 服务器。绑定到 ${name}。'; + @override String get borrowFromAnotherProfile => '从另一个配置文件借用'; + @override String get borrowFromAnotherProfileSubtitle => '重用已附加到另一个配置文件的连接。受 PIN 保护的来源配置文件会要求输入 PIN。'; +} + // Path: hotkeys.actions -class _TranslationsHotkeysActionsZh implements TranslationsHotkeysActionsEn { - _TranslationsHotkeysActionsZh._(this._root); +class _TranslationsHotkeysActionsZh extends TranslationsHotkeysActionsEn { + _TranslationsHotkeysActionsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1349,8 +1486,8 @@ class _TranslationsHotkeysActionsZh implements TranslationsHotkeysActionsEn { } // Path: videoControls.pipErrors -class _TranslationsVideoControlsPipErrorsZh implements TranslationsVideoControlsPipErrorsEn { - _TranslationsVideoControlsPipErrorsZh._(this._root); +class _TranslationsVideoControlsPipErrorsZh extends TranslationsVideoControlsPipErrorsEn { + _TranslationsVideoControlsPipErrorsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1365,8 +1502,8 @@ class _TranslationsVideoControlsPipErrorsZh implements TranslationsVideoControls } // Path: libraries.tabs -class _TranslationsLibrariesTabsZh implements TranslationsLibrariesTabsEn { - _TranslationsLibrariesTabsZh._(this._root); +class _TranslationsLibrariesTabsZh extends TranslationsLibrariesTabsEn { + _TranslationsLibrariesTabsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1378,8 +1515,8 @@ class _TranslationsLibrariesTabsZh implements TranslationsLibrariesTabsEn { } // Path: libraries.groupings -class _TranslationsLibrariesGroupingsZh implements TranslationsLibrariesGroupingsEn { - _TranslationsLibrariesGroupingsZh._(this._root); +class _TranslationsLibrariesGroupingsZh extends TranslationsLibrariesGroupingsEn { + _TranslationsLibrariesGroupingsZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1393,9 +1530,40 @@ class _TranslationsLibrariesGroupingsZh implements TranslationsLibrariesGrouping @override String get folders => '文件夹'; } +// Path: libraries.filterCategories +class _TranslationsLibrariesFilterCategoriesZh extends TranslationsLibrariesFilterCategoriesEn { + _TranslationsLibrariesFilterCategoriesZh._(TranslationsZh root) : this._root = root, super.internal(root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String get genre => '类型'; + @override String get year => '年份'; + @override String get contentRating => '内容分级'; + @override String get tag => '标签'; +} + +// Path: libraries.sortLabels +class _TranslationsLibrariesSortLabelsZh extends TranslationsLibrariesSortLabelsEn { + _TranslationsLibrariesSortLabelsZh._(TranslationsZh root) : this._root = root, super.internal(root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String get title => '标题'; + @override String get dateAdded => '添加日期'; + @override String get releaseDate => '发行日期'; + @override String get rating => '评分'; + @override String get lastPlayed => '最近播放'; + @override String get playCount => '播放次数'; + @override String get random => '随机'; + @override String get dateShared => '共享日期'; + @override String get latestEpisodeAirDate => '最新一集播出日期'; +} + // Path: companionRemote.session -class _TranslationsCompanionRemoteSessionZh implements TranslationsCompanionRemoteSessionEn { - _TranslationsCompanionRemoteSessionZh._(this._root); +class _TranslationsCompanionRemoteSessionZh extends TranslationsCompanionRemoteSessionEn { + _TranslationsCompanionRemoteSessionZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1415,8 +1583,8 @@ class _TranslationsCompanionRemoteSessionZh implements TranslationsCompanionRemo } // Path: companionRemote.pairing -class _TranslationsCompanionRemotePairingZh implements TranslationsCompanionRemotePairingEn { - _TranslationsCompanionRemotePairingZh._(this._root); +class _TranslationsCompanionRemotePairingZh extends TranslationsCompanionRemotePairingEn { + _TranslationsCompanionRemotePairingZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1439,8 +1607,8 @@ class _TranslationsCompanionRemotePairingZh implements TranslationsCompanionRemo } // Path: companionRemote.remote -class _TranslationsCompanionRemoteRemoteZh implements TranslationsCompanionRemoteRemoteEn { - _TranslationsCompanionRemoteRemoteZh._(this._root); +class _TranslationsCompanionRemoteRemoteZh extends TranslationsCompanionRemoteRemoteEn { + _TranslationsCompanionRemoteRemoteZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1475,8 +1643,8 @@ class _TranslationsCompanionRemoteRemoteZh implements TranslationsCompanionRemot } // Path: trackers.services -class _TranslationsTrackersServicesZh implements TranslationsTrackersServicesEn { - _TranslationsTrackersServicesZh._(this._root); +class _TranslationsTrackersServicesZh extends TranslationsTrackersServicesEn { + _TranslationsTrackersServicesZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1487,8 +1655,8 @@ class _TranslationsTrackersServicesZh implements TranslationsTrackersServicesEn } // Path: trackers.deviceCode -class _TranslationsTrackersDeviceCodeZh implements TranslationsTrackersDeviceCodeEn { - _TranslationsTrackersDeviceCodeZh._(this._root); +class _TranslationsTrackersDeviceCodeZh extends TranslationsTrackersDeviceCodeEn { + _TranslationsTrackersDeviceCodeZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1501,8 +1669,8 @@ class _TranslationsTrackersDeviceCodeZh implements TranslationsTrackersDeviceCod } // Path: trackers.oauthProxy -class _TranslationsTrackersOauthProxyZh implements TranslationsTrackersOauthProxyEn { - _TranslationsTrackersOauthProxyZh._(this._root); +class _TranslationsTrackersOauthProxyZh extends TranslationsTrackersOauthProxyEn { + _TranslationsTrackersOauthProxyZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1514,8 +1682,8 @@ class _TranslationsTrackersOauthProxyZh implements TranslationsTrackersOauthProx } // Path: trackers.libraryFilter -class _TranslationsTrackersLibraryFilterZh implements TranslationsTrackersLibraryFilterEn { - _TranslationsTrackersLibraryFilterZh._(this._root); +class _TranslationsTrackersLibraryFilterZh extends TranslationsTrackersLibraryFilterEn { + _TranslationsTrackersLibraryFilterZh._(TranslationsZh root) : this._root = root, super.internal(root); final TranslationsZh _root; // ignore: unused_field @@ -1543,6 +1711,7 @@ extension on TranslationsZh { dynamic _flatMapFunction(String path) { return switch (path) { 'app.title' => 'Plezy', + 'auth.signIn' => '登录', 'auth.signInWithPlex' => '使用 Plex 登录', 'auth.showQRCode' => '显示二维码', 'auth.authenticate' => '验证', @@ -1550,6 +1719,14 @@ extension on TranslationsZh { 'auth.scanQRToSignIn' => '扫描二维码登录', 'auth.waitingForAuth' => '等待验证中...\n请在你的浏览器中完成登录。', 'auth.useBrowser' => '使用浏览器', + 'auth.or' => '或', + 'auth.connectToJellyfin' => '连接到 Jellyfin', + 'auth.useQuickConnect' => '使用 Quick Connect', + 'auth.quickConnectCode' => 'Quick Connect 代码', + 'auth.quickConnectInstructions' => '在浏览器中打开你的 Jellyfin 服务器,登录后从用户菜单中选择 Quick Connect。输入此代码以批准登录。', + 'auth.quickConnectWaiting' => '等待批准…', + 'auth.quickConnectCancel' => '取消', + 'auth.quickConnectExpired' => 'Quick Connect 代码在批准前已过期。请重试。', 'common.cancel' => '取消', 'common.save' => '保存', 'common.close' => '关闭', @@ -1636,12 +1813,12 @@ extension on TranslationsZh { 'settings.gridView' => '网格视图', 'settings.listView' => '列表视图', 'settings.showHeroSection' => '显示主要精选区', - 'settings.useGlobalHubs' => '使用 Plex 主页布局', - 'settings.useGlobalHubsDescription' => '显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。', + 'settings.useGlobalHubs' => 'Use Home Layout', + 'settings.useGlobalHubsDescription' => 'Show home page hubs like the official client. When off, shows per-library recommendations instead.', 'settings.showServerNameOnHubs' => '在推荐栏显示服务器名称', 'settings.showServerNameOnHubsDescription' => '始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。', 'settings.groupLibrariesByServer' => '按服务器分组媒体库', - 'settings.groupLibrariesByServerDescription' => '当您连接到多个服务器时,在侧边栏中为每个 Plex 服务器显示一个标题。', + 'settings.groupLibrariesByServerDescription' => 'Show a header for each media server in the sidebar when you\'re connected to multiple servers.', 'settings.alwaysKeepSidebarOpen' => '始终保持侧边栏展开', 'settings.alwaysKeepSidebarOpenDescription' => '侧边栏保持展开状态,内容区域自动调整', 'settings.showUnwatchedCount' => '显示未观看数量', @@ -1962,7 +2139,7 @@ extension on TranslationsZh { 'messages.musicNotSupported' => '尚不支持播放音乐', 'messages.noDescriptionAvailable' => '暂无描述', 'messages.noProfilesAvailable' => '没有可用的用户', - 'messages.contactAdminForProfiles' => '请联系您的 Plex 管理员添加用户', + 'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles', 'messages.unableToDetermineLibrarySection' => '无法确定此项目的库分区', 'messages.logsCleared' => '日志已清除', 'messages.logsCopied' => '日志已复制到剪贴板', @@ -2016,11 +2193,65 @@ extension on TranslationsZh { 'mpvConfig.confirmDeletePreset' => '确定要删除此预设吗?', 'mpvConfig.configPlaceholder' => 'gpu-api=vulkan\nhwdec=auto\n# comment', 'dialog.confirmAction' => '确认操作', + 'profiles.addPlezyProfile' => '添加 Plezy 配置文件', + 'profiles.switchingProfile' => '切换配置文件中…', + 'profiles.deleteThisProfileTitle' => '删除此配置文件?', + 'profiles.deleteThisProfileMessage' => ({required Object displayName}) => '${displayName} 将被移除。连接本身不受影响。', + 'profiles.active' => '活跃', + 'profiles.manage' => '管理', + 'profiles.delete' => '删除', + 'profiles.signOut' => '退出登录', + 'profiles.signOutPlexTitle' => '退出 Plex 登录?', + 'profiles.signOutPlexMessage' => ({required Object displayName}) => '${displayName} 以及该账户下的所有 Plex Home 用户将从此设备移除。您可以随时重新登录。', + 'profiles.signedOutPlex' => '已退出 Plex 登录。', + 'profiles.signOutFailed' => '退出登录失败。', + 'profiles.sectionTitle' => '配置文件', + 'profiles.summarySingle' => '添加配置文件以混合托管用户和本地身份', + 'profiles.summaryMultipleWithActive' => ({required Object count, required Object activeName}) => '${count} 个配置文件 · 活跃:${activeName}', + 'profiles.summaryMultiple' => ({required Object count}) => '${count} 个配置文件', + 'profiles.removeConnectionTitle' => '移除连接?', + 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName} 将失去对 ${connectionLabel} 的访问权限。连接本身仍可供其他配置文件使用。', + 'profiles.deleteProfileTitle' => '删除配置文件?', + 'profiles.deleteProfileMessage' => ({required Object displayName}) => '这将从此设备中删除 ${displayName} 及其所有连接。底层的 Plex/Jellyfin 服务器不会受到影响。', + 'profiles.profileNameLabel' => '配置文件名称', + 'profiles.pinProtectionLabel' => 'PIN 保护', + 'profiles.pinManagedByPlex' => 'PIN 由 Plex 管理。在 plex.tv 上编辑。', + 'profiles.noPinSetEditOnPlex' => '未设置 PIN。如需要求 PIN,请在 plex.tv 上编辑 Home 用户。', + 'profiles.setPin' => '设置 PIN', + 'profiles.connectionsLabel' => '连接', + 'profiles.add' => '添加', + 'profiles.deleteProfileButton' => '删除配置文件', + 'profiles.noConnectionsHint' => '没有连接 — 添加一个以使用此配置文件。', + _ => null, + } ?? switch (path) { + 'profiles.plexHomeAccount' => 'Plex Home 账户', + 'profiles.connectionDefault' => '默认', + 'profiles.makeDefault' => '设为默认', + 'profiles.removeConnection' => '移除', + 'profiles.borrowAddTo' => ({required Object displayName}) => '添加到 ${displayName}', + 'profiles.borrowExplain' => '从另一个配置文件借用连接。PIN 保护的源配置文件在共享前会要求输入 PIN。', + 'profiles.borrowEmpty' => '暂无可借用的内容。', + 'profiles.borrowEmptySubtitle' => '请先将 Plex 账户或 Jellyfin 服务器连接到另一个配置文件,然后回到这里。', + 'profiles.newProfile' => '新建配置文件', + 'profiles.profileNameHint' => '例如:访客、儿童、家庭房', + 'profiles.pinProtectionOptional' => 'PIN 保护(可选)', + 'profiles.pinExplain' => '切换到此配置文件需要 4 位 PIN。软屏障 — 任何能清除应用数据的人都可以绕过它。', + 'profiles.continueButton' => '继续', + 'profiles.pinsDontMatch' => 'PIN 不匹配', + 'connections.sectionTitle' => '连接', + 'connections.addConnection' => '添加连接', + 'connections.addConnectionSubtitleNoProfile' => '使用 Plex 登录或连接 Jellyfin 服务器', + 'connections.addConnectionSubtitleScoped' => ({required Object displayName}) => '添加到 ${displayName} — Plex 帐户、Jellyfin 服务器或从其他配置文件借用', + 'connections.sessionExpiredOne' => ({required Object name}) => '${name} 的会话已过期', + 'connections.sessionExpiredMany' => ({required Object count}) => '${count} 个服务器的会话已过期', + 'connections.signInAgain' => '重新登录', 'discover.title' => '发现', 'discover.switchProfile' => '切换用户', 'discover.noContentAvailable' => '没有可用内容', 'discover.addMediaToLibraries' => '请向你的媒体库添加一些媒体', 'discover.continueWatching' => '继续观看', + 'discover.nextUp' => '接下来', + 'discover.recentlyAdded' => '最近添加', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => '概述', 'discover.cast' => '演员表', @@ -2032,7 +2263,7 @@ extension on TranslationsZh { 'discover.minutesLeft' => ({required Object minutes}) => '剩余 ${minutes} 分钟', 'errors.searchFailed' => ({required Object error}) => '搜索失败: ${error}', 'errors.connectionTimeout' => ({required Object context}) => '加载 ${context} 时连接超时', - 'errors.connectionFailed' => '无法连接到 Plex 服务器', + 'errors.connectionFailed' => 'Unable to connect to media server', 'errors.failedToLoad' => ({required Object context, required Object error}) => '无法加载 ${context}: ${error}', 'errors.noClientAvailable' => '没有可用客户端', 'errors.authenticationFailed' => ({required Object error}) => '验证失败: ${error}', @@ -2041,6 +2272,8 @@ extension on TranslationsZh { 'errors.invalidToken' => '令牌无效', 'errors.failedToVerifyToken' => ({required Object error}) => '无法验证令牌: ${error}', 'errors.failedToSwitchProfile' => ({required Object displayName}) => '无法切换到 ${displayName}', + 'errors.failedToDeleteProfile' => ({required Object displayName}) => '无法删除 ${displayName}', + 'errors.failedToRate' => '无法更新评分', 'libraries.title' => '媒体库', 'libraries.scanLibraryFiles' => '扫描媒体库文件', 'libraries.scanLibrary' => '扫描媒体库', @@ -2054,8 +2287,6 @@ extension on TranslationsZh { 'libraries.analyzing' => ({required Object title}) => '正在分析 “${title}”...', 'libraries.analysisStarted' => ({required Object title}) => '已开始分析 “${title}”', 'libraries.failedToAnalyze' => ({required Object error}) => '无法分析媒体库: ${error}', - _ => null, - } ?? switch (path) { 'libraries.noLibrariesFound' => '未找到媒体库', 'libraries.allLibrariesHidden' => '所有媒体库已隐藏', 'libraries.hiddenLibrariesCount' => ({required Object count}) => '已隐藏的媒体库 (${count})', @@ -2092,10 +2323,23 @@ extension on TranslationsZh { 'libraries.groupings.seasons' => '季', 'libraries.groupings.episodes' => '集', 'libraries.groupings.folders' => '文件夹', + 'libraries.filterCategories.genre' => '类型', + 'libraries.filterCategories.year' => '年份', + 'libraries.filterCategories.contentRating' => '内容分级', + 'libraries.filterCategories.tag' => '标签', + 'libraries.sortLabels.title' => '标题', + 'libraries.sortLabels.dateAdded' => '添加日期', + 'libraries.sortLabels.releaseDate' => '发行日期', + 'libraries.sortLabels.rating' => '评分', + 'libraries.sortLabels.lastPlayed' => '最近播放', + 'libraries.sortLabels.playCount' => '播放次数', + 'libraries.sortLabels.random' => '随机', + 'libraries.sortLabels.dateShared' => '共享日期', + 'libraries.sortLabels.latestEpisodeAirDate' => '最新一集播出日期', 'about.title' => '关于', 'about.openSourceLicenses' => '开源许可证', 'about.versionLabel' => ({required Object version}) => '版本 ${version}', - 'about.appDescription' => '一款精美的 Flutter Plex 客户端', + 'about.appDescription' => '一款精美的 Flutter Plex 和 Jellyfin 客户端', 'about.viewLicensesDescription' => '查看第三方库的许可证', 'serverSelection.allServerConnectionsFailed' => '无法连接到任何服务器。请检查你的网络并重试。', 'serverSelection.noServersFoundForAccount' => ({required Object username, required Object email}) => '未找到 ${username} (${email}) 的服务器', @@ -2243,6 +2487,8 @@ extension on TranslationsZh { 'watchTogether.recentRooms' => '最近的房间', 'watchTogether.renameRoom' => '重命名房间', 'watchTogether.removeRoom' => '移除', + 'watchTogether.guestSwitchUnavailable' => '无法切换 — 服务器无法同步', + 'watchTogether.guestSwitchFailed' => '无法切换 — 在此服务器上未找到内容', 'downloads.title' => '下载', 'downloads.manage' => '管理', 'downloads.tvShows' => '电视剧', @@ -2291,6 +2537,12 @@ extension on TranslationsZh { 'downloads.editSyncFilter' => '同步筛选', 'downloads.syncAllItems' => '同步所有项目', 'downloads.syncUnwatchedItems' => '同步未观看项目', + 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => '服务器: ${server} • ${status}', + 'downloads.syncRuleAvailable' => '可用', + 'downloads.syncRuleOffline' => '离线', + 'downloads.syncRuleSignInRequired' => '需要登录', + 'downloads.syncRuleNotAvailableForProfile' => '当前个人资料不可用', + 'downloads.syncRuleUnknownServer' => '未知服务器', 'downloads.syncRuleListCreated' => '同步规则已创建', 'shaders.title' => '着色器', 'shaders.noShaderDescription' => '无视频增强', @@ -2484,6 +2736,8 @@ extension on TranslationsZh { 'trakt.disconnectConfirmBody' => 'Plezy 将停止向 Trakt 发送播放事件。您随时可以重新连接。', 'trakt.scrobble' => '实时 Scrobble', 'trakt.scrobbleDescription' => '在播放时向 Trakt 发送播放、暂停和停止事件。', + _ => null, + } ?? switch (path) { 'trakt.watchedSync' => '同步已观看状态', 'trakt.watchedSyncDescription' => '在 Plezy 中将内容标记为已观看时,也会在 Trakt 上标记为已观看。', 'trackers.title' => '追踪器', @@ -2519,6 +2773,38 @@ extension on TranslationsZh { 'trackers.libraryFilter.modeHintWhitelist' => '仅同步下方勾选的媒体库。', 'trackers.libraryFilter.libraries' => '媒体库', 'trackers.libraryFilter.noLibraries' => '没有可用的媒体库', + 'addServer.addJellyfinTitle' => '添加 Jellyfin 服务器', + 'addServer.jellyfinUrlIntro' => '输入你的 Jellyfin 服务器 URL — 例如 `https://jellyfin.example.com`。可在之后登录。', + 'addServer.serverUrl' => '服务器 URL', + 'addServer.findServer' => '查找服务器', + 'addServer.username' => '用户名', + 'addServer.password' => '密码', + 'addServer.signIn' => '登录', + 'addServer.change' => '更改', + 'addServer.required' => '必填', + 'addServer.couldNotReachServer' => ({required Object error}) => '无法连接到服务器: ${error}', + 'addServer.signInFailed' => ({required Object error}) => '登录失败: ${error}', + 'addServer.quickConnectFailed' => ({required Object error}) => 'Quick Connect 失败: ${error}', + 'addServer.addPlexTitle' => '使用 Plex 登录', + 'addServer.plexAuthIntro' => '选择登录 Plex 的方式。浏览器流程会打开 plex.tv 让你确认连接;QR 选项适合电视 / 远程设备。', + 'addServer.plexQRPrompt' => '扫描此 QR 码以登录。', + 'addServer.waitingForPlexConfirmation' => '等待 plex.tv 确认登录…', + 'addServer.pinExpired' => 'PIN 在登录前已过期。请重试。', + 'addServer.duplicatePlexAccount' => '此设备已登录到一个 Plex 帐户。请在设置中退出登录以切换帐户。', + 'addServer.failedToRegisterAccount' => ({required Object error}) => '注册帐户失败: ${error}', + 'addServer.enterJellyfinUrlError' => '输入你的 Jellyfin 服务器 URL', + 'addServer.addConnectionTitle' => '添加连接', + 'addServer.addConnectionTitleScoped' => ({required Object name}) => '添加到 ${name}', + 'addServer.addConnectionIntroGlobal' => '添加另一台媒体服务器。你可以混合使用 Plex 帐户和 Jellyfin 服务器 — 所有已连接后端的项目会一起显示在主页。', + 'addServer.addConnectionIntroScoped' => '添加新服务器,或从另一个配置文件借用。', + 'addServer.signInWithPlexCard' => '使用 Plex 登录', + 'addServer.signInWithPlexCardSubtitle' => '为你的 Plex 帐户授权此设备。与该帐户共享的服务器会自动加入。', + 'addServer.signInWithPlexCardSubtitleScoped' => '授权一个新的 Plex 帐户。其 Home 用户会显示为配置文件。', + 'addServer.connectToJellyfinCard' => '连接到 Jellyfin', + 'addServer.connectToJellyfinCardSubtitle' => '输入你的 Jellyfin 服务器 URL,使用用户名 + 密码登录(Quick Connect 即将支持)。', + '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 4198bf60..58c1d2f9 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "Logga in", "signInWithPlex": "Logga in med Plex", "showQRCode": "Visa QR-kod", "authenticate": "Autentisera", "authenticationTimeout": "Autentisering tog för lång tid. Försök igen.", "scanQRToSignIn": "Skanna QR-koden för att logga in", "waitingForAuth": "Väntar på autentisering...\nVänligen slutför inloggning i din webbläsare.", - "useBrowser": "Använd webbläsare" + "useBrowser": "Använd webbläsare", + "or": "eller", + "connectToJellyfin": "Anslut till Jellyfin", + "useQuickConnect": "Använd Quick Connect", + "quickConnectCode": "Quick Connect-kod", + "quickConnectInstructions": "Öppna din Jellyfin-server i en webbläsare, logga in och välj Quick Connect i användarmenyn. Ange denna kod för att godkänna inloggningen.", + "quickConnectWaiting": "Väntar på godkännande…", + "quickConnectCancel": "Avbryt", + "quickConnectExpired": "Quick Connect-koden gick ut innan den godkändes. Försök igen." }, "common": { "cancel": "Avbryt", @@ -104,12 +113,12 @@ "gridView": "Rutnät", "listView": "Lista", "showHeroSection": "Visa hjältesektion", - "useGlobalHubs": "Använd Plex hem-layout", - "useGlobalHubsDescription": "Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "Visa servernamn på hubbar", "showServerNameOnHubsDescription": "Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.", "groupLibrariesByServer": "Gruppera bibliotek efter server", - "groupLibrariesByServerDescription": "Visa en rubrik för varje Plex-server i sidofältet när du är ansluten till flera servrar.", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "Håll sidofältet alltid öppet", "alwaysKeepSidebarOpenDescription": "Sidofältet förblir expanderat och innehållsytan anpassas", "showUnwatchedCount": "Visa antal osedda", @@ -452,7 +461,7 @@ "musicNotSupported": "Musikuppspelning stöds inte ännu", "noDescriptionAvailable": "Ingen beskrivning tillgänglig", "noProfilesAvailable": "Inga profiler tillgängliga", - "contactAdminForProfiles": "Kontakta din Plex-administratör för att lägga till profiler", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "Kan inte avgöra biblioteksavdelningen för detta objekt", "logsCleared": "Loggar rensade", "logsCopied": "Loggar kopierade till urklipp", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "Bekräfta åtgärd" }, + "profiles": { + "addPlezyProfile": "Lägg till Plezy-profil", + "switchingProfile": "Byter profil…", + "deleteThisProfileTitle": "Ta bort denna profil?", + "deleteThisProfileMessage": "${displayName} tas bort. Anslutningar påverkas inte.", + "active": "Aktiv", + "manage": "Hantera", + "delete": "Ta bort", + "signOut": "Logga ut", + "signOutPlexTitle": "Logga ut från Plex?", + "signOutPlexMessage": "${displayName} och alla Plex Home-användare på det här kontot tas bort från den här enheten. Du kan logga in igen när som helst.", + "signedOutPlex": "Utloggad från Plex.", + "signOutFailed": "Utloggningen misslyckades.", + "sectionTitle": "Profiler", + "summarySingle": "Lägg till profiler för att blanda hanterade användare och lokala identiteter", + "summaryMultipleWithActive": "${count} profiler · aktiv: ${activeName}", + "summaryMultiple": "${count} profiler", + "removeConnectionTitle": "Ta bort anslutning?", + "removeConnectionMessage": "${displayName} förlorar tillgång till ${connectionLabel}. Anslutningen är fortfarande tillgänglig för andra profiler.", + "deleteProfileTitle": "Ta bort profil?", + "deleteProfileMessage": "Detta tar bort ${displayName} och alla dess anslutningar från den här enheten. De underliggande Plex/Jellyfin-servrarna påverkas inte.", + "profileNameLabel": "Profilnamn", + "pinProtectionLabel": "PIN-skydd", + "pinManagedByPlex": "PIN hanteras av Plex. Redigera på plex.tv.", + "noPinSetEditOnPlex": "Ingen PIN angiven. För att kräva en, redigera Home-användaren på plex.tv.", + "setPin": "Ange PIN", + "connectionsLabel": "Anslutningar", + "add": "Lägg till", + "deleteProfileButton": "Ta bort profil", + "noConnectionsHint": "Inga anslutningar — lägg till en för att använda den här profilen.", + "plexHomeAccount": "Plex Home-konto", + "connectionDefault": "Standard", + "makeDefault": "Gör till standard", + "removeConnection": "Ta bort", + "borrowAddTo": "Lägg till i ${displayName}", + "borrowExplain": "Låna en anslutning från en annan profil. PIN-skyddade källprofiler ber om PIN före delning.", + "borrowEmpty": "Inget att låna ännu.", + "borrowEmptySubtitle": "Anslut först ett Plex-konto eller en Jellyfin-server till en annan profil och kom sedan tillbaka hit.", + "newProfile": "Ny profil", + "profileNameHint": "t.ex. Gäster, Barn, Familjerum", + "pinProtectionOptional": "PIN-skydd (valfritt)", + "pinExplain": "4-siffrig PIN krävs för att växla till den här profilen. Mjuk barriär — vem som helst som kan rensa appdata kan kringgå den.", + "continueButton": "Fortsätt", + "pinsDontMatch": "PIN-koderna stämmer inte överens" + }, + "connections": { + "sectionTitle": "Anslutningar", + "addConnection": "Lägg till anslutning", + "addConnectionSubtitleNoProfile": "Logga in med Plex eller anslut en Jellyfin-server", + "addConnectionSubtitleScoped": "Lägg till ${displayName} — Plex-konto, Jellyfin-server eller låna från en annan profil", + "sessionExpiredOne": "Sessionen har gått ut för ${name}", + "sessionExpiredMany": "Sessionen har gått ut för ${count} servrar", + "signInAgain": "Logga in igen" + }, "discover": { "title": "Upptäck", "switchProfile": "Byt profil", "noContentAvailable": "Inget innehåll tillgängligt", "addMediaToLibraries": "Lägg till media till dina bibliotek", "continueWatching": "Fortsätt titta", + "nextUp": "Nästa", + "recentlyAdded": "Nyligen tillagda", "playEpisode": "S${season}E${episode}", "overview": "Översikt", "cast": "Rollbesättning", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "Sökning misslyckades: ${error}", "connectionTimeout": "Anslutnings-timeout vid laddning ${context}", - "connectionFailed": "Kan inte ansluta till Plex-server", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "Misslyckades att ladda ${context}: ${error}", "noClientAvailable": "Ingen klient tillgänglig", "authenticationFailed": "Autentisering misslyckades: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "Vänligen ange en token", "invalidToken": "Ogiltig token", "failedToVerifyToken": "Misslyckades att verifiera token: ${error}", - "failedToSwitchProfile": "Misslyckades att byta till ${displayName}" + "failedToSwitchProfile": "Misslyckades att byta till ${displayName}", + "failedToDeleteProfile": "Misslyckades att ta bort ${displayName}", + "failedToRate": "Det gick inte att uppdatera betyget" }, "libraries": { "title": "Bibliotek", @@ -595,13 +662,30 @@ "seasons": "Säsonger", "episodes": "Avsnitt", "folders": "Mappar" + }, + "filterCategories": { + "genre": "Genre", + "year": "År", + "contentRating": "Åldersgräns", + "tag": "Tagg" + }, + "sortLabels": { + "title": "Titel", + "dateAdded": "Tillagd", + "releaseDate": "Releasedatum", + "rating": "Betyg", + "lastPlayed": "Senast spelad", + "playCount": "Antal spelningar", + "random": "Slumpmässigt", + "dateShared": "Delningsdatum", + "latestEpisodeAirDate": "Senaste avsnittets sändningsdatum" } }, "about": { "title": "Om", "openSourceLicenses": "Öppen källkod-licenser", "versionLabel": "Version ${version}", - "appDescription": "En vacker Plex-klient för Flutter", + "appDescription": "En vacker Plex- och Jellyfin-klient för Flutter", "viewLicensesDescription": "Visa licenser för tredjepartsbibliotek" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "Väntar på att andra laddar...", "recentRooms": "Senaste rum", "renameRoom": "Byt namn på rum", - "removeRoom": "Ta bort" + "removeRoom": "Ta bort", + "guestSwitchUnavailable": "Kunde inte byta — server inte tillgänglig för synkronisering", + "guestSwitchFailed": "Kunde inte byta — innehåll hittades inte på denna server" }, "downloads": { "title": "Nedladdningar", @@ -817,6 +903,12 @@ "editSyncFilter": "Synkroniseringsfilter", "syncAllItems": "Synkroniserar alla objekt", "syncUnwatchedItems": "Synkroniserar osedda objekt", + "syncRuleServerContext": "Server: ${server} • ${status}", + "syncRuleAvailable": "Tillgänglig", + "syncRuleOffline": "Offline", + "syncRuleSignInRequired": "Inloggning krävs", + "syncRuleNotAvailableForProfile": "Inte tillgänglig för aktuell profil", + "syncRuleUnknownServer": "Okänd server", "syncRuleListCreated": "Synkroniseringsregel skapad" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "Bibliotek", "noLibraries": "Inga bibliotek tillgängliga" } + }, + "addServer": { + "addJellyfinTitle": "Lägg till Jellyfin-server", + "jellyfinUrlIntro": "Ange URL till din Jellyfin-server — t.ex. `https://jellyfin.example.com`. Du kan logga in efteråt.", + "serverUrl": "Server-URL", + "findServer": "Hitta server", + "username": "Användarnamn", + "password": "Lösenord", + "signIn": "Logga in", + "change": "Ändra", + "required": "Krävs", + "couldNotReachServer": "Kunde inte nå servern: ${error}", + "signInFailed": "Inloggning misslyckades: ${error}", + "quickConnectFailed": "Quick Connect misslyckades: ${error}", + "addPlexTitle": "Logga in med Plex", + "plexAuthIntro": "Välj hur du vill logga in på Plex. Webbläsarflödet öppnar plex.tv där du bekräftar anslutningen; QR-alternativet är praktiskt för TV / fjärrenheter.", + "plexQRPrompt": "Skanna denna QR-kod för att logga in.", + "waitingForPlexConfirmation": "Väntar på att plex.tv ska bekräfta inloggningen…", + "pinExpired": "PIN-koden gick ut innan inloggning. Försök igen.", + "duplicatePlexAccount": "Den här enheten är redan inloggad på ett Plex-konto. Logga ut från inställningarna för att byta konto.", + "failedToRegisterAccount": "Kunde inte registrera kontot: ${error}", + "enterJellyfinUrlError": "Ange URL till din Jellyfin-server", + "addConnectionTitle": "Lägg till anslutning", + "addConnectionTitleScoped": "Lägg till i ${name}", + "addConnectionIntroGlobal": "Lägg till ytterligare en medieserver. Du kan blanda Plex-konton och Jellyfin-servrar — innehåll från alla anslutna backends visas tillsammans på startskärmen.", + "addConnectionIntroScoped": "Lägg till en ny server, eller låna en från en annan profil.", + "signInWithPlexCard": "Logga in med Plex", + "signInWithPlexCardSubtitle": "Auktorisera den här enheten mot ditt Plex-konto. Servrar delade med kontot följer med automatiskt.", + "signInWithPlexCardSubtitleScoped": "Auktorisera ett nytt Plex-konto. Dess Home-användare visas som profiler.", + "connectToJellyfinCard": "Anslut till Jellyfin", + "connectToJellyfinCardSubtitle": "Ange URL till din Jellyfin-server och logga in med användarnamn + lösenord (Quick Connect kommer snart).", + "connectToJellyfinCardSubtitleScoped": "Logga in på en Jellyfin-server. Kopplas till ${name}.", + "borrowFromAnotherProfile": "Låna från en annan profil", + "borrowFromAnotherProfileSubtitle": "Återanvänd en anslutning som redan är kopplad till en annan profil. PIN-skyddade källprofiler ber om PIN." } } diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 1f6152b2..b5d6ee73 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -3,13 +3,22 @@ "title": "Plezy" }, "auth": { + "signIn": "登录", "signInWithPlex": "使用 Plex 登录", "showQRCode": "显示二维码", "authenticate": "验证", "authenticationTimeout": "验证超时。请重试。", "scanQRToSignIn": "扫描二维码登录", "waitingForAuth": "等待验证中...\n请在你的浏览器中完成登录。", - "useBrowser": "使用浏览器" + "useBrowser": "使用浏览器", + "or": "或", + "connectToJellyfin": "连接到 Jellyfin", + "useQuickConnect": "使用 Quick Connect", + "quickConnectCode": "Quick Connect 代码", + "quickConnectInstructions": "在浏览器中打开你的 Jellyfin 服务器,登录后从用户菜单中选择 Quick Connect。输入此代码以批准登录。", + "quickConnectWaiting": "等待批准…", + "quickConnectCancel": "取消", + "quickConnectExpired": "Quick Connect 代码在批准前已过期。请重试。" }, "common": { "cancel": "取消", @@ -104,12 +113,12 @@ "gridView": "网格视图", "listView": "列表视图", "showHeroSection": "显示主要精选区", - "useGlobalHubs": "使用 Plex 主页布局", - "useGlobalHubsDescription": "显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。", + "useGlobalHubs": "Use Home Layout", + "useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.", "showServerNameOnHubs": "在推荐栏显示服务器名称", "showServerNameOnHubsDescription": "始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。", "groupLibrariesByServer": "按服务器分组媒体库", - "groupLibrariesByServerDescription": "当您连接到多个服务器时,在侧边栏中为每个 Plex 服务器显示一个标题。", + "groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.", "alwaysKeepSidebarOpen": "始终保持侧边栏展开", "alwaysKeepSidebarOpenDescription": "侧边栏保持展开状态,内容区域自动调整", "showUnwatchedCount": "显示未观看数量", @@ -452,7 +461,7 @@ "musicNotSupported": "尚不支持播放音乐", "noDescriptionAvailable": "暂无描述", "noProfilesAvailable": "没有可用的用户", - "contactAdminForProfiles": "请联系您的 Plex 管理员添加用户", + "contactAdminForProfiles": "Contact your server administrator to add profiles", "unableToDetermineLibrarySection": "无法确定此项目的库分区", "logsCleared": "日志已清除", "logsCopied": "日志已复制到剪贴板", @@ -513,12 +522,68 @@ "dialog": { "confirmAction": "确认操作" }, + "profiles": { + "addPlezyProfile": "添加 Plezy 配置文件", + "switchingProfile": "切换配置文件中…", + "deleteThisProfileTitle": "删除此配置文件?", + "deleteThisProfileMessage": "${displayName} 将被移除。连接本身不受影响。", + "active": "活跃", + "manage": "管理", + "delete": "删除", + "signOut": "退出登录", + "signOutPlexTitle": "退出 Plex 登录?", + "signOutPlexMessage": "${displayName} 以及该账户下的所有 Plex Home 用户将从此设备移除。您可以随时重新登录。", + "signedOutPlex": "已退出 Plex 登录。", + "signOutFailed": "退出登录失败。", + "sectionTitle": "配置文件", + "summarySingle": "添加配置文件以混合托管用户和本地身份", + "summaryMultipleWithActive": "${count} 个配置文件 · 活跃:${activeName}", + "summaryMultiple": "${count} 个配置文件", + "removeConnectionTitle": "移除连接?", + "removeConnectionMessage": "${displayName} 将失去对 ${connectionLabel} 的访问权限。连接本身仍可供其他配置文件使用。", + "deleteProfileTitle": "删除配置文件?", + "deleteProfileMessage": "这将从此设备中删除 ${displayName} 及其所有连接。底层的 Plex/Jellyfin 服务器不会受到影响。", + "profileNameLabel": "配置文件名称", + "pinProtectionLabel": "PIN 保护", + "pinManagedByPlex": "PIN 由 Plex 管理。在 plex.tv 上编辑。", + "noPinSetEditOnPlex": "未设置 PIN。如需要求 PIN,请在 plex.tv 上编辑 Home 用户。", + "setPin": "设置 PIN", + "connectionsLabel": "连接", + "add": "添加", + "deleteProfileButton": "删除配置文件", + "noConnectionsHint": "没有连接 — 添加一个以使用此配置文件。", + "plexHomeAccount": "Plex Home 账户", + "connectionDefault": "默认", + "makeDefault": "设为默认", + "removeConnection": "移除", + "borrowAddTo": "添加到 ${displayName}", + "borrowExplain": "从另一个配置文件借用连接。PIN 保护的源配置文件在共享前会要求输入 PIN。", + "borrowEmpty": "暂无可借用的内容。", + "borrowEmptySubtitle": "请先将 Plex 账户或 Jellyfin 服务器连接到另一个配置文件,然后回到这里。", + "newProfile": "新建配置文件", + "profileNameHint": "例如:访客、儿童、家庭房", + "pinProtectionOptional": "PIN 保护(可选)", + "pinExplain": "切换到此配置文件需要 4 位 PIN。软屏障 — 任何能清除应用数据的人都可以绕过它。", + "continueButton": "继续", + "pinsDontMatch": "PIN 不匹配" + }, + "connections": { + "sectionTitle": "连接", + "addConnection": "添加连接", + "addConnectionSubtitleNoProfile": "使用 Plex 登录或连接 Jellyfin 服务器", + "addConnectionSubtitleScoped": "添加到 ${displayName} — Plex 帐户、Jellyfin 服务器或从其他配置文件借用", + "sessionExpiredOne": "${name} 的会话已过期", + "sessionExpiredMany": "${count} 个服务器的会话已过期", + "signInAgain": "重新登录" + }, "discover": { "title": "发现", "switchProfile": "切换用户", "noContentAvailable": "没有可用内容", "addMediaToLibraries": "请向你的媒体库添加一些媒体", "continueWatching": "继续观看", + "nextUp": "接下来", + "recentlyAdded": "最近添加", "playEpisode": "S${season}E${episode}", "overview": "概述", "cast": "演员表", @@ -532,7 +597,7 @@ "errors": { "searchFailed": "搜索失败: ${error}", "connectionTimeout": "加载 ${context} 时连接超时", - "connectionFailed": "无法连接到 Plex 服务器", + "connectionFailed": "Unable to connect to media server", "failedToLoad": "无法加载 ${context}: ${error}", "noClientAvailable": "没有可用客户端", "authenticationFailed": "验证失败: ${error}", @@ -540,7 +605,9 @@ "pleaseEnterToken": "请输入一个令牌", "invalidToken": "令牌无效", "failedToVerifyToken": "无法验证令牌: ${error}", - "failedToSwitchProfile": "无法切换到 ${displayName}" + "failedToSwitchProfile": "无法切换到 ${displayName}", + "failedToDeleteProfile": "无法删除 ${displayName}", + "failedToRate": "无法更新评分" }, "libraries": { "title": "媒体库", @@ -595,13 +662,30 @@ "seasons": "季", "episodes": "集", "folders": "文件夹" + }, + "filterCategories": { + "genre": "类型", + "year": "年份", + "contentRating": "内容分级", + "tag": "标签" + }, + "sortLabels": { + "title": "标题", + "dateAdded": "添加日期", + "releaseDate": "发行日期", + "rating": "评分", + "lastPlayed": "最近播放", + "playCount": "播放次数", + "random": "随机", + "dateShared": "共享日期", + "latestEpisodeAirDate": "最新一集播出日期" } }, "about": { "title": "关于", "openSourceLicenses": "开源许可证", "versionLabel": "版本 ${version}", - "appDescription": "一款精美的 Flutter Plex 客户端", + "appDescription": "一款精美的 Flutter Plex 和 Jellyfin 客户端", "viewLicensesDescription": "查看第三方库的许可证" }, "serverSelection": { @@ -766,7 +850,9 @@ "waitingForParticipants": "等待其他人加载...", "recentRooms": "最近的房间", "renameRoom": "重命名房间", - "removeRoom": "移除" + "removeRoom": "移除", + "guestSwitchUnavailable": "无法切换 — 服务器无法同步", + "guestSwitchFailed": "无法切换 — 在此服务器上未找到内容" }, "downloads": { "title": "下载", @@ -817,6 +903,12 @@ "editSyncFilter": "同步筛选", "syncAllItems": "同步所有项目", "syncUnwatchedItems": "同步未观看项目", + "syncRuleServerContext": "服务器: ${server} • ${status}", + "syncRuleAvailable": "可用", + "syncRuleOffline": "离线", + "syncRuleSignInRequired": "需要登录", + "syncRuleNotAvailableForProfile": "当前个人资料不可用", + "syncRuleUnknownServer": "未知服务器", "syncRuleListCreated": "同步规则已创建" }, "shaders": { @@ -1077,5 +1169,39 @@ "libraries": "媒体库", "noLibraries": "没有可用的媒体库" } + }, + "addServer": { + "addJellyfinTitle": "添加 Jellyfin 服务器", + "jellyfinUrlIntro": "输入你的 Jellyfin 服务器 URL — 例如 `https://jellyfin.example.com`。可在之后登录。", + "serverUrl": "服务器 URL", + "findServer": "查找服务器", + "username": "用户名", + "password": "密码", + "signIn": "登录", + "change": "更改", + "required": "必填", + "couldNotReachServer": "无法连接到服务器: ${error}", + "signInFailed": "登录失败: ${error}", + "quickConnectFailed": "Quick Connect 失败: ${error}", + "addPlexTitle": "使用 Plex 登录", + "plexAuthIntro": "选择登录 Plex 的方式。浏览器流程会打开 plex.tv 让你确认连接;QR 选项适合电视 / 远程设备。", + "plexQRPrompt": "扫描此 QR 码以登录。", + "waitingForPlexConfirmation": "等待 plex.tv 确认登录…", + "pinExpired": "PIN 在登录前已过期。请重试。", + "duplicatePlexAccount": "此设备已登录到一个 Plex 帐户。请在设置中退出登录以切换帐户。", + "failedToRegisterAccount": "注册帐户失败: ${error}", + "enterJellyfinUrlError": "输入你的 Jellyfin 服务器 URL", + "addConnectionTitle": "添加连接", + "addConnectionTitleScoped": "添加到 ${name}", + "addConnectionIntroGlobal": "添加另一台媒体服务器。你可以混合使用 Plex 帐户和 Jellyfin 服务器 — 所有已连接后端的项目会一起显示在主页。", + "addConnectionIntroScoped": "添加新服务器,或从另一个配置文件借用。", + "signInWithPlexCard": "使用 Plex 登录", + "signInWithPlexCardSubtitle": "为你的 Plex 帐户授权此设备。与该帐户共享的服务器会自动加入。", + "signInWithPlexCardSubtitleScoped": "授权一个新的 Plex 帐户。其 Home 用户会显示为配置文件。", + "connectToJellyfinCard": "连接到 Jellyfin", + "connectToJellyfinCardSubtitle": "输入你的 Jellyfin 服务器 URL,使用用户名 + 密码登录(Quick Connect 即将支持)。", + "connectToJellyfinCardSubtitleScoped": "登录到 Jellyfin 服务器。绑定到 ${name}。", + "borrowFromAnotherProfile": "从另一个配置文件借用", + "borrowFromAnotherProfileSubtitle": "重用已附加到另一个配置文件的连接。受 PIN 保护的来源配置文件会要求输入 PIN。" } } diff --git a/lib/main.dart b/lib/main.dart index 531e73fe..09c7b5d8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,8 +11,19 @@ import 'package:window_manager/window_manager.dart'; import 'package:provider/provider.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'connection/connection.dart'; +import 'connection/connection_bootstrap.dart'; +import 'connection/connection_registry.dart'; +import 'profiles/active_profile_binder.dart'; +import 'profiles/active_profile_provider.dart'; +import 'profiles/profile.dart'; +import 'profiles/profile_connection_registry.dart'; +import 'profiles/profile_registry.dart'; +import 'profiles/plex_home_service.dart'; import 'screens/main_screen.dart'; import 'screens/auth_screen.dart'; +import 'screens/profile/pin_entry_dialog.dart'; +import 'screens/profile/profile_switch_screen.dart'; import 'services/storage_service.dart'; import 'services/macos_window_service.dart'; import 'services/native_window_service.dart'; @@ -42,7 +53,6 @@ import 'utils/snackbar_helper.dart'; import 'watch_together/providers/watch_together_provider.dart'; import 'services/multi_server_manager.dart'; import 'services/offline_watch_sync_service.dart'; -import 'services/server_connection_orchestrator.dart'; import 'services/data_aggregation_service.dart'; import 'services/in_app_review_service.dart'; import 'services/server_registry.dart'; @@ -50,13 +60,13 @@ import 'services/download_manager_service.dart'; import 'services/pip_service.dart'; import 'services/download_storage_service.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'services/jellyfin_api_cache.dart'; import 'services/plex_api_cache.dart'; import 'database/app_database.dart'; import 'screens/video_player_screen.dart'; import 'utils/app_logger.dart'; -import 'utils/plex_http_client.dart' show httpClient; +import 'utils/media_server_http_client.dart' show httpClient; import 'utils/orientation_helper.dart'; -import 'utils/global_key_utils.dart'; import 'utils/watch_state_notifier.dart'; import 'i18n/strings.g.dart'; import 'focus/input_mode_tracker.dart'; @@ -69,7 +79,6 @@ import 'package:package_info_plus/package_info_plus.dart'; const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false); const String gitCommit = String.fromEnvironment('GIT_COMMIT'); const String _sentryEnvironment = String.fromEnvironment('SENTRY_ENVIRONMENT'); -const String _plexTokenDefine = String.fromEnvironment('PLEX_TOKEN'); // Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events // at (0,0) as barrier taps, causing modals to dismiss immediately. @@ -100,7 +109,10 @@ void _registerTvosPlatformPlugins() { } Future main() async { - WidgetsFlutterBinding.ensureInitialized(); + final binding = WidgetsFlutterBinding.ensureInitialized(); + // Build the semantics tree in debug so Maestro/UI automation can locate + // widgets by text. Zero cost in release builds. + if (kDebugMode) binding.ensureSemantics(); _installZeroOffsetPointerGuard(); // Workaround for iPadOS 26.1+ modal dismissal bug // On tvOS, Flutter's generated plugin registrant doesn't run (no tvOS @@ -182,11 +194,9 @@ Future _bootstrapApp() async { // Wait for all parallel services to complete await Future.wait(futures); - // Seed Plex token from dart-define (used by screenshot automation) - if (_plexTokenDefine.isNotEmpty) { - final storage = await StorageService.getInstance(); - await storage.savePlexToken(_plexTokenDefine); - } + // The PLEX_TOKEN dart-define (screenshot automation) is consumed by + // [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry + // is available — keeps the deprecated legacy slots out of runtime paths. // Initialize logger level based on debug setting final debugEnabled = settings.read(SettingsService.enableDebugLogging); @@ -388,6 +398,17 @@ void _registerShaderLicenses() { final RouteObserver routeObserver = RouteObserver(); final rootNavigatorKey = GlobalKey(); +/// Top-level PIN prompt used by [ActiveProfileBinder] when it runs above the +/// per-screen widget tree. Routes through [rootNavigatorKey] so the dialog +/// renders correctly whether the binder fires from the splash, MainScreen, +/// or any future host. Returns `null` when no Navigator is available yet +/// (early boot, post-dispose) so the binder treats it as "PIN cancelled". +Future _rootPinPrompt(Profile profile, {String? errorMessage}) { + final ctx = rootNavigatorKey.currentContext; + if (ctx == null) return Future.value(null); + return showPinEntryDialog(ctx, profile.displayName, errorMessage: errorMessage); +} + class MainApp extends StatefulWidget { const MainApp({super.key}); @@ -439,9 +460,15 @@ class _MainAppState extends State with WidgetsBindingObserver { // Initialize API cache with database PlexApiCache.initialize(_appDatabase); + JellyfinApiCache.initialize(_appDatabase); _downloadManager = DownloadManagerService(database: _appDatabase, storageService: DownloadStorageService.instance); - _downloadManager.setClientResolver(_serverManager.getClient); + _downloadManager.setClientResolver((serverId, {clientScopeId}) { + if (clientScopeId != null && clientScopeId.isNotEmpty) { + return _serverManager.getJellyfinClientByCompoundId(clientScopeId) ?? _serverManager.getClient(serverId); + } + return _serverManager.getClient(serverId); + }); _downloadManager.recoveryFuture = _downloadManager.recoverInterruptedDownloads(); _offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager); @@ -536,10 +563,10 @@ class _MainAppState extends State with WidgetsBindingObserver { _isAutoDeleteRunning = true; try { await downloadProvider.refreshMetadataFromCache(); - final activeKey = VideoPlayerScreenState.activeRatingKey; + final activeKey = VideoPlayerScreenState.activeId; final settings = SettingsService.instanceOrNull; if (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) { - final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeRatingKey: activeKey); + final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeId: activeKey); if (deleted.isNotEmpty) { final msg = deleted.length == 1 ? t.messages.autoRemovedWatchedDownload(title: deleted.first) @@ -616,7 +643,60 @@ class _MainAppState extends State with WidgetsBindingObserver { Widget build(BuildContext context) { return MultiProvider( providers: [ - ChangeNotifierProvider(create: (context) => MultiServerProvider(_serverManager, _aggregationService)), + // Expose AppDatabase + ConnectionRegistry so screens (Settings, Setup) + // can manage stored Jellyfin/Plex connections without re-creating + // the registry per-call site. + Provider.value(value: _appDatabase), + Provider(create: (_) => ConnectionRegistry(_appDatabase)), + Provider(create: (_) => ProfileRegistry(_appDatabase)), + Provider(create: (_) => ProfileConnectionRegistry(_appDatabase)), + Provider( + create: (context) { + // start() resolves StorageService internally — the singleton was + // already initialised eagerly during boot, so the await is a + // microtask hop in practice. + final service = PlexHomeService( + connections: context.read(), + profileConnections: context.read(), + ); + unawaited(service.start()); + return service; + }, + dispose: (_, s) => s.dispose(), + ), + ChangeNotifierProvider( + create: (context) { + final provider = ActiveProfileProvider( + registry: context.read(), + plexHome: context.read(), + connections: context.read(), + ); + unawaited(provider.initialize()); + return provider; + }, + ), + ChangeNotifierProvider( + create: (context) { + _serverManager.onJellyfinConnectionUpdated = context.read().upsert; + return MultiServerProvider(_serverManager, _aggregationService); + }, + ), + // Profile binder owns the cold-start client connect: Plex token + // refresh + Jellyfin client creation. Hoisted out of MainScreen so + // the splash can await its first settle — without this, MainScreen + // mounts (and discover/libraries query) before any client exists. + Provider( + lazy: false, + create: (context) => ActiveProfileBinder( + activeProfile: context.read(), + connections: context.read(), + profileConnections: context.read(), + serverManager: _serverManager, + multiServerProvider: context.read(), + pinPrompt: _rootPinPrompt, + )..start(), + dispose: (_, binder) => binder.dispose(), + ), // Offline mode provider - depends on MultiServerProvider ChangeNotifierProxyProvider( create: (_) { @@ -630,15 +710,26 @@ class _MainAppState extends State with WidgetsBindingObserver { return provider; }, ), - // Download provider - ChangeNotifierProvider( + // Download provider. Downloads are shared, but sync rules are scoped to + // the active profile and reload when the profile changes. + ChangeNotifierProxyProvider( create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase), + update: (context, activeProfile, previous) { + final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase); + provider.setActiveProfileId(activeProfile.activeId); + return provider; + }, ), // Offline watch sync service - ChangeNotifierProvider( + ChangeNotifierProxyProvider( create: (context) { final offlineModeProvider = context.read(); final downloadProvider = context.read(); + final activeProfile = context.read(); + _offlineWatchSyncService.setActiveProfileId( + activeProfile.activeId, + availableProfileCount: activeProfile.profiles.length, + ); // Offline-sync drain replays a batch of queued watch actions without // per-item data, so we can't target rules — force a full pass. @@ -652,12 +743,9 @@ class _MainAppState extends State with WidgetsBindingObserver { // binge-watching coalesces into one pass. _watchStateSubscription = WatchStateNotifier().stream.listen((event) { if (event.changeType != WatchStateChangeType.watched) return; - if (VideoPlayerScreenState.activeRatingKey == event.ratingKey) return; + if (VideoPlayerScreenState.activeId == event.itemId) return; - _pendingSyncKeys.add(event.globalKey); - for (final parentKey in event.parentChain) { - _pendingSyncKeys.add(buildGlobalKey(event.serverId, parentKey)); - } + _pendingSyncKeys.addAll(downloadProvider.syncRuleKeysForWatchEvent(event)); _syncDebounce?.cancel(); _syncDebounce = Timer(const Duration(seconds: 5), () { @@ -676,6 +764,11 @@ class _MainAppState extends State with WidgetsBindingObserver { _offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider); return _offlineWatchSyncService; }, + update: (_, activeProfile, previous) { + final provider = previous ?? _offlineWatchSyncService; + provider.setActiveProfileId(activeProfile.activeId, availableProfileCount: activeProfile.profiles.length); + return provider; + }, ), // Offline watch provider - depends on sync service and download provider ChangeNotifierProxyProvider2( @@ -688,7 +781,19 @@ class _MainAppState extends State with WidgetsBindingObserver { }, ), // Existing providers - ChangeNotifierProvider(create: (context) => UserProfileProvider()), + ChangeNotifierProxyProvider2( + create: (_) => UserProfileProvider(), + update: (context, activeProfile, connections, previous) { + final provider = previous ?? UserProfileProvider(); + provider.attach( + connections: connections, + activeProfile: activeProfile, + profileConnections: context.read(), + serverManager: context.read().serverManager, + ); + return provider; + }, + ), ChangeNotifierProvider(create: (context) => ThemeProvider()), // Tracker accounts — depend on UserProfileProvider for per-profile // session scoping. Hydrated and rebound by `_TrackerProfileBootstrap`. @@ -809,15 +914,15 @@ class _AppleTvScale extends StatelessWidget { } } -/// Hydrates Trakt and MAL/AniList/Simkl providers with the active Plex -/// profile's sessions and rebinds their services whenever the user switches. +/// Hydrates Trakt and MAL/AniList/Simkl providers with the active profile's +/// sessions and rebinds their services whenever the user switches profiles. /// /// Lives high in the widget tree (above MaterialApp) so the listener survives /// route changes. [onFirstMount] runs exactly once after the first /// `didChangeDependencies`. class _TrackerProfileBootstrap extends StatefulWidget { final Widget child; - final List Function(String? uuid)> onProfileChanged; + final List Function(String? profileId)> onProfileChanged; final VoidCallback? onFirstMount; const _TrackerProfileBootstrap({required this.child, required this.onProfileChanged, this.onFirstMount}); @@ -827,19 +932,19 @@ class _TrackerProfileBootstrap extends StatefulWidget { } class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> { - UserProfileProvider? _profile; - String? _lastUuid; + ActiveProfileProvider? _provider; + String? _lastId; bool _initialized = false; @override void didChangeDependencies() { super.didChangeDependencies(); - final profile = context.read(); + final provider = context.read(); - if (!identical(_profile, profile)) { - _profile?.removeListener(_onProfileChanged); - _profile = profile; - _profile!.addListener(_onProfileChanged); + if (!identical(_provider, provider)) { + _provider?.removeListener(_onProfileChanged); + _provider = provider; + _provider!.addListener(_onProfileChanged); } if (!_initialized) { @@ -850,12 +955,12 @@ class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> { } void _onProfileChanged() { - final uuid = _profile?.currentUser?.uuid; - if (uuid == _lastUuid) return; - _lastUuid = uuid; + final id = _provider?.activeId; + if (id == _lastId) return; + _lastId = id; for (final fn in widget.onProfileChanged) { unawaited( - fn(uuid).catchError((Object e, StackTrace s) { + fn(id).catchError((Object e, StackTrace s) { appLogger.w('Tracker profile bootstrap failed', error: e, stackTrace: s); }), ); @@ -864,7 +969,7 @@ class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> { @override void dispose() { - _profile?.removeListener(_onProfileChanged); + _provider?.removeListener(_onProfileChanged); super.dispose(); } @@ -925,6 +1030,25 @@ class _SetupScreenState extends State { final storage = await StorageService.getInstance(); final registry = ServerRegistry(storage); + // Idempotent: brings legacy SharedPreferences state (plexToken, + // currentUserUUID, homeUsersCache) into the new ConnectionRegistry + + // ProfileRegistry tables. No-op on subsequent launches. + if (mounted) { + try { + final connRegistry = context.read(); + final profileRegistry = context.read(); + final bootstrap = ConnectionBootstrap( + storage: storage, + connectionRegistry: connRegistry, + serverRegistry: registry, + profileRegistry: profileRegistry, + ); + await bootstrap.run(); + } catch (e, st) { + appLogger.w('Boot-time migration failed', error: e, stackTrace: st); + } + } + // Check network connectivity early to fast-path airplane mode. // Timeout guards against connectivity_plus hanging on some Android TV devices after force-close. bool hasNetwork; @@ -944,28 +1068,17 @@ class _SetupScreenState extends State { Sentry.addBreadcrumb(Breadcrumb(message: 'Network check done: hasNetwork=$hasNetwork', category: 'setup')), ); - if (hasNetwork) { - _setStatus(t.common.refreshingServers); - - // Refresh servers from API to get updated connection info (IPs may change). - // If the stored token is invalid (e.g. after removing a Plex profile PIN), - // redirect to AuthScreen so the user can re-authenticate. - final refreshResult = await registry.refreshServersFromApi(); - if (refreshResult == ServerRefreshResult.authError) { - await storage.clearCredentials(); - if (mounted) { - unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen()))); - } - return; - } - } - _setStatus(t.common.loadingServers); - // Load all configured servers - final servers = await registry.getServers(); + if (!mounted) return; - if (servers.isEmpty) { + // Snapshot ConnectionRegistry before we cross any awaits — Provider lookups + // through `context` after async gaps trip the use_build_context_synchronously + // lint, and reading early is safe because the registry is a singleton. + final connectionRegistry = context.read(); + final allConnections = await connectionRegistry.list(); + + if (allConnections.isEmpty) { if (mounted) { unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen()))); } @@ -983,67 +1096,118 @@ class _SetupScreenState extends State { return; } - unawaited( - Sentry.addBreadcrumb(Breadcrumb(message: 'Connecting to ${servers.length} server(s)', category: 'setup')), - ); - _setStatus(t.common.connectingToServers); - - // Populate per-server status for splash display + // Populate per-server status from the registry for the splash list. if (mounted) { setState(() { - for (final server in servers) { - _serverStatus[server.clientIdentifier] = (server.name, null); + for (final conn in allConnections) { + if (conn is PlexAccountConnection) { + for (final s in conn.servers) { + _serverStatus[s.clientIdentifier] = (s.name, null); + } + } else if (conn is JellyfinConnection) { + _serverStatus[conn.serverMachineId] = (conn.serverName, null); + } } }); } - try { - final result = await ServerConnectionOrchestrator.connectAndInitialize( - servers: servers, - multiServerProvider: context.read(), - librariesProvider: context.read(), - syncService: context.read(), - clientIdentifier: storage.getClientIdentifier(), - onServerStatus: (serverId, success) { - if (mounted) { - setState(() { - final existing = _serverStatus[serverId]; - if (existing != null) { - _serverStatus[serverId] = (existing.$1, success); - } - }); - } - }, - ); + final plexCount = allConnections.whereType().fold(0, (n, c) => n + c.servers.length); + final jellyfinCount = allConnections.whereType().length; + unawaited( + Sentry.addBreadcrumb( + Breadcrumb( + message: 'Handing off to MainScreen with $plexCount Plex server(s) + $jellyfinCount Jellyfin', + category: 'setup', + ), + ), + ); + _setStatus(t.common.connectingToServers); + // Snapshot Provider refs before further awaits. + final activeProfile = context.read(); + // Reading the binder here is enough — the Provider is `lazy: false` so + // it has already constructed the binder and called `start()` during + // MultiProvider build. We just need to wait for it. + context.read(); + final downloadProvider = context.read(); + + // Wait for the active profile to load from disk so the binder has a + // profile to bind. `initialize` is fire-and-forget at provider creation, + // so awaiting here pulls control through the same future and triggers + // the listener-driven rebind synchronously. + await activeProfile.initialize(); + if (!mounted) return; + + // Wire the per-server status listener before either branch so the splash + // checkmarks fill in even while the user is choosing a profile. + _bindServerStatusListener(activeProfile, _serverManagerFromContext); + + // If "prompt for profile on launch" is on (or no profile is selected + // yet), surface the picker BEFORE waiting for the previously-active + // profile's bind to settle — otherwise the user sees the splash fully + // connect before the prompt arrives. The picker's own `_switchTo` calls + // `awaitBindingSettle` after activation, so by the time it pops, the + // chosen profile's bind is settled. + final settings = await SettingsService.getInstance(); + if (!mounted) return; + final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; + final requireOnOpen = + settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; + final shouldPrompt = hasNoActive || requireOnOpen; + + if (shouldPrompt) { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true))); + if (!mounted) return; + } else { + // Now wait for the binder to settle. This is the Plex/Jellyfin server + // 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 + // available" race the old eager-navigate flow caused. + await activeProfile.awaitBindingSettle(); if (!mounted) return; - - if (result.hasConnections && result.firstClient != null) { - // Resume any downloads that were interrupted by app kill - final downloadProvider = context.read(); - unawaited( - downloadProvider.ensureInitialized().then((_) { - downloadProvider.resumeQueuedDownloads(result.firstClient!); - }), - ); - - unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!)))); - } else { - _setStatus(t.common.startingOfflineMode); - await context.read().ensureInitialized(); - if (!mounted) return; - unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)))); - } - } catch (e, stackTrace) { - appLogger.e('Error during multi-server connection', error: e, stackTrace: stackTrace); - - if (mounted) { - _setStatus(t.common.startingOfflineMode); - await context.read().ensureInitialized(); - if (!mounted) return; - unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)))); - } } + + // Repopulate metadata for downloaded items now that per-backend caches + // are resolvable (the Connections row + live JellyfinClient are in + // place). Without this the downloads list and sync-rule titles render + // empty until something forces a later refresh. + await downloadProvider.refreshMetadataFromCache(); + if (!mounted) return; + + unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(initialPromptHandled: shouldPrompt)))); + } + + /// Wire per-server status updates from [MultiServerManager] into the + /// splash list so the user sees check/cross marks land as the binder + /// brings each client online. Best-effort: stops listening when the + /// state goes away. + StreamSubscription>? _statusSub; + + void _bindServerStatusListener(ActiveProfileProvider _, MultiServerManager Function() resolveManager) { + _statusSub?.cancel(); + final manager = resolveManager(); + _statusSub = manager.statusStream.listen((status) { + if (!mounted) return; + setState(() { + for (final entry in status.entries) { + final existing = _serverStatus[entry.key]; + if (existing != null) { + _serverStatus[entry.key] = (existing.$1, entry.value); + } + } + }); + }); + } + + MultiServerManager _serverManagerFromContext() => context.read().serverManager; + + @override + void dispose() { + _statusSub?.cancel(); + super.dispose(); } Widget _buildStatusText(BuildContext context) { diff --git a/lib/media/download_resolution.dart b/lib/media/download_resolution.dart new file mode 100644 index 00000000..09be1f87 --- /dev/null +++ b/lib/media/download_resolution.dart @@ -0,0 +1,53 @@ +/// Backend-neutral download resolution types. +/// +/// Returned by [MediaServerClient] download-resolution methods so the +/// [DownloadManagerService] doesn't need to know whether it's talking to +/// Plex or Jellyfin. +library; + +/// Spec for a single external subtitle track that should be downloaded +/// alongside the video file. +/// +/// `id` is a backend-stable integer used in the on-disk filename +/// (Plex stream id, Jellyfin stream index). +class DownloadSubtitleSpec { + final int id; + final String url; + final String? codec; + final String? language; + final String? languageCode; + final bool forced; + final String? displayTitle; + + const DownloadSubtitleSpec({ + required this.id, + required this.url, + this.codec, + this.language, + this.languageCode, + this.forced = false, + this.displayTitle, + }); +} + +/// Spec for a single artwork file. `localKey` is the deterministic key the +/// storage service hashes to compute the on-disk filename — Plex passes the +/// backend-relative path so cache deduplication works across items that +/// reference the same blob; Jellyfin passes the absolute URL since artwork +/// URLs are already unique per item after stripping auth query parameters. +class DownloadArtworkSpec { + final String localKey; + final String url; + + const DownloadArtworkSpec({required this.localKey, required this.url}); +} + +/// Bundle of everything the download pipeline needs to fetch the primary +/// video file and its companion subtitle sidecars for a chosen media +/// version. +class DownloadResolution { + final String? videoUrl; + final List externalSubtitles; + + const DownloadResolution({required this.videoUrl, this.externalSubtitles = const []}); +} diff --git a/lib/media/library_filter_result.dart b/lib/media/library_filter_result.dart new file mode 100644 index 00000000..a440d880 --- /dev/null +++ b/lib/media/library_filter_result.dart @@ -0,0 +1,13 @@ +import 'media_filter.dart'; + +/// Combined filter listing result. Plex returns categories with no values +/// pre-loaded; Jellyfin pre-populates [cachedValues] so the FiltersBottomSheet +/// avoids a second round-trip per category. +class LibraryFilterResult { + final List filters; + final Map> cachedValues; + + const LibraryFilterResult({required this.filters, required this.cachedValues}); + + static const empty = LibraryFilterResult(filters: [], cachedValues: {}); +} diff --git a/lib/media/library_first_character.dart b/lib/media/library_first_character.dart new file mode 100644 index 00000000..f8738a1e --- /dev/null +++ b/lib/media/library_first_character.dart @@ -0,0 +1,16 @@ +/// One entry in the alpha-jump bar — a letter (or `#` bucket for items +/// starting with a digit / non-letter) plus the count of items beginning +/// with it. +/// +/// Plex's `/library/sections/{id}/firstCharacter` endpoint returns these +/// natively (counts let the UI scroll to a cumulative offset). Jellyfin +/// has no equivalent endpoint, so [JellyfinClient.fetchFirstCharacters] +/// synthesises a 27-letter alphabet with `size: 1` per entry — the bar +/// then acts as a name-prefix filter rather than a scroll affordance. +class LibraryFirstCharacter { + final String key; + final String title; + final int size; + + const LibraryFirstCharacter({required this.key, required this.title, required this.size}); +} diff --git a/lib/media/library_query.dart b/lib/media/library_query.dart new file mode 100644 index 00000000..cc634870 --- /dev/null +++ b/lib/media/library_query.dart @@ -0,0 +1,114 @@ +import 'media_kind.dart'; + +/// Sort order applied to a library query. +enum LibrarySortDirection { ascending, descending } + +class LibrarySort { + /// Backend-neutral sort field. Common values: `addedAt`, `originallyAvailableAt`, + /// `lastViewedAt`, `title`, `rating`, `viewCount`, `random`. + final String field; + final LibrarySortDirection direction; + + const LibrarySort({required this.field, this.direction = LibrarySortDirection.descending}); +} + +/// A single filter clause. The semantics of `field` and `value` are +/// backend-translated — the neutral query just carries the intent. +class LibraryFilter { + final String field; + final String op; // "=", "!=", "contains", ">=", etc. + final List values; + + const LibraryFilter({required this.field, this.op = '=', required this.values}); +} + +/// Backend-neutral library content query. Each backend's adapter translates +/// these into its own query DSL (Plex `/library/sections/{id}/all?type=...` +/// or Jellyfin `/Items?ParentId=...&Filters=...`). +class LibraryQuery { + /// Restrict to a single kind (e.g. `MediaKind.movie`). Null = library default. + final MediaKind? kind; + + /// Pagination — zero-based offset. + final int offset; + final int limit; + + final LibrarySort? sort; + final List filters; + + /// Free-text search restricted to this library. Distinct from the global + /// search endpoint. + final String? search; + + /// Whether to include items the active user has already watched. + final bool includeWatched; + + /// Restrict the result to items whose sort name starts with this string — + /// the alpha-jump bar's filter UX. The literal `#` is a sentinel for + /// "non-alphabetic" and translates to a `NameLessThan=A` query for backends + /// that support it. + final String? nameStartsWith; + + /// Genre filter — used by the per-library filter sheet. Backends that + /// take multiple values (Jellyfin) AND/intersect; those that take one + /// (Plex's existing flow) consult `filters` instead. + final List? genres; + final List? officialRatings; + final List? years; + final List? tags; + + const LibraryQuery({ + this.kind, + this.offset = 0, + this.limit = 50, + this.sort, + this.filters = const [], + this.search, + this.includeWatched = true, + this.nameStartsWith, + this.genres, + this.officialRatings, + this.years, + this.tags, + }); + + LibraryQuery copyWith({ + MediaKind? kind, + int? offset, + int? limit, + LibrarySort? sort, + List? filters, + String? search, + bool? includeWatched, + String? nameStartsWith, + List? genres, + List? officialRatings, + List? years, + List? tags, + }) { + return LibraryQuery( + kind: kind ?? this.kind, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + sort: sort ?? this.sort, + filters: filters ?? this.filters, + search: search ?? this.search, + includeWatched: includeWatched ?? this.includeWatched, + nameStartsWith: nameStartsWith ?? this.nameStartsWith, + genres: genres ?? this.genres, + officialRatings: officialRatings ?? this.officialRatings, + years: years ?? this.years, + tags: tags ?? this.tags, + ); + } +} + +/// Page of items returned by [MediaServerClient.getLibraryContent]. +/// Carries the total count so the UI can render correct pagination affordances. +class LibraryPage { + final List items; + final int totalCount; + final int offset; + + const LibraryPage({required this.items, required this.totalCount, this.offset = 0}); +} diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart new file mode 100644 index 00000000..d2a8af92 --- /dev/null +++ b/lib/media/live_tv_support.dart @@ -0,0 +1,84 @@ +import '../models/livetv_channel.dart'; +import '../models/livetv_dvr.dart'; +import '../models/livetv_program.dart'; + +enum FavoriteChannelPersistenceMode { + /// A single write replaces the full backend account's favorite list. + sharedFullList, + + /// Writes must only include the favorites owned by this server/source. + serverSlice, +} + +class LiveTvStreamResolution { + final String url; + final String? playSessionId; + + const LiveTvStreamResolution({required this.url, this.playSessionId}); +} + +/// Backend-neutral live-TV operations. Implementations are obtained via +/// [MediaServerClient.liveTv]; the getter returns `null` when the server has no +/// live-TV support configured. +/// +/// Plex servers expose multiple per-DVR lineups (`/livetv/dvrs`), Jellyfin +/// servers expose a single flat channel list. The interface flattens both: +/// callers that need DVR identity for Plex's per-lineup channel fetch use +/// [fetchDvrs]; callers that only need the channel list pass the optional +/// [lineup] (Plex provider identifier) to [fetchChannels]. +/// +/// Stream URL resolution differs sharply by backend: Plex's DVR allocates a +/// transcode session and returns a session-scoped path that requires +/// follow-up calls (`tuneChannel` + `buildLiveStreamPath`). Jellyfin returns a +/// direct-play URL. [resolveStreamUrl] returns the Jellyfin URL directly; +/// Plex callers use the existing `client + dvrKey` plumbing inside the player. +abstract class LiveTvSupport { + /// Fast probe — `true` when this server has live-TV configured. Plex calls + /// `/livetv/dvrs` and returns true when any DVR exists; Jellyfin probes + /// `/LiveTv/Channels?limit=1`. + Future isAvailable(); + + /// Plex returns one entry per configured DVR; Jellyfin returns an empty + /// list (it has no per-DVR partitioning). + Future> fetchDvrs(); + + /// Channel list. Plex callers may pass [lineup] (the EPG provider + /// identifier from a DVR's lineup) to scope to a specific provider's + /// channels. Jellyfin ignores [lineup] and returns the flat list. + Future> fetchChannels({String? lineup}); + + /// EPG / programs grid covering [from]..[to]. Plex queries + /// `/livetv/dvrs/{dvrKey}/grid`; Jellyfin queries `/LiveTv/Programs`. + Future> fetchSchedule({DateTime? from, DateTime? to}); + + /// Resolve a playable stream URL for [channelKey]. + /// + /// Jellyfin returns a negotiated stream URL plus the play session id. Plex + /// returns `null` because its stream URL is only valid after a `tuneChannel` + /// call; the player's Plex branch uses `client + dvrKey` instead. + Future resolveStreamUrl(String channelKey, {String? dvrKey}); + + /// Source URI to stamp into [FavoriteChannel] entries. Plex uses + /// `server://{machineId}/{providerId}` so its cloud-synced favorites are + /// keyed per EPG provider. Jellyfin uses `server://{serverId}/jellyfin` + /// (no provider concept). + Future buildFavoriteChannelSource({String? lineup}); + + /// Runtime store identity used to avoid fetching/writing a shared favorite + /// backend more than once. Plex is cloud/account-scoped; Jellyfin is + /// server-user scoped. + String get favoriteStoreKey; + + FavoriteChannelPersistenceMode get favoritePersistenceMode; + + /// Read the user's favorite channels for this server. Plex pulls from the + /// cloud-synced list; Jellyfin queries `IsFavorite=true` with locally + /// stored ordering. + Future> fetchFavoriteChannels(); + + /// Persist the favorites list (and order, where supported). Plex pushes + /// to its cloud sync endpoint; Jellyfin POSTs/DELETEs the + /// `/Users/{userId}/FavoriteItems/{channelId}` flag and saves the order + /// locally. + Future setFavoriteChannels(List channels); +} diff --git a/lib/media/media_backend.dart b/lib/media/media_backend.dart new file mode 100644 index 00000000..98aa325f --- /dev/null +++ b/lib/media/media_backend.dart @@ -0,0 +1,38 @@ +import '../utils/app_logger.dart'; + +/// Backend identifier for a media item, library, or server. +/// +/// Used as a discriminator on neutral domain types so consumers can branch on +/// backend-specific behavior (e.g. only Plex supports server-side play queues +/// in v1) and so persisted records can round-trip the source of an item. +enum MediaBackend { + plex, + jellyfin; + + String get id => switch (this) { + MediaBackend.plex => 'plex', + MediaBackend.jellyfin => 'jellyfin', + }; + + static MediaBackend fromId(String id) => switch (id) { + 'plex' => MediaBackend.plex, + 'jellyfin' => MediaBackend.jellyfin, + _ => throw ArgumentError('Unknown MediaBackend id: $id'), + }; + + /// Like [fromId] but tolerates legacy/missing values by defaulting to Plex. + /// Used by JSON deserialization of cached offline data: + /// - `null` is the pre-Jellyfin shape and silently defaults to Plex. + /// - An unrecognized non-null id logs a warning and defaults to Plex; this + /// 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') { + appLogger.w('Unknown MediaBackend id "$id"; defaulting to plex'); + } + return switch (id) { + 'jellyfin' => MediaBackend.jellyfin, + _ => MediaBackend.plex, + }; + } +} diff --git a/lib/models/plex_file_info.dart b/lib/media/media_file_info.dart similarity index 52% rename from lib/models/plex_file_info.dart rename to lib/media/media_file_info.dart index eddbe553..1af945b1 100644 --- a/lib/models/plex_file_info.dart +++ b/lib/media/media_file_info.dart @@ -1,7 +1,14 @@ import '../utils/formatters.dart'; -import 'plex_media_info.dart'; +import 'media_source_info.dart'; -class PlexFileInfo { +/// Backend-neutral file-info payload rendered by [FileInfoBottomSheet]. +/// +/// Both Plex and Jellyfin can populate any subset of these fields; rows that +/// don't apply to the active backend are left null and skipped at render +/// time. Plex fills the full set from `/library/metadata/{id}` (Media + Part +/// + Stream); Jellyfin fills the subset that's available on inline +/// `MediaSources`. +class MediaFileInfo { // Media level properties final String? container; final String? videoCodec; @@ -34,10 +41,10 @@ class PlexFileInfo { final String? audioChannelLayout; // Multi-track support - final List audioTracks; - final List subtitleTracks; + final List audioTracks; + final List subtitleTracks; - PlexFileInfo({ + MediaFileInfo({ this.container, this.videoCodec, this.videoResolution, @@ -67,73 +74,48 @@ class PlexFileInfo { this.subtitleTracks = const [], }); - /// Format file size in human-readable format (GB, MB, KB, bytes) - String get fileSizeFormatted { - if (fileSize == null) return 'Unknown'; + String? get fileSizeFormatted { + if (fileSize == null) return null; return ByteFormatter.formatBytes(fileSize!, decimals: 2); } - /// Format duration in HH:MM:SS or MM:SS format - String get durationFormatted { - if (duration == null) return 'Unknown'; - + String? get durationFormatted { + if (duration == null) return null; final seconds = duration! ~/ 1000; final hours = seconds ~/ 3600; final minutes = (seconds % 3600) ~/ 60; final secs = seconds % 60; - return hours > 0 ? '${hours}h ${minutes}m ${secs}s' : '${minutes}m ${secs}s'; } - /// Format overall bitrate in Mbps or kbps (Plex API returns kbps) - String get bitrateFormatted { - if (bitrate == null) return 'Unknown'; + String? get bitrateFormatted { + if (bitrate == null) return null; return ByteFormatter.formatBitrate(bitrate!); } - /// Format video stream bitrate in Mbps or kbps - String get videoBitrateFormatted { - if (videoBitrate == null) return 'Unknown'; + String? get videoBitrateFormatted { + if (videoBitrate == null) return null; return ByteFormatter.formatBitrate(videoBitrate!); } - /// Format resolution as widthxheight - String get resolutionFormatted { - if (width != null && height != null) { - return '${width}x$height'; - } else if (videoResolution != null) { - return videoResolution!; - } - return 'Unknown'; + String? get resolutionFormatted { + if (width != null && height != null) return '${width}x$height'; + if (videoResolution != null) return videoResolution; + return null; } - /// Format aspect ratio - String get aspectRatioFormatted { - if (aspectRatio != null) { - return aspectRatio!.toStringAsFixed(2); - } - return 'Unknown'; + String? get aspectRatioFormatted => aspectRatio?.toStringAsFixed(2); + + String? get frameRateFormatted { + if (frameRate != null) return '${frameRate!.toStringAsFixed(3)} fps'; + if (videoFrameRate != null) return videoFrameRate; + return null; } - /// Format frame rate - String get frameRateFormatted { - if (frameRate != null) { - return '${frameRate!.toStringAsFixed(3)} fps'; - } else if (videoFrameRate != null) { - return videoFrameRate!; - } - return 'Unknown'; - } - - /// Format audio channels (e.g., "2 channels (stereo)") - String get audioChannelsFormatted { - if (audioChannels != null) { - String channelText = '$audioChannels channel${audioChannels! > 1 ? 's' : ''}'; - if (audioChannelLayout != null) { - channelText += ' ($audioChannelLayout)'; - } - return channelText; - } - return 'Unknown'; + String? get audioChannelsFormatted { + if (audioChannels == null) return null; + var channelText = '$audioChannels channel${audioChannels! > 1 ? 's' : ''}'; + if (audioChannelLayout != null) channelText += ' ($audioChannelLayout)'; + return channelText; } } diff --git a/lib/models/plex_filter.dart b/lib/media/media_filter.dart similarity index 57% rename from lib/models/plex_filter.dart rename to lib/media/media_filter.dart index 80520b00..a8bef947 100644 --- a/lib/models/plex_filter.dart +++ b/lib/media/media_filter.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -part 'plex_filter.g.dart'; +part 'media_filter.g.dart'; @JsonSerializable() -class PlexFilter { +class MediaFilter { @JsonKey(defaultValue: '') final String filter; @JsonKey(defaultValue: 'string') @@ -15,7 +15,7 @@ class PlexFilter { @JsonKey(defaultValue: 'filter') final String type; - PlexFilter({ + MediaFilter({ required this.filter, required this.filterType, required this.key, @@ -23,22 +23,22 @@ class PlexFilter { required this.type, }); - factory PlexFilter.fromJson(Map json) => _$PlexFilterFromJson(json); + factory MediaFilter.fromJson(Map json) => _$MediaFilterFromJson(json); - Map toJson() => _$PlexFilterToJson(this); + Map toJson() => _$MediaFilterToJson(this); } @JsonSerializable(includeIfNull: false) -class PlexFilterValue { +class MediaFilterValue { @JsonKey(defaultValue: '') final String key; @JsonKey(defaultValue: '') final String title; final String? type; - PlexFilterValue({required this.key, required this.title, this.type}); + MediaFilterValue({required this.key, required this.title, this.type}); - factory PlexFilterValue.fromJson(Map json) => _$PlexFilterValueFromJson(json); + factory MediaFilterValue.fromJson(Map json) => _$MediaFilterValueFromJson(json); - Map toJson() => _$PlexFilterValueToJson(this); + Map toJson() => _$MediaFilterValueToJson(this); } diff --git a/lib/media/media_filter.g.dart b/lib/media/media_filter.g.dart new file mode 100644 index 00000000..a1844e56 --- /dev/null +++ b/lib/media/media_filter.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_filter.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MediaFilter _$MediaFilterFromJson(Map json) => MediaFilter( + filter: json['filter'] as String? ?? '', + filterType: json['filterType'] as String? ?? 'string', + key: json['key'] as String? ?? '', + title: json['title'] as String? ?? '', + type: json['type'] as String? ?? 'filter', +); + +Map _$MediaFilterToJson(MediaFilter instance) => { + 'filter': instance.filter, + 'filterType': instance.filterType, + 'key': instance.key, + 'title': instance.title, + 'type': instance.type, +}; + +MediaFilterValue _$MediaFilterValueFromJson(Map json) => MediaFilterValue( + key: json['key'] as String? ?? '', + title: json['title'] as String? ?? '', + type: json['type'] as String?, +); + +Map _$MediaFilterValueToJson(MediaFilterValue instance) => { + 'key': instance.key, + 'title': instance.title, + 'type': ?instance.type, +}; diff --git a/lib/media/media_hub.dart b/lib/media/media_hub.dart new file mode 100644 index 00000000..705d0333 --- /dev/null +++ b/lib/media/media_hub.dart @@ -0,0 +1,72 @@ +import 'media_item.dart'; + +/// A named, ordered list of items grouped on the home screen (Plex `Hub`, +/// or a synthesized Jellyfin "Latest"/"Resume"/"NextUp" row). +class MediaHub { + /// Backend-opaque hub identifier (Plex `key`, synthesized for Jellyfin). + final String id; + + /// Human-readable hub identifier for analytics and routing — e.g. + /// `home.continue`, `tv.recentlyadded`. Synthesized for Jellyfin. + final String? identifier; + + final String title; + + /// Hub kind: `movie`, `show`, `mixed`, `clip`, etc. — drives UI rendering. + final String type; + + final List items; + + /// Total number of items the server reports (may exceed [items.length] when + /// a "see more" affordance is available). + final int size; + + /// Whether more items are available beyond what's loaded. + final bool more; + + /// When set, this hub was split from a multi-library hub and should only + /// show items belonging to this library. + final String? libraryId; + + final String? serverId; + final String? serverName; + + const MediaHub({ + required this.id, + required this.title, + required this.type, + required this.items, + this.identifier, + this.size = 0, + this.more = false, + this.libraryId, + this.serverId, + this.serverName, + }); + + MediaHub copyWith({ + String? id, + String? identifier, + String? title, + String? type, + List? items, + int? size, + bool? more, + String? libraryId, + String? serverId, + String? serverName, + }) { + return MediaHub( + id: id ?? this.id, + identifier: identifier ?? this.identifier, + title: title ?? this.title, + type: type ?? this.type, + items: items ?? this.items, + size: size ?? this.size, + more: more ?? this.more, + libraryId: libraryId ?? this.libraryId, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + ); + } +} diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart new file mode 100644 index 00000000..0943b8c0 --- /dev/null +++ b/lib/media/media_item.dart @@ -0,0 +1,1487 @@ +import '../services/settings_service.dart' show EpisodePosterMode; +import '../utils/global_key_utils.dart'; +import '../utils/json_utils.dart'; +import 'media_backend.dart'; +import 'media_kind.dart'; +import 'media_part.dart'; +import 'media_role.dart'; +import 'media_version.dart'; + +/// Backend-neutral media item — the central domain type the app's UI, +/// providers, and persistence layer operate on. Each backend's adapter is +/// responsible for mapping its native representation (Plex `Metadata`, +/// Jellyfin `BaseItemDto`) into this shape. +/// +/// Sealed root with two concrete subclasses: [PlexMediaItem] (carries +/// Plex-only fields like `trailerKey`, `playQueueItemId`, `audienceRating`) +/// and [JellyfinMediaItem] (only the backend-neutral fields). Read sites +/// that need a Plex-only field type-narrow with +/// `case PlexMediaItem(:final trailerKey?)` or +/// `if (item is PlexMediaItem) item.trailerKey`. +sealed class MediaItem { + /// Backend-opaque identifier (Plex `ratingKey`, Jellyfin `Id`). + final String id; + final MediaBackend backend; + final MediaKind kind; + + /// Stable cross-backend identifier (Plex `guid`, Jellyfin `Id` URI). Used + /// for matching across servers and for Trakt-style external lookups. + final String? guid; + + // ── Display metadata ───────────────────────────────────────────── + final String? title; + final String? titleSort; + final String? summary; + final String? tagline; + final String? originalTitle; + final String? studio; + final int? year; + + /// Original release date (`YYYY-MM-DD`). + final String? originallyAvailableAt; + final String? contentRating; + + // ── Hierarchy (episodes/seasons) ───────────────────────────────── + final String? parentId; + final String? parentTitle; + final String? parentThumbPath; + final int? parentIndex; + final int? index; + final String? grandparentId; + final String? grandparentTitle; + final String? grandparentThumbPath; + final String? grandparentArtPath; + + // ── Artwork ────────────────────────────────────────────────────── + final String? thumbPath; + final String? artPath; + final String? clearLogoPath; + final String? backgroundSquarePath; + + // ── Time / watch state ────────────────────────────────────────── + final int? durationMs; + + /// Resume position in ms. + final int? viewOffsetMs; + final int? viewCount; + final int? lastViewedAt; + + /// Total leaf items (episodes in a show/season, items in a collection). + final int? leafCount; + + /// Watched leaf items. + final int? viewedLeafCount; + + /// Direct children count (e.g. seasons in a show). + final int? childCount; + + final int? addedAt; + final int? updatedAt; + + // ── Rating ─────────────────────────────────────────────────────── + final double? rating; + final double? userRating; + + // ── Tags / people ──────────────────────────────────────────────── + final List? genres; + final List? directors; + final List? writers; + final List? producers; + final List? countries; + final List? collections; + final List? labels; + final List? styles; + final List? moods; + final List? roles; + + // ── Media files ────────────────────────────────────────────────── + final List? mediaVersions; + + // ── Library reference ──────────────────────────────────────────── + /// Backend-opaque library/section id this item belongs to. + final String? libraryId; + final String? libraryTitle; + + // ── Per-item playback prefs ────────────────────────────────────── + /// Preferred audio language for this item — used by track-selection + /// fallback (Priority 3) on both backends. Plex persists changes via + /// [PlexClient.setMetadataPreferences]; Jellyfin populates it from the + /// per-user `PreferredMetadataLanguage` field but has no per-item write + /// endpoint, so the value is read-only there. + final String? audioLanguage; + + // ── Multi-server ───────────────────────────────────────────────── + final String? serverId; + final String? serverName; + + // ── Escape hatch ───────────────────────────────────────────────── + /// Untyped fall-through for backend-specific fields not yet mapped onto a + /// typed accessor. Use sparingly; promote to typed fields when stable. + final Map? raw; + + const MediaItem._({ + required this.id, + required this.backend, + required this.kind, + this.guid, + this.title, + this.titleSort, + this.summary, + this.tagline, + this.originalTitle, + this.studio, + this.year, + this.originallyAvailableAt, + this.contentRating, + this.parentId, + this.parentTitle, + this.parentThumbPath, + this.parentIndex, + this.index, + this.grandparentId, + this.grandparentTitle, + this.grandparentThumbPath, + this.grandparentArtPath, + this.thumbPath, + this.artPath, + this.clearLogoPath, + this.backgroundSquarePath, + this.durationMs, + this.viewOffsetMs, + this.viewCount, + this.lastViewedAt, + this.leafCount, + this.viewedLeafCount, + this.childCount, + this.addedAt, + this.updatedAt, + this.rating, + this.userRating, + this.genres, + this.directors, + this.writers, + this.producers, + this.countries, + this.collections, + this.labels, + this.styles, + this.moods, + this.roles, + this.mediaVersions, + this.libraryId, + this.libraryTitle, + this.audioLanguage, + this.serverId, + this.serverName, + this.raw, + }); + + /// Backend-dispatching factory: constructs the right concrete subclass + /// for the given [backend]. Non-const because the dispatch happens at + /// runtime; the (rare) const construct sites have been adjusted. + factory MediaItem({ + required String id, + required MediaBackend backend, + required MediaKind kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? userRating, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + + /// Plex-only — silently ignored when [backend] is Jellyfin (Jellyfin has + /// no per-item subtitle preference write endpoint). Forwarded to + /// [PlexMediaItem] only. + String? subtitleLanguage, + int? subtitleMode, + String? serverId, + String? serverName, + Map? raw, + }) { + return switch (backend) { + MediaBackend.plex => PlexMediaItem( + 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, + thumbPath: thumbPath, + artPath: artPath, + 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, + 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, + subtitleLanguage: subtitleLanguage, + subtitleMode: subtitleMode, + serverId: serverId, + serverName: serverName, + raw: raw, + ), + MediaBackend.jellyfin => JellyfinMediaItem( + 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, + thumbPath: thumbPath, + artPath: artPath, + 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, + 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, + serverId: serverId, + serverName: serverName, + raw: raw, + ), + }; + } + + /// Global unique identifier across all servers (`serverId:id`). Falls back + /// to bare [id] if [serverId] is missing. + String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id; + + /// Global unique identifier of this item's library section. + String? get libraryGlobalKey => serverId != null && libraryId != null ? buildGlobalKey(serverId!, libraryId!) : null; + + /// Parent rating keys for hierarchical invalidation. For an episode: + /// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`. + List get parentChain => [?parentId, ?grandparentId]; + + /// Whether this item has started but not finished playback. + bool get hasActiveProgress { + if (durationMs == null || viewOffsetMs == null) return false; + return viewOffsetMs! > 0 && viewOffsetMs! < durationMs!; + } + + /// Whether this container (show/season) has some but not all leaves watched. + bool get isPartiallyWatched => + viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!; + + /// Whether the item is fully watched. Series/seasons consult leaf counts; + /// individual movies/episodes use [viewCount]. + bool get isWatched { + if (leafCount != null && viewedLeafCount != null) { + return viewedLeafCount! >= leafCount!; + } + return viewCount != null && viewCount! > 0; + } + + /// Display-friendly title that prefers the show name for episodes/seasons. + String get displayTitle { + if ((kind == MediaKind.episode || kind == MediaKind.season) && grandparentTitle != null) { + return grandparentTitle!; + } + if (kind == MediaKind.season && parentTitle != null) { + return parentTitle!; + } + return title ?? ''; + } + + /// Subtitle line shown below [displayTitle] for episodes/seasons. + String? get displaySubtitle { + if (kind == MediaKind.episode || kind == MediaKind.season) { + if (grandparentTitle != null || (kind == MediaKind.season && parentTitle != null)) { + return title; + } + } + return null; + } + + /// Plex-only edition label (e.g. "Director's Cut"). Returns null on + /// backends that don't model editions; lets callers avoid type-narrowing + /// to [PlexMediaItem] just to read this field. + String? get editionTitle => null; + + /// Returns the appropriate poster path based on episode poster mode. + /// + /// For episodes: + /// - `seriesPoster`: grandparentThumb (series poster) + /// - `seasonPoster`: parentThumb (season poster) + /// - `episodeThumbnail`: thumb (16:9 episode still) + /// + /// For seasons: returns grandparentThumb (series poster), or art/thumb in + /// mixed hub context. + /// For movies/shows in mixed hub context with episode-thumbnail mode: + /// returns art (16:9 background). + /// For other types: returns thumb. + String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { + if (kind == MediaKind.episode) { + switch (mode) { + case EpisodePosterMode.episodeThumbnail: + return thumbPath; + case EpisodePosterMode.seasonPoster: + return parentThumbPath ?? grandparentThumbPath ?? thumbPath; + case EpisodePosterMode.seriesPoster: + return grandparentThumbPath ?? thumbPath; + } + } else if (kind == MediaKind.season) { + if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail) { + return artPath ?? thumbPath; + } + if (grandparentThumbPath != null) { + return grandparentThumbPath; + } + } + + if (mixedHubContext && + mode == EpisodePosterMode.episodeThumbnail && + (kind == MediaKind.movie || kind == MediaKind.show)) { + return artPath ?? thumbPath; + } + + return thumbPath; + } + + /// True when the item should render in 16:9. + /// - Clips are always 16:9. + /// - Episodes are 16:9 in `episodeThumbnail` mode. + /// - Movies/shows/seasons are 16:9 in mixed-hub `episodeThumbnail` context. + bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) { + if (kind == MediaKind.clip) return true; + if (kind == MediaKind.episode && mode == EpisodePosterMode.episodeThumbnail) { + return true; + } + if (mixedHubContext && + mode == EpisodePosterMode.episodeThumbnail && + (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.season)) { + return true; + } + return false; + } + + /// Returns the best hero art path based on the container's aspect ratio. + /// Uses backgroundSquare when the container is closer to 1:1 than 16:9. + String? heroArt({required double containerAspectRatio}) { + // Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39 + if (containerAspectRatio < 1.39 && backgroundSquarePath != null) { + return backgroundSquarePath; + } + return artPath; + } + + MediaItem copyWith({ + String? id, + MediaBackend? backend, + MediaKind? kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? userRating, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + + /// Plex-only — forwarded only when this item is a [PlexMediaItem]. + String? subtitleLanguage, + int? subtitleMode, + String? serverId, + String? serverName, + Map? raw, + }) { + return MediaItem( + id: id ?? this.id, + backend: backend ?? this.backend, + kind: kind ?? this.kind, + guid: guid ?? this.guid, + title: title ?? this.title, + titleSort: titleSort ?? this.titleSort, + summary: summary ?? this.summary, + tagline: tagline ?? this.tagline, + originalTitle: originalTitle ?? this.originalTitle, + studio: studio ?? this.studio, + year: year ?? this.year, + originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, + contentRating: contentRating ?? this.contentRating, + parentId: parentId ?? this.parentId, + parentTitle: parentTitle ?? this.parentTitle, + parentThumbPath: parentThumbPath ?? this.parentThumbPath, + parentIndex: parentIndex ?? this.parentIndex, + index: index ?? this.index, + grandparentId: grandparentId ?? this.grandparentId, + grandparentTitle: grandparentTitle ?? this.grandparentTitle, + grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath, + grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath, + thumbPath: thumbPath ?? this.thumbPath, + artPath: artPath ?? this.artPath, + clearLogoPath: clearLogoPath ?? this.clearLogoPath, + backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath, + durationMs: durationMs ?? this.durationMs, + viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs, + viewCount: viewCount ?? this.viewCount, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, + leafCount: leafCount ?? this.leafCount, + viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + childCount: childCount ?? this.childCount, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + rating: rating ?? this.rating, + userRating: userRating ?? this.userRating, + genres: genres ?? this.genres, + directors: directors ?? this.directors, + writers: writers ?? this.writers, + producers: producers ?? this.producers, + countries: countries ?? this.countries, + collections: collections ?? this.collections, + labels: labels ?? this.labels, + styles: styles ?? this.styles, + moods: moods ?? this.moods, + roles: roles ?? this.roles, + mediaVersions: mediaVersions ?? this.mediaVersions, + libraryId: libraryId ?? this.libraryId, + libraryTitle: libraryTitle ?? this.libraryTitle, + audioLanguage: audioLanguage ?? this.audioLanguage, + // [subtitleLanguage] / [subtitleMode] are Plex-only fields. Base + // [MediaItem] doesn't carry them; [PlexMediaItem.copyWith] overrides + // this method and forwards its own copies. For Jellyfin items the + // params are silently dropped. + subtitleLanguage: subtitleLanguage, + subtitleMode: subtitleMode, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + raw: raw ?? this.raw, + ); + } + + /// Serialize to a backend-neutral JSON map. Used by the offline cache so + /// downloads retain their metadata without round-tripping through a + /// backend-specific shape. + /// + /// Subclasses extend this with their own backend-specific keys + /// ([PlexMediaItem.toJson] adds the Plex-only fields). + Map toJson() { + return { + 'id': id, + 'backend': backend.id, + 'kind': kind.id, + if (guid != null) 'guid': guid, + if (title != null) 'title': title, + if (titleSort != null) 'titleSort': titleSort, + if (summary != null) 'summary': summary, + if (tagline != null) 'tagline': tagline, + if (originalTitle != null) 'originalTitle': originalTitle, + if (studio != null) 'studio': studio, + if (year != null) 'year': year, + if (originallyAvailableAt != null) 'originallyAvailableAt': originallyAvailableAt, + if (contentRating != null) 'contentRating': contentRating, + if (parentId != null) 'parentId': parentId, + if (parentTitle != null) 'parentTitle': parentTitle, + if (parentThumbPath != null) 'parentThumbPath': parentThumbPath, + if (parentIndex != null) 'parentIndex': parentIndex, + if (index != null) 'index': index, + if (grandparentId != null) 'grandparentId': grandparentId, + if (grandparentTitle != null) 'grandparentTitle': grandparentTitle, + if (grandparentThumbPath != null) 'grandparentThumbPath': grandparentThumbPath, + if (grandparentArtPath != null) 'grandparentArtPath': grandparentArtPath, + if (thumbPath != null) 'thumbPath': thumbPath, + if (artPath != null) 'artPath': artPath, + if (clearLogoPath != null) 'clearLogoPath': clearLogoPath, + if (backgroundSquarePath != null) 'backgroundSquarePath': backgroundSquarePath, + if (durationMs != null) 'durationMs': durationMs, + if (viewOffsetMs != null) 'viewOffsetMs': viewOffsetMs, + if (viewCount != null) 'viewCount': viewCount, + if (lastViewedAt != null) 'lastViewedAt': lastViewedAt, + if (leafCount != null) 'leafCount': leafCount, + if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount, + if (childCount != null) 'childCount': childCount, + if (addedAt != null) 'addedAt': addedAt, + if (updatedAt != null) 'updatedAt': updatedAt, + if (rating != null) 'rating': rating, + if (userRating != null) 'userRating': userRating, + if (genres != null) 'genres': genres, + if (directors != null) 'directors': directors, + if (writers != null) 'writers': writers, + if (producers != null) 'producers': producers, + if (countries != null) 'countries': countries, + if (collections != null) 'collections': collections, + if (labels != null) 'labels': labels, + if (styles != null) 'styles': styles, + if (moods != null) 'moods': moods, + if (roles != null) 'roles': [for (final r in roles!) _roleToJson(r)], + if (mediaVersions != null) 'mediaVersions': [for (final v in mediaVersions!) _versionToJson(v)], + if (libraryId != null) 'libraryId': libraryId, + if (libraryTitle != null) 'libraryTitle': libraryTitle, + if (audioLanguage != null) 'audioLanguage': audioLanguage, + if (serverId != null) 'serverId': serverId, + if (serverName != null) 'serverName': serverName, + if (raw != null) 'raw': raw, + }; + } + + /// Restore a [MediaItem] from a [toJson] payload. Dispatches to + /// [PlexMediaItem.fromJson] when the payload's `backend` tag is Plex so + /// the Plex-only fields round-trip correctly. Unknown shapes degrade to a + /// minimal item carrying just `id` so cache misses don't crash. + factory MediaItem.fromJson(Map json) { + final backend = MediaBackend.fromString(json['backend'] as String?); + if (backend == MediaBackend.plex) return PlexMediaItem.fromJson(json); + return JellyfinMediaItem.fromJson(json); + } +} + +/// Shared parsing of the backend-neutral fields. Returns a typed record +/// consumed by both [JellyfinMediaItem.fromJson] and +/// [PlexMediaItem.fromJson] (which layers the Plex-only fields on top). +typedef _BaseFields = ({ + String id, + MediaKind kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? userRating, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + String? serverId, + String? serverName, + Map? raw, +}); + +_BaseFields _parseBaseFields(Map json) { + final rolesRaw = json['roles']; + final versionsRaw = json['mediaVersions']; + return ( + id: (json['id'] ?? '').toString(), + kind: MediaKind.fromString(json['kind'] as String?), + guid: json['guid'] as String?, + title: json['title'] as String?, + titleSort: json['titleSort'] as String?, + summary: json['summary'] as String?, + tagline: json['tagline'] as String?, + originalTitle: json['originalTitle'] as String?, + studio: json['studio'] as String?, + year: flexibleInt(json['year']), + originallyAvailableAt: json['originallyAvailableAt'] as String?, + contentRating: json['contentRating'] as String?, + parentId: json['parentId'] as String?, + parentTitle: json['parentTitle'] as String?, + parentThumbPath: json['parentThumbPath'] as String?, + parentIndex: flexibleInt(json['parentIndex']), + index: flexibleInt(json['index']), + grandparentId: json['grandparentId'] as String?, + grandparentTitle: json['grandparentTitle'] as String?, + grandparentThumbPath: json['grandparentThumbPath'] as String?, + grandparentArtPath: json['grandparentArtPath'] as String?, + thumbPath: json['thumbPath'] as String?, + artPath: json['artPath'] as String?, + clearLogoPath: json['clearLogoPath'] as String?, + backgroundSquarePath: json['backgroundSquarePath'] as String?, + durationMs: flexibleInt(json['durationMs']), + viewOffsetMs: flexibleInt(json['viewOffsetMs']), + viewCount: flexibleInt(json['viewCount']), + lastViewedAt: flexibleInt(json['lastViewedAt']), + leafCount: flexibleInt(json['leafCount']), + viewedLeafCount: flexibleInt(json['viewedLeafCount']), + childCount: flexibleInt(json['childCount']), + addedAt: flexibleInt(json['addedAt']), + updatedAt: flexibleInt(json['updatedAt']), + rating: flexibleDouble(json['rating']), + userRating: flexibleDouble(json['userRating']), + genres: _stringList(json['genres']), + directors: _stringList(json['directors']), + writers: _stringList(json['writers']), + producers: _stringList(json['producers']), + countries: _stringList(json['countries']), + collections: _stringList(json['collections']), + labels: _stringList(json['labels']), + styles: _stringList(json['styles']), + moods: _stringList(json['moods']), + roles: rolesRaw is List + ? [ + for (final r in rolesRaw) + if (r is Map) _roleFromJson(r), + ] + : null, + mediaVersions: versionsRaw is List + ? [ + for (final v in versionsRaw) + if (v is Map) _versionFromJson(v), + ] + : null, + libraryId: json['libraryId'] as String?, + libraryTitle: json['libraryTitle'] as String?, + audioLanguage: json['audioLanguage'] as String?, + serverId: json['serverId'] as String?, + serverName: json['serverName'] as String?, + raw: json['raw'] is Map ? Map.from(json['raw'] as Map) : null, + ); +} + +/// Backend-tagged concrete subclass for items sourced from a Plex server. +/// Carries the Plex-only fields that have no Jellyfin equivalent +/// (trailerKey, playlistItemId, playQueueItemId, subtype, extraType, +/// ratingImage, audienceRating, audienceRatingImage, editionTitle). +/// Read sites that need these fields type-narrow with +/// `case PlexMediaItem(:final trailerKey?)` or +/// `if (item is PlexMediaItem) item.trailerKey`. +final class PlexMediaItem extends MediaItem { + /// Plex `editionTitle` — secondary title that distinguishes editions of + /// the same movie ("Director's Cut", "Theatrical"). Jellyfin has no + /// equivalent metadata field today. + @override + final String? editionTitle; + + /// Plex `audienceRating` (e.g. Rotten Tomatoes audience score). Jellyfin's + /// `CommunityRating` lives on [rating]; there's no separate audience field. + final double? audienceRating; + + /// Plex `ratingImage` URI ("rottentomatoes://image.rating.ripe"). Used by + /// the rating chip to pick an icon. Jellyfin doesn't expose + /// rating-source attribution. + final String? ratingImage; + + /// Plex `audienceRatingImage` URI — companion to [ratingImage] for the + /// audience score icon. + final String? audienceRatingImage; + + /// Plex per-item subtitle language preference — persisted server-side via + /// [PlexClient.setMetadataPreferences]. Jellyfin has no equivalent + /// per-item write endpoint, so the field lives here rather than on the + /// neutral [MediaItem] base. + final String? subtitleLanguage; + + /// Plex per-item subtitle mode (`0` = manual, `1` = always on, `2` = match + /// audio). Jellyfin doesn't expose a comparable knob. + final int? subtitleMode; + + /// Plex `primaryExtraKey` — points at the main trailer extra. Jellyfin + /// stores trailers separately via `RemoteTrailers`; not yet wired. + final String? trailerKey; + + /// Plex playlist item id — only set when the item came out of a + /// server-side playlist. Jellyfin has no per-playlist-item id. + final int? playlistItemId; + + /// Plex play-queue item id — set when the item is part of a server-side + /// `PlayQueue`. Jellyfin uses client-side queues; [PlaybackStateProvider] + /// tracks synthetic IDs in a parallel map for those. + final int? playQueueItemId; + + /// Plex clip subtype: `trailer`, `behindTheScenes`, `deleted`, etc. + final String? subtype; + + /// Plex numeric extra type identifier. + final int? extraType; + + const PlexMediaItem({ + required super.id, + required super.kind, + super.guid, + super.title, + super.titleSort, + super.summary, + super.tagline, + super.originalTitle, + this.editionTitle, + super.studio, + super.year, + super.originallyAvailableAt, + super.contentRating, + super.parentId, + super.parentTitle, + super.parentThumbPath, + super.parentIndex, + super.index, + super.grandparentId, + super.grandparentTitle, + super.grandparentThumbPath, + super.grandparentArtPath, + super.thumbPath, + super.artPath, + super.clearLogoPath, + super.backgroundSquarePath, + super.durationMs, + super.viewOffsetMs, + super.viewCount, + super.lastViewedAt, + super.leafCount, + super.viewedLeafCount, + super.childCount, + super.addedAt, + super.updatedAt, + super.rating, + this.audienceRating, + super.userRating, + this.ratingImage, + this.audienceRatingImage, + super.genres, + super.directors, + super.writers, + super.producers, + super.countries, + super.collections, + super.labels, + super.styles, + super.moods, + super.roles, + super.mediaVersions, + super.libraryId, + super.libraryTitle, + super.audioLanguage, + this.subtitleLanguage, + this.subtitleMode, + this.trailerKey, + this.playlistItemId, + this.playQueueItemId, + this.subtype, + this.extraType, + super.serverId, + super.serverName, + super.raw, + }) : super._(backend: MediaBackend.plex); + + @override + PlexMediaItem copyWith({ + String? id, + MediaBackend? backend, + MediaKind? kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? editionTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? audienceRating, + double? userRating, + String? ratingImage, + String? audienceRatingImage, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + String? subtitleLanguage, + int? subtitleMode, + String? trailerKey, + int? playlistItemId, + int? playQueueItemId, + String? subtype, + int? extraType, + String? serverId, + String? serverName, + Map? raw, + }) { + return PlexMediaItem( + id: id ?? this.id, + kind: kind ?? this.kind, + guid: guid ?? this.guid, + title: title ?? this.title, + titleSort: titleSort ?? this.titleSort, + summary: summary ?? this.summary, + tagline: tagline ?? this.tagline, + originalTitle: originalTitle ?? this.originalTitle, + editionTitle: editionTitle ?? this.editionTitle, + studio: studio ?? this.studio, + year: year ?? this.year, + originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, + contentRating: contentRating ?? this.contentRating, + parentId: parentId ?? this.parentId, + parentTitle: parentTitle ?? this.parentTitle, + parentThumbPath: parentThumbPath ?? this.parentThumbPath, + parentIndex: parentIndex ?? this.parentIndex, + index: index ?? this.index, + grandparentId: grandparentId ?? this.grandparentId, + grandparentTitle: grandparentTitle ?? this.grandparentTitle, + grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath, + grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath, + thumbPath: thumbPath ?? this.thumbPath, + artPath: artPath ?? this.artPath, + clearLogoPath: clearLogoPath ?? this.clearLogoPath, + backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath, + durationMs: durationMs ?? this.durationMs, + viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs, + viewCount: viewCount ?? this.viewCount, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, + leafCount: leafCount ?? this.leafCount, + viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + childCount: childCount ?? this.childCount, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + rating: rating ?? this.rating, + audienceRating: audienceRating ?? this.audienceRating, + userRating: userRating ?? this.userRating, + ratingImage: ratingImage ?? this.ratingImage, + audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage, + genres: genres ?? this.genres, + directors: directors ?? this.directors, + writers: writers ?? this.writers, + producers: producers ?? this.producers, + countries: countries ?? this.countries, + collections: collections ?? this.collections, + labels: labels ?? this.labels, + styles: styles ?? this.styles, + moods: moods ?? this.moods, + roles: roles ?? this.roles, + mediaVersions: mediaVersions ?? this.mediaVersions, + libraryId: libraryId ?? this.libraryId, + libraryTitle: libraryTitle ?? this.libraryTitle, + audioLanguage: audioLanguage ?? this.audioLanguage, + subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, + subtitleMode: subtitleMode ?? this.subtitleMode, + trailerKey: trailerKey ?? this.trailerKey, + playlistItemId: playlistItemId ?? this.playlistItemId, + playQueueItemId: playQueueItemId ?? this.playQueueItemId, + subtype: subtype ?? this.subtype, + extraType: extraType ?? this.extraType, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + raw: raw ?? this.raw, + ); + } + + @override + Map toJson() { + return { + ...super.toJson(), + if (editionTitle != null) 'editionTitle': editionTitle, + if (audienceRating != null) 'audienceRating': audienceRating, + if (ratingImage != null) 'ratingImage': ratingImage, + if (audienceRatingImage != null) 'audienceRatingImage': audienceRatingImage, + if (subtitleLanguage != null) 'subtitleLanguage': subtitleLanguage, + if (subtitleMode != null) 'subtitleMode': subtitleMode, + if (trailerKey != null) 'trailerKey': trailerKey, + if (playlistItemId != null) 'playlistItemId': playlistItemId, + if (playQueueItemId != null) 'playQueueItemId': playQueueItemId, + if (subtype != null) 'subtype': subtype, + if (extraType != null) 'extraType': extraType, + }; + } + + /// Restore a [PlexMediaItem] from a [toJson] payload. Reads the Plex-only + /// keys on top of the backend-neutral fields parsed by [_parseBaseFields]. + factory PlexMediaItem.fromJson(Map json) { + final base = _parseBaseFields(json); + return PlexMediaItem( + id: base.id, + kind: base.kind, + guid: base.guid, + title: base.title, + titleSort: base.titleSort, + summary: base.summary, + tagline: base.tagline, + originalTitle: base.originalTitle, + editionTitle: json['editionTitle'] as String?, + studio: base.studio, + year: base.year, + originallyAvailableAt: base.originallyAvailableAt, + contentRating: base.contentRating, + parentId: base.parentId, + parentTitle: base.parentTitle, + parentThumbPath: base.parentThumbPath, + parentIndex: base.parentIndex, + index: base.index, + grandparentId: base.grandparentId, + grandparentTitle: base.grandparentTitle, + grandparentThumbPath: base.grandparentThumbPath, + grandparentArtPath: base.grandparentArtPath, + thumbPath: base.thumbPath, + artPath: base.artPath, + clearLogoPath: base.clearLogoPath, + backgroundSquarePath: base.backgroundSquarePath, + durationMs: base.durationMs, + viewOffsetMs: base.viewOffsetMs, + viewCount: base.viewCount, + lastViewedAt: base.lastViewedAt, + leafCount: base.leafCount, + viewedLeafCount: base.viewedLeafCount, + childCount: base.childCount, + addedAt: base.addedAt, + updatedAt: base.updatedAt, + rating: base.rating, + audienceRating: flexibleDouble(json['audienceRating']), + userRating: base.userRating, + ratingImage: json['ratingImage'] as String?, + audienceRatingImage: json['audienceRatingImage'] as String?, + genres: base.genres, + directors: base.directors, + writers: base.writers, + producers: base.producers, + countries: base.countries, + collections: base.collections, + labels: base.labels, + styles: base.styles, + moods: base.moods, + roles: base.roles, + mediaVersions: base.mediaVersions, + libraryId: base.libraryId, + libraryTitle: base.libraryTitle, + audioLanguage: base.audioLanguage, + subtitleLanguage: json['subtitleLanguage'] as String?, + subtitleMode: flexibleInt(json['subtitleMode']), + trailerKey: json['trailerKey'] as String?, + playlistItemId: flexibleInt(json['playlistItemId']), + playQueueItemId: flexibleInt(json['playQueueItemId']), + subtype: json['subtype'] as String?, + extraType: flexibleInt(json['extraType']), + serverId: base.serverId, + serverName: base.serverName, + raw: base.raw, + ); + } +} + +/// Backend-tagged concrete subclass for items sourced from a Jellyfin +/// server. Carries only the backend-neutral fields — Plex-only fields +/// (trailerKey, audienceRating, etc.) live on [PlexMediaItem] instead. +final class JellyfinMediaItem extends MediaItem { + /// Jellyfin per-playlist item id — only set when the item came out of + /// `/Playlists/{id}/Items`. Used as the `entryIds` / move-target id for + /// the playlist write endpoints. Null outside playlist contexts. + final String? playlistItemId; + + const JellyfinMediaItem({ + required super.id, + required super.kind, + super.guid, + super.title, + super.titleSort, + super.summary, + super.tagline, + super.originalTitle, + super.studio, + super.year, + super.originallyAvailableAt, + super.contentRating, + super.parentId, + super.parentTitle, + super.parentThumbPath, + super.parentIndex, + super.index, + super.grandparentId, + super.grandparentTitle, + super.grandparentThumbPath, + super.grandparentArtPath, + super.thumbPath, + super.artPath, + super.clearLogoPath, + super.backgroundSquarePath, + super.durationMs, + super.viewOffsetMs, + super.viewCount, + super.lastViewedAt, + super.leafCount, + super.viewedLeafCount, + super.childCount, + super.addedAt, + super.updatedAt, + super.rating, + super.userRating, + super.genres, + super.directors, + super.writers, + super.producers, + super.countries, + super.collections, + super.labels, + super.styles, + super.moods, + super.roles, + super.mediaVersions, + super.libraryId, + super.libraryTitle, + super.audioLanguage, + this.playlistItemId, + super.serverId, + super.serverName, + super.raw, + }) : super._(backend: MediaBackend.jellyfin); + + /// Override the base [MediaItem.copyWith] so [playlistItemId] survives + /// round-trips through the absolutizer (which calls copyWith to rewrite + /// image paths). Without this, every Jellyfin playlist item came out with + /// `playlistItemId == null` after mapping, making the move/remove endpoints + /// silently no-op. + @override + JellyfinMediaItem copyWith({ + String? id, + MediaBackend? backend, + MediaKind? kind, + String? guid, + String? title, + String? titleSort, + String? summary, + String? tagline, + String? originalTitle, + String? studio, + int? year, + String? originallyAvailableAt, + String? contentRating, + String? parentId, + String? parentTitle, + String? parentThumbPath, + int? parentIndex, + int? index, + String? grandparentId, + String? grandparentTitle, + String? grandparentThumbPath, + String? grandparentArtPath, + String? thumbPath, + String? artPath, + String? clearLogoPath, + String? backgroundSquarePath, + int? durationMs, + int? viewOffsetMs, + int? viewCount, + int? lastViewedAt, + int? leafCount, + int? viewedLeafCount, + int? childCount, + int? addedAt, + int? updatedAt, + double? rating, + double? userRating, + List? genres, + List? directors, + List? writers, + List? producers, + List? countries, + List? collections, + List? labels, + List? styles, + List? moods, + List? roles, + List? mediaVersions, + String? libraryId, + String? libraryTitle, + String? audioLanguage, + String? subtitleLanguage, + int? subtitleMode, + String? playlistItemId, + String? serverId, + String? serverName, + Map? raw, + }) { + return JellyfinMediaItem( + id: id ?? this.id, + kind: kind ?? this.kind, + guid: guid ?? this.guid, + title: title ?? this.title, + titleSort: titleSort ?? this.titleSort, + summary: summary ?? this.summary, + tagline: tagline ?? this.tagline, + originalTitle: originalTitle ?? this.originalTitle, + studio: studio ?? this.studio, + year: year ?? this.year, + originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, + contentRating: contentRating ?? this.contentRating, + parentId: parentId ?? this.parentId, + parentTitle: parentTitle ?? this.parentTitle, + parentThumbPath: parentThumbPath ?? this.parentThumbPath, + parentIndex: parentIndex ?? this.parentIndex, + index: index ?? this.index, + grandparentId: grandparentId ?? this.grandparentId, + grandparentTitle: grandparentTitle ?? this.grandparentTitle, + grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath, + grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath, + thumbPath: thumbPath ?? this.thumbPath, + artPath: artPath ?? this.artPath, + clearLogoPath: clearLogoPath ?? this.clearLogoPath, + backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath, + durationMs: durationMs ?? this.durationMs, + viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs, + viewCount: viewCount ?? this.viewCount, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, + leafCount: leafCount ?? this.leafCount, + viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + childCount: childCount ?? this.childCount, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + rating: rating ?? this.rating, + userRating: userRating ?? this.userRating, + genres: genres ?? this.genres, + directors: directors ?? this.directors, + writers: writers ?? this.writers, + producers: producers ?? this.producers, + countries: countries ?? this.countries, + collections: collections ?? this.collections, + labels: labels ?? this.labels, + styles: styles ?? this.styles, + moods: moods ?? this.moods, + roles: roles ?? this.roles, + mediaVersions: mediaVersions ?? this.mediaVersions, + libraryId: libraryId ?? this.libraryId, + libraryTitle: libraryTitle ?? this.libraryTitle, + audioLanguage: audioLanguage ?? this.audioLanguage, + playlistItemId: playlistItemId ?? this.playlistItemId, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + raw: raw ?? this.raw, + ); + } + + @override + Map toJson() { + return {...super.toJson(), if (playlistItemId != null) 'playlistItemId': playlistItemId}; + } + + /// Restore a [JellyfinMediaItem] from a [toJson] payload. Used as the + /// non-Plex fallback by [MediaItem.fromJson]. + factory JellyfinMediaItem.fromJson(Map json) { + final base = _parseBaseFields(json); + return JellyfinMediaItem( + id: base.id, + kind: base.kind, + guid: base.guid, + title: base.title, + titleSort: base.titleSort, + summary: base.summary, + tagline: base.tagline, + originalTitle: base.originalTitle, + studio: base.studio, + year: base.year, + originallyAvailableAt: base.originallyAvailableAt, + contentRating: base.contentRating, + parentId: base.parentId, + parentTitle: base.parentTitle, + parentThumbPath: base.parentThumbPath, + parentIndex: base.parentIndex, + index: base.index, + grandparentId: base.grandparentId, + grandparentTitle: base.grandparentTitle, + grandparentThumbPath: base.grandparentThumbPath, + grandparentArtPath: base.grandparentArtPath, + thumbPath: base.thumbPath, + artPath: base.artPath, + clearLogoPath: base.clearLogoPath, + backgroundSquarePath: base.backgroundSquarePath, + durationMs: base.durationMs, + viewOffsetMs: base.viewOffsetMs, + viewCount: base.viewCount, + lastViewedAt: base.lastViewedAt, + leafCount: base.leafCount, + viewedLeafCount: base.viewedLeafCount, + childCount: base.childCount, + addedAt: base.addedAt, + updatedAt: base.updatedAt, + rating: base.rating, + userRating: base.userRating, + genres: base.genres, + directors: base.directors, + writers: base.writers, + producers: base.producers, + countries: base.countries, + collections: base.collections, + labels: base.labels, + styles: base.styles, + moods: base.moods, + roles: base.roles, + mediaVersions: base.mediaVersions, + libraryId: base.libraryId, + libraryTitle: base.libraryTitle, + audioLanguage: base.audioLanguage, + playlistItemId: json['playlistItemId'] as String?, + serverId: base.serverId, + serverName: base.serverName, + raw: base.raw, + ); + } +} + +List? _stringList(Object? raw) { + return stringListFromRaw(raw, stringify: true); +} + +Map _roleToJson(MediaRole role) => { + if (role.id != null) 'id': role.id, + 'tag': role.tag, + if (role.role != null) 'role': role.role, + if (role.thumbPath != null) 'thumbPath': role.thumbPath, +}; + +MediaRole _roleFromJson(Map json) => MediaRole( + id: json['id'] as String?, + tag: (json['tag'] ?? '').toString(), + role: json['role'] as String?, + thumbPath: json['thumbPath'] as String?, +); + +Map _versionToJson(MediaVersion v) => { + 'id': v.id, + if (v.width != null) 'width': v.width, + if (v.height != null) 'height': v.height, + if (v.videoResolution != null) 'videoResolution': v.videoResolution, + if (v.videoCodec != null) 'videoCodec': v.videoCodec, + if (v.bitrate != null) 'bitrate': v.bitrate, + if (v.container != null) 'container': v.container, + if (v.name != null) 'name': v.name, + 'parts': [ + for (final p in v.parts) + { + 'id': p.id, + if (p.streamPath != null) 'streamPath': p.streamPath, + if (p.sizeBytes != null) 'sizeBytes': p.sizeBytes, + if (p.container != null) 'container': p.container, + if (p.durationMs != null) 'durationMs': p.durationMs, + if (p.accessible != null) 'accessible': p.accessible, + if (p.exists != null) 'exists': p.exists, + }, + ], +}; + +MediaVersion _versionFromJson(Map json) { + final partsRaw = json['parts']; + return MediaVersion( + id: (json['id'] ?? '').toString(), + width: flexibleInt(json['width']), + height: flexibleInt(json['height']), + videoResolution: json['videoResolution'] as String?, + videoCodec: json['videoCodec'] as String?, + bitrate: flexibleInt(json['bitrate']), + container: json['container'] as String?, + name: json['name'] as String?, + parts: partsRaw is List + ? [ + for (final p in partsRaw) + if (p is Map) + MediaPart( + id: (p['id'] ?? '').toString(), + streamPath: p['streamPath'] as String?, + sizeBytes: flexibleInt(p['sizeBytes']), + container: p['container'] as String?, + durationMs: flexibleInt(p['durationMs']), + accessible: p['accessible'] as bool?, + exists: p['exists'] as bool?, + ), + ] + : const [], + ); +} diff --git a/lib/media/media_item_types.dart b/lib/media/media_item_types.dart new file mode 100644 index 00000000..0196b4cf --- /dev/null +++ b/lib/media/media_item_types.dart @@ -0,0 +1,30 @@ +import 'media_item.dart'; +import 'media_kind.dart'; + +/// Convenience type-check getters and spoiler helpers on [MediaItem]. These +/// give consumers a Plex-style fluent API (e.g. `item.isShow`) while keeping +/// the underlying type backend-neutral. +extension MediaItemTypes on MediaItem { + bool get isShow => kind == MediaKind.show; + bool get isMovie => kind == MediaKind.movie; + bool get isSeason => kind == MediaKind.season; + bool get isEpisode => kind == MediaKind.episode; + bool get isCollection => kind == MediaKind.collection; + bool get isMusicContent => kind == MediaKind.artist || kind == MediaKind.album || kind == MediaKind.track; + bool get isVideoContent => + kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.season || kind == MediaKind.episode; + + /// Whether this episode should have spoiler protection applied. + /// True when the item is an unwatched episode watched less than 50%. + bool get shouldHideSpoiler { + if (!isEpisode) return false; + if (isWatched) return false; + if (viewOffsetMs != null && viewOffsetMs! > 0 && durationMs != null && durationMs! > 0) { + return viewOffsetMs! / durationMs! < 0.5; + } + return true; + } + + /// Non-spoiler art path for episodes (show/season background). + String? get spoilerSafeArt => grandparentArtPath ?? artPath; +} diff --git a/lib/media/media_kind.dart b/lib/media/media_kind.dart new file mode 100644 index 00000000..483ad8bd --- /dev/null +++ b/lib/media/media_kind.dart @@ -0,0 +1,63 @@ +/// Backend-neutral classification of a media item. +/// +/// Mirrors the categories in [PlexMediaType] but is the canonical type used by +/// neutral domain models. Each backend's adapter is responsible for mapping +/// its own type strings (Plex `type` field, Jellyfin `BaseItemKind`) into one +/// of these values. +enum MediaKind { + movie, + show, + season, + episode, + artist, + album, + track, + collection, + playlist, + clip, + photo, + unknown; + + bool get isVideo => this == movie || this == episode || this == clip; + + bool get isShowRelated => this == show || this == season || this == episode; + + bool get isMusic => this == artist || this == album || this == track; + + bool get isPlayable => isVideo || this == track; + + /// Lowercase string id used when persisting or comparing legacy code paths + /// that still hold raw type strings. + String get id => switch (this) { + MediaKind.movie => 'movie', + MediaKind.show => 'show', + MediaKind.season => 'season', + MediaKind.episode => 'episode', + MediaKind.artist => 'artist', + MediaKind.album => 'album', + MediaKind.track => 'track', + MediaKind.collection => 'collection', + MediaKind.playlist => 'playlist', + MediaKind.clip => 'clip', + MediaKind.photo => 'photo', + MediaKind.unknown => 'unknown', + }; + + static MediaKind fromString(String? raw) { + if (raw == null) return MediaKind.unknown; + return switch (raw.toLowerCase()) { + 'movie' => MediaKind.movie, + 'show' || 'series' => MediaKind.show, + 'season' => MediaKind.season, + 'episode' => MediaKind.episode, + 'artist' || 'musicartist' => MediaKind.artist, + 'album' || 'musicalbum' => MediaKind.album, + 'track' || 'audio' => MediaKind.track, + 'collection' || 'boxset' => MediaKind.collection, + 'playlist' => MediaKind.playlist, + 'clip' || 'trailer' => MediaKind.clip, + 'photo' => MediaKind.photo, + _ => MediaKind.unknown, + }; + } +} diff --git a/lib/media/media_library.dart b/lib/media/media_library.dart new file mode 100644 index 00000000..b9fdc6c9 --- /dev/null +++ b/lib/media/media_library.dart @@ -0,0 +1,77 @@ +import '../utils/global_key_utils.dart'; +import 'media_backend.dart'; +import 'media_kind.dart'; + +/// A top-level browseable section on a server (Plex library section, Jellyfin +/// view). +class MediaLibrary { + /// Backend-opaque identifier (Plex section id like `"5"`, Jellyfin view UUID). + final String id; + final MediaBackend backend; + final String title; + + /// Primary media kind held by this library — drives default UI affordances + /// (poster shape, sort options). For mixed libraries this is [MediaKind.unknown]. + final MediaKind kind; + + /// Optional ISO language code of the library's metadata locale. + final String? language; + + /// Server-side last-update timestamp in seconds. + final int? updatedAt; + final int? createdAt; + + /// Whether the user has hidden this library from the home browser. + final bool hidden; + + /// True for individually-shared items presented as a virtual library + /// (Plex's "shared with me" surface). Jellyfin returns no such marker. + final bool isShared; + + final String? serverId; + final String? serverName; + + const MediaLibrary({ + required this.id, + required this.backend, + required this.title, + this.kind = MediaKind.unknown, + this.language, + this.updatedAt, + this.createdAt, + this.hidden = false, + this.isShared = false, + this.serverId, + this.serverName, + }); + + String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id; + + MediaLibrary copyWith({ + String? id, + MediaBackend? backend, + String? title, + MediaKind? kind, + String? language, + int? updatedAt, + int? createdAt, + bool? hidden, + bool? isShared, + String? serverId, + String? serverName, + }) { + return MediaLibrary( + id: id ?? this.id, + backend: backend ?? this.backend, + title: title ?? this.title, + kind: kind ?? this.kind, + language: language ?? this.language, + updatedAt: updatedAt ?? this.updatedAt, + createdAt: createdAt ?? this.createdAt, + hidden: hidden ?? this.hidden, + isShared: isShared ?? this.isShared, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + ); + } +} diff --git a/lib/media/media_part.dart b/lib/media/media_part.dart new file mode 100644 index 00000000..86e1b3c9 --- /dev/null +++ b/lib/media/media_part.dart @@ -0,0 +1,37 @@ +import 'media_stream.dart'; + +/// One physical file part of a [MediaVersion]. A movie typically has a single +/// part; some Plex multi-part files (CD1/CD2) and DVD/BluRay rips can have +/// several. Jellyfin items always map to a single part per media source. +class MediaPart { + /// Backend-opaque part identifier. + final String id; + + /// Backend-specific path used to construct a direct stream URL — e.g. Plex's + /// `/library/parts/123/file.mkv` or Jellyfin's `/Videos/{id}/stream`. The + /// per-backend client is responsible for prefixing the base URL and + /// appending auth. + final String? streamPath; + + final int? sizeBytes; + final String? container; + final int? durationMs; + final bool? accessible; + final bool? exists; + final List streams; + + const MediaPart({ + required this.id, + this.streamPath, + this.sizeBytes, + this.container, + this.durationMs, + this.accessible, + this.exists, + this.streams = const [], + }); + + /// Defaults to true when fields are absent. Plex sets these only when the + /// metadata request includes `checkFiles=1`. + bool get isPlayable => accessible != false && exists != false; +} diff --git a/lib/media/media_playlist.dart b/lib/media/media_playlist.dart new file mode 100644 index 00000000..f50ed16f --- /dev/null +++ b/lib/media/media_playlist.dart @@ -0,0 +1,109 @@ +import '../utils/global_key_utils.dart'; +import 'media_backend.dart'; + +/// Backend-neutral playlist record. Holds metadata only — items are fetched +/// separately via the client. +class MediaPlaylist { + /// Backend-opaque identifier (Plex `ratingKey`, Jellyfin playlist `Id`). + final String id; + final MediaBackend backend; + final String title; + final String? summary; + final String? guid; + + /// Plex smart-playlist marker; always false for Jellyfin (no equivalent). + final bool smart; + + /// `video`, `audio`, or `photo`. Drives default sort and rendering. + final String playlistType; + + final int? durationMs; + + /// Number of items in the playlist. + final int? leafCount; + final int? viewCount; + + final int? addedAt; + final int? updatedAt; + final int? lastViewedAt; + + /// Plex composite (auto-generated grid). Null on Jellyfin. + final String? compositeImagePath; + final String? thumbPath; + + final String? serverId; + final String? serverName; + + const MediaPlaylist({ + required this.id, + required this.backend, + required this.title, + required this.playlistType, + this.summary, + this.guid, + this.smart = false, + this.durationMs, + this.leafCount, + this.viewCount, + this.addedAt, + this.updatedAt, + this.lastViewedAt, + this.compositeImagePath, + this.thumbPath, + this.serverId, + this.serverName, + }); + + /// Image used to represent the playlist in browse views. + String? get displayImagePath => compositeImagePath ?? thumbPath; + + /// Display-friendly title (alias of [title] for parity with [MediaItem]). + String get displayTitle => title; + + /// Whether this playlist's contents can be reordered/edited by the client. + /// Plex smart playlists are read-only; manual playlists and Jellyfin + /// playlists are editable. + bool get isEditable => !smart; + + String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id; + + MediaPlaylist copyWith({ + String? id, + MediaBackend? backend, + String? title, + String? summary, + String? guid, + bool? smart, + String? playlistType, + int? durationMs, + int? leafCount, + int? viewCount, + int? addedAt, + int? updatedAt, + int? lastViewedAt, + String? compositeImagePath, + String? thumbPath, + String? serverId, + String? serverName, + }) { + return MediaPlaylist( + id: id ?? this.id, + backend: backend ?? this.backend, + title: title ?? this.title, + summary: summary ?? this.summary, + guid: guid ?? this.guid, + smart: smart ?? this.smart, + playlistType: playlistType ?? this.playlistType, + durationMs: durationMs ?? this.durationMs, + leafCount: leafCount ?? this.leafCount, + viewCount: viewCount ?? this.viewCount, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, + compositeImagePath: compositeImagePath ?? this.compositeImagePath, + thumbPath: thumbPath ?? this.thumbPath, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + ); + } +} diff --git a/lib/media/media_role.dart b/lib/media/media_role.dart new file mode 100644 index 00000000..57c69a29 --- /dev/null +++ b/lib/media/media_role.dart @@ -0,0 +1,9 @@ +/// A cast or crew member attached to a media item. +class MediaRole { + final String? id; + final String tag; + final String? role; + final String? thumbPath; + + const MediaRole({this.id, required this.tag, this.role, this.thumbPath}); +} diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart new file mode 100644 index 00000000..49922ad2 --- /dev/null +++ b/lib/media/media_server_client.dart @@ -0,0 +1,572 @@ +import '../media/media_source_info.dart'; +import '../media/media_sort.dart'; +import '../services/api_cache.dart'; +import '../services/playback_initialization_types.dart'; +import '../utils/app_logger.dart'; +import '../utils/media_server_http_client.dart' show AbortController, MediaServerResponse; +import '../utils/external_ids.dart'; +import 'download_resolution.dart'; +import 'library_filter_result.dart'; +import 'library_first_character.dart'; +import 'library_query.dart'; +import 'live_tv_support.dart'; +import 'media_backend.dart'; +import 'media_file_info.dart'; +import 'media_hub.dart'; +import '../services/scrub_preview_source.dart'; +import 'media_item.dart'; +import 'media_kind.dart'; +import 'media_library.dart'; +import 'media_playlist.dart'; +import 'server_capabilities.dart'; + +/// Backend-neutral client for a single media server (Plex or Jellyfin). +/// +/// Each implementation wraps the per-backend HTTP layer and exposes the same +/// operations the rest of the app needs to browse libraries, mark watch +/// state, and render items. Concrete classes ([PlexClient], `JellyfinClient`) +/// own the per-backend networking — providers and UI consume them only +/// through this interface. +/// +/// ## Naming +/// +/// Read methods use a `fetch*` prefix. Plex-only operations that have no +/// Jellyfin equivalent (DVR tuning, metadata edit, match) live on +/// [PlexClient] directly under their original `get*` / verb names. +/// +/// ## Error contract (write methods) +/// +/// All write methods (`markWatched`, `markUnwatched`, `removeFromContinueWatching`, +/// `rate`, `createPlaylist`, `addToPlaylist`, `deletePlaylist`, +/// `movePlaylistItem`, `removeFromPlaylist`, `createCollection`, +/// `addToCollection`, `removeFromCollection`, `deleteCollection`, +/// `deleteMediaItem`) follow the same contract: +/// +/// - HTTP 4xx/5xx → throw [MediaServerHttpException]. +/// - Network/IO failure → throw the underlying exception. +/// - Business "not applicable" (e.g. wrong-backend item handed to a +/// write call) → return `false` without throwing. +/// - Success → return the created entity / `true`. +/// +/// `fetchItem` returns `null` on a real 404 (item gone) and on a 200 that +/// can't be parsed; auth/server errors throw rather than silently dropping +/// to `null`. +/// +/// Callers that need to differentiate "operation impossible" from "server +/// error" should `try`/`catch` the result and inspect the exception's +/// `statusCode`. + +/// Outcome of a health probe. Distinguishes "session expired" (token was +/// rejected) from a generic transport failure, so the manager can route the +/// two states to different UI ("Sign in again" vs "Server offline"). +enum HealthStatus { online, offline, authError } + +abstract class MediaServerClient { + // ── Identity ───────────────────────────────────────────────────── + String get serverId; + String? get serverName; + MediaBackend get backend; + ServerCapabilities get capabilities; + + // ── Lifecycle ──────────────────────────────────────────────────── + /// Release HTTP resources and any other long-lived state. Idempotent. + void close(); + + /// Probe the server with a lightweight auth-required round-trip and + /// classify the outcome. Implementations must surface 401/403 as + /// [HealthStatus.authError] so the manager can flag a revoked token + /// distinctly from a generic network failure. + Future checkHealth(); + + /// Convenience predicate over [checkHealth] for callers that only need a + /// boolean. Treats both `offline` and `authError` as unhealthy. + Future isHealthy() async => (await checkHealth()) == HealthStatus.online; + + /// Server-reported unique identifier (Plex `machineIdentifier`, + /// Jellyfin `Id`). Returns `null` if the probe fails. + Future getMachineIdentifier(); + + /// When `true`, the client serves cached responses only and never hits the + /// network. + bool get isOfflineMode; + void setOfflineMode(bool offline); + + /// Backend-specific cache substrate. Subclasses override this so the + /// shared [MediaServerCacheMixin] helpers can read and write through the + /// appropriate cache instance. + ApiCache get cache; + + // ── Browse: libraries ──────────────────────────────────────────── + Future> fetchLibraries(); + + /// Page through items in [libraryId] using the neutral [query]. Backends + /// translate sort/filter clauses into their own DSL. + Future> fetchLibraryContent(String libraryId, LibraryQuery query); + + /// Backend-aware paginated content fetch. + /// + /// Pagination lives on [LibraryQuery.offset] / [LibraryQuery.limit]. + /// [libraryKind] disambiguates a Jellyfin "Shows" library so it returns + /// Series rows rather than the recursive episode expansion the server + /// defaults to. Plex ignores it (the section id already pins the type). + /// + /// The previous `plexStyleFilters: Map` parameter was + /// retired — the library UI now builds a neutral [LibraryQuery] at the + /// call boundary via `libraryQueryFromPlexMap`, and the Plex client + /// translates back to wire params via [PlexLibraryQueryTranslator]. + Future> fetchLibraryPagedContent( + String libraryId, { + required LibraryQuery query, + MediaKind? libraryKind, + AbortController? abort, + }); + + /// Filter categories for [libraryId] plus any values the backend serves + /// up-front. Plex returns categories without values (the FiltersBottomSheet + /// fetches values lazily per category); Jellyfin returns both in a single + /// `/Items/Filters` call and pre-populates [LibraryFilterResult.cachedValues]. + /// Backends that have no filter listing return [LibraryFilterResult.empty]. + Future fetchLibraryFiltersWithValues(String libraryId); + + /// Backend-aware sort options for [libraryId]. Plex hits + /// `/library/sections/{id}/sorts`; Jellyfin returns a hardcoded list + /// (the API has no equivalent endpoint). Returns [] when the server + /// has no opinion. [libraryType] disambiguates Plex's per-type sort + /// lists (movie vs show). + Future> fetchSortOptions(String libraryId, {String? libraryType}); + + /// First-character bucket counts for the alpha-jump bar in the library + /// browse view. Plex returns real counts from + /// `/library/sections/{id}/firstCharacter` (filterable); Jellyfin has no + /// equivalent endpoint and synthesises a 27-letter alphabet so the bar + /// can act as a name-prefix filter (`size: 1` per entry). + Future> fetchFirstCharacters(String libraryId, {Map? filters}); + + /// Queue a metadata refresh for [libraryId]. The id is the backend-native + /// library identifier (Plex section id / Jellyfin view item id, both + /// surfaced via [MediaLibrary.key]). Plex hits + /// `/library/sections/{id}/refresh?force=1`; Jellyfin posts to + /// `/Items/{id}/Refresh` with `metadataRefreshMode=FullRefresh` (the + /// library view is itself a Jellyfin item, and refresh recurses into its + /// children). + Future refreshLibraryMetadata(String libraryId); + + // ── Browse: items ──────────────────────────────────────────────── + /// Fetch a single item by its backend-opaque id. Returns `null` when the + /// item no longer exists or the user can't see it. + Future fetchItem(String id); + + /// Fetch a single item *and* its on-deck episode (the next unwatched / + /// in-progress episode) in one round-trip when the backend supports it. + /// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`; + /// Jellyfin has no equivalent endpoint and returns `onDeckEpisode: null`, + /// leaving callers to fetch on-deck separately if they need it. + Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id); + + /// Direct children of [parentId] — episodes of a season, seasons of a + /// show, tracks of an album, items of a collection. + Future> fetchChildren(String parentId); + + /// Playable descendants of [parentId] in one server-side query — for a + /// show this returns every episode across every season; for a season the + /// same episodes as [fetchChildren]; on Jellyfin a collection/playlist + /// expands to its Movies + Episodes (Series containers are skipped). + /// Used by playback launch (Jellyfin only — Plex routes containers + /// through `/playQueues`) and by bulk download/sync (both backends) so + /// neither has to walk show → seasons → episodes itself, and neither + /// inherits a per-page Limit cap. + /// + /// Plex hits `/library/metadata/{id}/grandchildren` (the only endpoint + /// the server will one-shot for both show and season, and the + /// recommended path for `skipChildren=true` mini-series); Jellyfin hits + /// `/Items?ParentId={id}&Recursive=true&IncludeItemTypes=Movie,Episode`. + /// Plex's choice means a *collection* ratingKey is not currently + /// supported (no Plex consumer needs that today); add a kind-specific + /// branch if/when one does. + Future> fetchPlayableDescendants(String parentId); + + /// All episodes of a series across every season, ordered by air date — + /// used to build a centred 21-item navigation window when no server-side + /// play queue is available. Returns `null` for backends that maintain + /// queues server-side (Plex's `/playQueues`); returns the list (possibly + /// empty for an empty series) for backends without that capability + /// (Jellyfin). Callers distinguish "no client-side queue" from "empty + /// series" via the null vs `[]` distinction. + Future?> fetchClientSideEpisodeQueue(String seriesId); + + /// Free-text search across the user's libraries. + Future> searchItems(String query, {int limit = 30}); + + /// Recently-added items across all libraries. + Future> fetchRecentlyAdded({int limit = 50}); + + /// Items the user has started but not finished. Plex calls this "On Deck" + /// internally; the neutral name matches the Continue Watching UI surface. + Future> fetchContinueWatching({int count = 20}); + + // ── Browse: hubs ───────────────────────────────────────────────── + /// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin + /// synthesizes `Latest` + `Resume` + `NextUp`). + Future> fetchGlobalHubs({int limit = 10}); + + /// Hubs scoped to a single library section. + Future> fetchLibraryHubs(String libraryId, {int limit = 10}); + + /// "More like this" recommendations for [id]. + Future> fetchRelatedHubs(String id, {int count = 10}); + + /// Page through items in [hubId] when the hub previewed only the first N + /// items (`MediaHub.more == true`). Plex hits `/hubs/{key}` (the same + /// id used in [fetchGlobalHubs]); Jellyfin re-runs the synthesised query + /// (Latest / Resume / NextUp) without the preview limit. + Future> fetchMoreHubItems(String hubId, {int? limit}); + + // ── Watch state ────────────────────────────────────────────────── + /// Mark [item] as watched. The full item is passed (not just an id) so + /// implementations can fire a [WatchStateEvent] on [WatchStateNotifier] + /// for UI invalidation — episode/season/show parent chain, library + /// section etc. live on the item. + Future markWatched(MediaItem item); + Future markUnwatched(MediaItem item); + + /// Hide an item from Continue Watching without changing its watched + /// status. + Future removeFromContinueWatching(MediaItem item); + + /// Rate the item on a 0–10 scale. Backends without numeric ratings + /// (Jellyfin) collapse to like/dislike — see [ServerCapabilities.numericUserRating]. + /// Throws [MediaServerHttpException] on failure, mirroring [markWatched] / + /// [markUnwatched] / [removeFromContinueWatching] — callers wrap the + /// awaited call in `try/catch` and surface a snackbar on the catch arm. + Future rate(MediaItem item, double rating); + + // ── Playlists ──────────────────────────────────────────────────── + Future> fetchPlaylists({String playlistType = 'video', bool? smart}); + + /// Metadata only — items are fetched via [fetchPlaylistItems]. + Future fetchPlaylistMetadata(String id); + + Future> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}); + + /// Create a new playlist seeded with [items]. Returns the created + /// playlist on success, `null` on failure. Plex builds a metadata URI + /// from the item ids; Jellyfin posts `Ids=`. + Future createPlaylist({required String title, required List items}); + + /// Append [items] to an existing playlist. Returns `true` on success. + Future addToPlaylist({required String playlistId, required List items}); + + /// Delete [playlist] from the server. Returns `true` on success. + Future deletePlaylist(MediaPlaylist playlist); + + /// Move an item to a new position within a playlist. The item must have come + /// from this client's [fetchPlaylistItems] (i.e. carry a per-playlist id). + /// + /// [newIndex] - 0-based target position after the move + /// [afterItem] - the item that should sit immediately before [item] after + /// the move, or null when [newIndex] == 0. Plex uses this to derive its + /// `?after=` query param; Jellyfin ignores it (it takes an absolute index). + /// + /// Returns `false` (without throwing) if [item] is from the wrong backend + /// or is missing its per-playlist id — callers should surface a snackbar. + Future movePlaylistItem({ + required String playlistId, + required MediaItem item, + required int newIndex, + required MediaItem? afterItem, + }); + + /// Remove [item] from the playlist [playlistId]. See [movePlaylistItem] for + /// the same caveats about backend tagging and the per-playlist id. + Future removeFromPlaylist({required String playlistId, required MediaItem item}); + + // ── Collections ────────────────────────────────────────────────── + /// Collections in [libraryId]. Plex hits `/library/sections/{id}/collections`; + /// Jellyfin queries `/Items?ParentId={libraryId}&IncludeItemTypes=BoxSet`. + /// Each result carries `kind == MediaKind.collection`. + Future> fetchCollections(String libraryId); + + /// Page through items in [collectionId]. Plex paginates server-side via + /// `/library/collections/{id}/children`; Jellyfin's API has no + /// pagination knob for collection children, so its impl fetches the full + /// list once (cached on the client) and slices locally. Callers can rely + /// on [LibraryPage.totalCount] either way. + Future> fetchCollectionPage( + String collectionId, { + int? start, + int? size, + AbortController? abort, + }); + + /// Create a new collection in [libraryId] seeded with [items]. Returns the + /// created collection's id on success, `null` on failure. [itemKind] is + /// only used by Plex (it disambiguates the section type — movie/show/ + /// season/episode); Jellyfin ignores it. + Future createCollection({ + required String libraryId, + required String title, + required List items, + MediaKind? itemKind, + }); + + /// Append [items] to an existing collection. + Future addToCollection({required String collectionId, required List items}); + + /// Remove a single [item] from [collectionId]. + Future removeFromCollection({required String collectionId, required MediaItem item}); + + /// Delete a collection from the server. The collection is passed as a + /// [MediaItem] (kind == [MediaKind.collection]) so the implementation can + /// read [MediaItem.libraryId] for backends that need it (Plex). + Future deleteCollection(MediaItem collection); + + // ── Item write ─────────────────────────────────────────────────── + /// Permanently delete [item] from the library. + Future deleteMediaItem(MediaItem item); + + /// File info (codec / resolution / bitrate / file path) for [item]. + /// Plex round-trips `/library/metadata/{id}` for the full set; Jellyfin + /// reads inline `MediaSources` for the subset it has. Returns `null` if + /// the server has no info to show. + Future getFileInfo(MediaItem item); + + // ── Images ─────────────────────────────────────────────────────── + /// Resolve a backend-relative thumbnail path to a fully-qualified URL ready + /// for `cached_network_image`. Returns an empty string for null/empty + /// inputs. + /// + /// When [width]/[height] are provided, the implementation should request + /// a server-side resize: Plex builds a `/photo/:/transcode` URL; Jellyfin + /// appends `MaxWidth`/`MaxHeight` to the image endpoint. + String thumbnailUrl(String? path, {int? width, int? height}); + + /// Proxy an absolute external image URL through the server's transcoder + /// (Plex `/photo/:/transcode?url=...`). Backends without a proxy endpoint + /// (Jellyfin) should return the URL unchanged. Used for EPG provider art + /// and other off-server images that benefit from re-encoding. + String externalImageUrl(String url, {int? width, int? height}); + + /// Headers that must be attached when the player fetches a direct-play + /// URL from this server. Plex requires `X-Plex-Token` (and identity + /// headers); Jellyfin embeds its `api_key` in the query string and + /// returns an empty map. Player code should pass these through to the + /// engine alongside the URL. + Map get streamHeaders; + + // ── External IDs ───────────────────────────────────────────────── + /// External IDs (IMDb / TMDB / TVDB) for [itemId]. Plex hits + /// `/library/metadata/{id}?includeGuids=1`; Jellyfin reads the inline + /// `ProviderIds` map. Returns an empty [ExternalIds] when the server + /// has no external mapping for the item. + Future fetchExternalIds(String itemId); + + // ── Hubs: extras ───────────────────────────────────────────────── + /// Chapters and intro/credits markers for [itemId]. Plex returns both + /// in one round trip; Jellyfin only has chapters (markers list is + /// empty). Implementations may cache. + Future fetchPlaybackExtras( + String itemId, { + String? introPattern, + String? creditsPattern, + bool forceRefresh = false, + }); + + /// Cache-only [PlaybackExtras] read for [itemId]. Used as the offline + /// fallback when [fetchPlaybackExtras] cannot reach the network. Returns + /// `null` when no row is cached or the row carries no chapter/marker + /// data — callers treat that as "no extras available" without surfacing + /// an error. + Future fetchPlaybackExtrasFromCacheOnly( + String itemId, { + String? introPattern, + String? creditsPattern, + }); + + /// Cache-only [MediaSourceInfo] read for [itemId]. Used by the offline + /// playback path to recover audio/subtitle track info (track ids, language + /// codes, displayTitles) without hitting the network. Returns `null` when + /// the row isn't cached or carries no usable media source. + Future fetchCachedMediaSourceInfo(String itemId); + + /// Build a scrub preview source for [item] using [mediaSource]. Plex + /// downloads + parses BIF bytes; Jellyfin assembles a sprite-sheet + /// reader from the trickplay manifest. Returns `null` when scrub + /// previews aren't available for this item — either because the + /// backend doesn't advertise the capability, or the per-item inputs + /// are missing (Plex needs `partId`, Jellyfin needs a non-empty + /// `trickplayByWidth` map). + Future createScrubPreviewSource({required MediaItem item, required MediaSourceInfo mediaSource}); + + // ── Playback progress ──────────────────────────────────────────── + /// Watched threshold (0.0–1.0). An item is considered "watched" when + /// `position / duration` crosses this value. Plex reads it from the + /// server's `LibraryVideoPlayedThreshold` pref; Jellyfin doesn't expose + /// one and returns a fixed 0.9. + double get watchedThreshold; + + /// First playback signal for [itemId]. Plex sends a `/:/timeline?state=playing` + /// heartbeat; Jellyfin opens a `/Sessions/Playing` session row. Subsequent + /// ticks must call [reportPlaybackProgress] (Jellyfin distinguishes session + /// open from progress; Plex treats them identically). + /// + /// [duration] is the media's total length — passed through to Plex's + /// timeline param so the server can use it. Jellyfin ignores [duration] but + /// uses [mediaSourceId] and stream indexes for active-session state. + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }); + + /// Progress heartbeat after [reportPlaybackStarted]. State is derived from + /// [isPaused]. Jellyfin persists remembered audio/subtitle choices from the + /// selected stream indexes on this call. + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }); + + /// End-of-session signal. Plex sends `state=stopped`; Jellyfin closes + /// the session row. + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + }); + + // ── Playback initialization ────────────────────────────────────── + /// Resolve the video URL, media info, and external subtitle list for + /// playback. Backends own the per-backend particulars: Plex runs the + /// transcode-decision flow when [PlaybackInitializationOptions.qualityPreset] + /// is non-original; Jellyfin always direct-streams. Throws + /// [PlaybackException] when the item can't be resolved (no MediaSources, + /// no playable URL, transcode decision unavailable). + /// + /// Offline-file substitution is handled centrally in + /// `PlaybackInitializationService` — backends always produce online + /// metadata, even when the caller intends to play a downloaded copy. + Future getPlaybackInitialization(PlaybackInitializationOptions options); + + // ── Live TV ────────────────────────────────────────────────────── + /// Backend-neutral live-TV operations. Always returns a wrapper; consult + /// [LiveTvSupport.isAvailable] to find out whether the server actually + /// has live TV configured before calling other methods. + LiveTvSupport get liveTv; + + // ── Downloads ──────────────────────────────────────────────────── + /// Resolve the download URL for [item]'s primary video file along with + /// any external subtitle tracks that should be saved alongside it. + /// + /// [mediaIndex] selects among multiple media versions when an item has + /// them (Plex only — Jellyfin returns the same file regardless). + Future resolveDownload(MediaItem item, {int mediaIndex = 0}); + + /// The artwork files the download pipeline should persist for [item] so + /// the offline UI can render its poster, clear logo, and background art. + /// Each entry pairs the absolute URL with a stable `localKey` the + /// storage service hashes to deduplicate across items that share blobs. + List resolveDownloadArtwork(MediaItem item); + + /// Resolve a fully-qualified URL the OS-level external player (VLC, Infuse, + /// MX Player, etc.) can fetch directly. Plex builds this from the chosen + /// media version's part path; Jellyfin returns its `/Videos/{id}/stream` + /// endpoint with `Static=true` so transcoding is bypassed. Returns null + /// when the backend can't resolve a playable URL for the item. + Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}); +} + +/// Optional interface for backends whose public server id is not specific +/// enough for user-scoped local state. +abstract interface class ScopedMediaServerClient { + String get scopedServerId; +} + +extension MediaServerClientScope on MediaServerClient { + /// Internal cache/sync namespace. Most backends use [serverId]; Jellyfin + /// overrides this with its compound `{machineId}/{userId}` connection id so + /// per-user `UserData` and queued progress never bleed across profiles. + String get cacheServerId => switch (this) { + ScopedMediaServerClient(:final scopedServerId) => scopedServerId, + _ => serverId, + }; +} + +/// Cache-aware fetch helpers shared by both backends so the offline-first / +/// network-then-cache pattern lives in one place. +/// +/// Originally a Plex-only inline helper; lifted into a mixin so [JellyfinClient] +/// can stop reimplementing it (and gets the missing "fall back to cache on +/// non-network errors" branch). Mixed onto concrete [MediaServerClient] +/// implementations — both clients use `implements MediaServerClient` so a +/// shared base class isn't an option, but a `mixin on MediaServerClient` is. +mixin MediaServerCacheMixin implements MediaServerClient { + /// Fetch with cache fallback: offline → cached only; online → try network, + /// cache the result, fall back to cached on any error. + /// + /// Returns `null` when offline mode is on and no cached row exists, or + /// when both network and cache come up empty. + Future fetchWithCacheFallback({ + required String cacheKey, + required Future Function() networkCall, + required T? Function(dynamic cachedData) parseCache, + required T? Function(MediaServerResponse response) parseResponse, + bool cacheResponse = true, + }) async { + if (isOfflineMode) { + final cached = await cache.get(cacheServerId, cacheKey); + if (cached != null) return parseCache(cached); + return null; + } + try { + final response = await networkCall(); + if (cacheResponse) await _putCacheResponse(cacheKey, response.data); + return parseResponse(response); + } catch (e) { + appLogger.w('Network request failed for $cacheKey, trying cache', error: e); + final cached = await cache.get(cacheServerId, cacheKey); + if (cached != null) return parseCache(cached); + rethrow; + } + } + + /// Cache-first fetch: serve from cache when available, hit the network + /// only on miss. Use when freshness is non-critical and prior fetches are + /// likely to have populated the cache (e.g. playback after the detail + /// screen pre-warmed the row). + Future fetchWithCacheFirst({ + required String cacheKey, + required Future Function() networkCall, + required T? Function(dynamic cachedData) parseCache, + required T? Function(MediaServerResponse response) parseResponse, + bool cacheResponse = true, + }) async { + final cached = await cache.get(cacheServerId, cacheKey); + if (cached != null) return parseCache(cached); + if (isOfflineMode) return null; + final response = await networkCall(); + if (cacheResponse) await _putCacheResponse(cacheKey, response.data); + return parseResponse(response); + } + + Future _putCacheResponse(String cacheKey, dynamic data) async { + if (data is Map) { + await cache.put(cacheServerId, cacheKey, data); + } else if (data != null) { + appLogger.w('Unexpected response type for $cacheKey: ${data.runtimeType}'); + } + } +} diff --git a/lib/media/media_server_user_profile.dart b/lib/media/media_server_user_profile.dart new file mode 100644 index 00000000..5f998f3b --- /dev/null +++ b/lib/media/media_server_user_profile.dart @@ -0,0 +1,53 @@ +/// Backend-neutral subtitle playback modes used by server-side user profiles. +enum SubtitlePlaybackMode { + none, + defaultMode, + always, + onlyForced, + smart; + + static SubtitlePlaybackMode? fromServerValue(Object? value) { + final normalized = value?.toString().trim().toLowerCase(); + return switch (normalized) { + 'none' => SubtitlePlaybackMode.none, + 'default' => SubtitlePlaybackMode.defaultMode, + 'always' => SubtitlePlaybackMode.always, + 'onlyforced' => SubtitlePlaybackMode.onlyForced, + 'smart' => SubtitlePlaybackMode.smart, + _ => null, + }; + } +} + +/// Backend-neutral subset of a server-stored user profile, scoped to the +/// fields the player needs for auto-track selection. Each backend exposes +/// these on its own concrete type ([PlexUserProfile], [JellyfinUserProfile]). +/// +/// Language strings are server-shaped (Plex returns 639-2/B like "fre", +/// Jellyfin returns 639-2/T like "fra"); [LanguageCodes.getVariations] +/// handles either when matching against mpv-reported track languages. +abstract class MediaServerUserProfile { + /// Whether the player should auto-pick an audio track based on the + /// language preferences. False means "keep the file's default track". + bool get autoSelectAudio; + + /// Primary preferred audio language. May be null when the user has no + /// preference set. + String? get defaultAudioLanguage; + + /// Additional ranked audio language preferences. Plex exposes a list, + /// Jellyfin only the primary; Jellyfin implementations return null. + List? get defaultAudioLanguages; + + /// Primary preferred subtitle language. May be null. + String? get defaultSubtitleLanguage; + + /// Additional ranked subtitle language preferences. Same Plex/Jellyfin + /// difference as the audio list. + List? get defaultSubtitleLanguages; + + /// Server-side subtitle mode when exposed by the backend. Plex does not map + /// cleanly to Jellyfin's mode enum, so it returns null and keeps existing + /// Plex-selected-stream behavior. + SubtitlePlaybackMode? get subtitleMode => null; +} diff --git a/lib/models/plex_sort.dart b/lib/media/media_sort.dart similarity index 67% rename from lib/models/plex_sort.dart rename to lib/media/media_sort.dart index 6884d743..0fe56d95 100644 --- a/lib/models/plex_sort.dart +++ b/lib/media/media_sort.dart @@ -1,19 +1,19 @@ import 'package:json_annotation/json_annotation.dart'; -part 'plex_sort.g.dart'; +part 'media_sort.g.dart'; @JsonSerializable() -class PlexSort { +class MediaSort { final String key; final String? descKey; final String title; final String? defaultDirection; - PlexSort({required this.key, this.descKey, required this.title, this.defaultDirection}); + MediaSort({required this.key, this.descKey, required this.title, this.defaultDirection}); - factory PlexSort.fromJson(Map json) => _$PlexSortFromJson(json); + factory MediaSort.fromJson(Map json) => _$MediaSortFromJson(json); - Map toJson() => _$PlexSortToJson(this); + Map toJson() => _$MediaSortToJson(this); /// Gets the full sort key with direction /// If [descending] is true, returns the descKey or key:desc @@ -34,13 +34,13 @@ class PlexSort { @override String toString() { - return 'PlexSort(key: $key, title: $title, defaultDirection: $defaultDirection)'; + return 'MediaSort(key: $key, title: $title, defaultDirection: $defaultDirection)'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is PlexSort && other.key == key; + return other is MediaSort && other.key == key; } @override diff --git a/lib/models/plex_sort.g.dart b/lib/media/media_sort.g.dart similarity index 74% rename from lib/models/plex_sort.g.dart rename to lib/media/media_sort.g.dart index 4ebe5f3c..0e127209 100644 --- a/lib/models/plex_sort.g.dart +++ b/lib/media/media_sort.g.dart @@ -1,19 +1,19 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'plex_sort.dart'; +part of 'media_sort.dart'; // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** -PlexSort _$PlexSortFromJson(Map json) => PlexSort( +MediaSort _$MediaSortFromJson(Map json) => MediaSort( key: json['key'] as String, descKey: json['descKey'] as String?, title: json['title'] as String, defaultDirection: json['defaultDirection'] as String?, ); -Map _$PlexSortToJson(PlexSort instance) => { +Map _$MediaSortToJson(MediaSort instance) => { 'key': instance.key, 'descKey': instance.descKey, 'title': instance.title, diff --git a/lib/models/plex_media_info.dart b/lib/media/media_source_info.dart similarity index 57% rename from lib/models/plex_media_info.dart rename to lib/media/media_source_info.dart index c17bd6cd..a68a78be 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/media/media_source_info.dart @@ -1,95 +1,69 @@ -import '../utils/app_logger.dart'; -import '../utils/json_utils.dart'; import '../utils/codec_utils.dart'; import '../utils/track_label_builder.dart' show buildTrackLabel; -class PlexMediaInfo { +class MediaSourceInfo { final String videoUrl; - final List audioTracks; - final List subtitleTracks; - final List chapters; + final List audioTracks; + final List subtitleTracks; + final List chapters; final int? partId; final double? frameRate; - PlexMediaInfo({ + /// Jellyfin source id for the *selected* version (null on Plex). Lets the + /// trickplay loader request the right tile sheet when an item has multiple + /// `MediaSources`. + final String? mediaSourceId; + + /// Jellyfin default stream indexes for this source. A subtitle index of -1 + /// is an explicit server/user decision to start with subtitles off. + final int? defaultAudioStreamIndex; + final int? defaultSubtitleStreamIndex; + + /// Jellyfin trickplay manifest for the selected source, keyed by tile + /// width. Null when the server didn't run trickplay extraction. Plex stays + /// null here and uses [partId] + the BIF service instead. + final Map? trickplayByWidth; + + MediaSourceInfo({ required this.videoUrl, required this.audioTracks, required this.subtitleTracks, required this.chapters, this.partId, this.frameRate, + this.mediaSourceId, + this.defaultAudioStreamIndex, + this.defaultSubtitleStreamIndex, + this.trickplayByWidth, }); int? getPartId() => partId; +} - /// Creates a [PlexMediaInfo] from cached metadata JSON (as stored by [PlexApiCache]). - /// Parses audio/subtitle tracks from `Media[0].Part[0].Stream[]` so that - /// offline playback can still apply language-based track selection. - static PlexMediaInfo? fromMetadataJson(Map metadata) { - final media = flexibleList(metadata['Media']); - if (media == null || media.isEmpty) return null; - final parts = flexibleList(media.first['Part']); - if (parts == null || parts.isEmpty) return null; - final streams = flexibleList(parts.first['Stream']); +/// Per-resolution Jellyfin trickplay manifest. Mirrors `TrickplayInfoDto` +/// from the Jellyfin OpenAPI spec. +class TrickplayInfo { + final int width; + final int height; + final int tileWidth; + final int tileHeight; + final int thumbnailCount; + final int interval; + final int bandwidth; - final audioTracks = []; - final subtitleTracks = []; - double? frameRate; - - if (streams != null) { - for (final s in streams) { - try { - final streamType = s['streamType'] as int?; - if (streamType == 1) { - frameRate ??= (s['frameRate'] as num?)?.toDouble(); - } else if (streamType == 2) { - audioTracks.add( - PlexAudioTrack( - id: s['id'] as int, - index: s['index'] as int?, - codec: s['codec'] as String?, - language: s['language'] as String?, - languageCode: s['languageCode'] as String?, - title: s['title'] as String?, - displayTitle: s['displayTitle'] as String?, - channels: s['channels'] as int?, - selected: flexibleBool(s['selected']), - ), - ); - } else if (streamType == 3) { - subtitleTracks.add( - PlexSubtitleTrack( - id: s['id'] as int, - index: s['index'] as int?, - codec: s['codec'] as String?, - language: s['language'] as String?, - languageCode: s['languageCode'] as String?, - title: s['title'] as String?, - displayTitle: s['displayTitle'] as String?, - selected: flexibleBool(s['selected']), - forced: flexibleBool(s['forced']), - key: s['key'] as String?, - ), - ); - } - } catch (e) { - appLogger.d('Skipping malformed stream in cached metadata', error: e); - } - } - } - - return PlexMediaInfo( - videoUrl: '', - audioTracks: audioTracks, - subtitleTracks: subtitleTracks, - chapters: const [], - frameRate: frameRate, - ); - } + const TrickplayInfo({ + required this.width, + required this.height, + required this.tileWidth, + required this.tileHeight, + required this.thumbnailCount, + required this.interval, + required this.bandwidth, + }); } /// Mixin for building track labels with a consistent pattern. /// -/// Used by [PlexAudioTrack] and [PlexSubtitleTrack] to provide a [buildLabel] +/// Used by [MediaAudioTrack] and [MediaSubtitleTrack] to provide a [buildLabel] /// method that delegates to the shared [buildTrackLabel] function. mixin _TrackLabelMixin { int get id; @@ -108,7 +82,7 @@ mixin _TrackLabelMixin { } } -class PlexAudioTrack with _TrackLabelMixin { +class MediaAudioTrack with _TrackLabelMixin { @override final int id; @override @@ -123,7 +97,7 @@ class PlexAudioTrack with _TrackLabelMixin { final int? channels; final bool selected; - PlexAudioTrack({ + MediaAudioTrack({ required this.id, this.index, this.codec, @@ -143,7 +117,7 @@ class PlexAudioTrack with _TrackLabelMixin { } } -class PlexSubtitleTrack with _TrackLabelMixin { +class MediaSubtitleTrack with _TrackLabelMixin { @override final int id; @override @@ -158,8 +132,9 @@ class PlexSubtitleTrack with _TrackLabelMixin { final bool selected; final bool forced; final String? key; + final bool external; - PlexSubtitleTrack({ + MediaSubtitleTrack({ required this.id, this.index, this.codec, @@ -170,6 +145,7 @@ class PlexSubtitleTrack with _TrackLabelMixin { required this.selected, required this.forced, this.key, + this.external = false, }); String get label { @@ -178,30 +154,13 @@ class PlexSubtitleTrack with _TrackLabelMixin { return buildLabel(additionalParts); } - /// Returns true if this subtitle track is an external file (sidecar subtitle) - /// External subtitles have a key property that points to /library/streams/{id} - bool get isExternal => key != null && key!.isNotEmpty; - - /// Constructs the full URL for fetching external subtitle files - /// Returns null if this is not an external subtitle - String? getSubtitleUrl(String baseUrl, String token) { - if (!isExternal) return null; - final ext = CodecUtils.getSubtitleExtension(codec); - return '$baseUrl$key.$ext?encoding=utf-8&X-Plex-Token=$token'; - } - - /// Constructs a sidecar URL for any subtitle track (internal or external), - /// used in transcode mode where embedded subtitle streams are stripped. - /// Falls back to the standard `/library/streams/{id}.{ext}` path when - /// [key] is missing. - String getTranscodeSidecarUrl(String baseUrl, String token) { - final ext = CodecUtils.getSubtitleExtension(codec); - final path = (key != null && key!.isNotEmpty) ? key! : '/library/streams/$id'; - return '$baseUrl$path.$ext?encoding=utf-8&X-Plex-Token=$token'; - } + /// Returns true if this subtitle track is an external file (sidecar subtitle). + /// Some backends provide a direct key/URL, others require constructing one + /// from stream metadata. + bool get isExternal => external || (key != null && key!.isNotEmpty); } -class PlexChapter { +class MediaChapter { final int id; final int? index; final int? startTimeOffset; @@ -209,7 +168,26 @@ class PlexChapter { final String? title; final String? thumb; - PlexChapter({required this.id, this.index, this.startTimeOffset, this.endTimeOffset, this.title, this.thumb}); + MediaChapter({required this.id, this.index, this.startTimeOffset, this.endTimeOffset, this.title, this.thumb}); + + /// Backfill missing `endTimeOffset` on each chapter from the next chapter's + /// `startTimeOffset`. Jellyfin sends only starts; the seek-bar tick UI needs + /// duration ranges. Mutates [chapters] in place and returns it. + static List backfillEndOffsets(List chapters) { + for (var i = 0; i < chapters.length - 1; i++) { + final c = chapters[i]; + if (c.endTimeOffset != null) continue; + chapters[i] = MediaChapter( + id: c.id, + index: c.index, + startTimeOffset: c.startTimeOffset, + endTimeOffset: chapters[i + 1].startTimeOffset, + title: c.title, + thumb: c.thumb, + ); + } + return chapters; + } String get label => title ?? 'Chapter ${(index ?? 0) + 1}'; @@ -219,7 +197,7 @@ class PlexChapter { /// Find the chapter index containing [position]. Returns null if none match. /// A chapter's end defaults to the next chapter's start when [endTimeOffset] /// is missing; the final chapter without an end extends to infinity. - static int? indexAtPosition(Duration position, List chapters) { + static int? indexAtPosition(Duration position, List chapters) { final positionMs = position.inMilliseconds; for (int i = 0; i < chapters.length; i++) { final chapter = chapters[i]; @@ -233,13 +211,13 @@ class PlexChapter { } } -class PlexMarker { +class MediaMarker { final int id; final String type; final int startTimeOffset; final int endTimeOffset; - PlexMarker({required this.id, required this.type, required this.startTimeOffset, required this.endTimeOffset}); + MediaMarker({required this.id, required this.type, required this.startTimeOffset, required this.endTimeOffset}); Duration get startTime => Duration(milliseconds: startTimeOffset); Duration get endTime => Duration(milliseconds: endTimeOffset); @@ -255,8 +233,8 @@ class PlexMarker { /// Combined chapters and markers fetched in a single API call class PlaybackExtras { - final List chapters; - final List markers; + final List chapters; + final List markers; PlaybackExtras({required this.chapters, required this.markers}); @@ -271,8 +249,8 @@ class PlaybackExtras { /// When real markers exist, reclassifies markers with unknown types against /// the patterns so non-standard type strings (e.g. "OP-Song") get recognized. factory PlaybackExtras.withChapterFallback({ - required List chapters, - required List markers, + required List chapters, + required List markers, String? introPatternStr, String? creditsPatternStr, }) { @@ -291,7 +269,7 @@ class PlaybackExtras { if (m.type == 'intro' || m.type == 'credits') return m; final newType = _classifyChapterTitle(m.type, introPattern, creditsPattern); if (newType != null) { - return PlexMarker( + return MediaMarker( id: m.id, type: newType, startTimeOffset: m.startTimeOffset, @@ -303,7 +281,7 @@ class PlaybackExtras { return PlaybackExtras(chapters: chapters, markers: reclassified); } - final synthetic = []; + final synthetic = []; for (var i = 0; i < chapters.length; i++) { final ch = chapters[i]; final title = ch.title; @@ -318,7 +296,7 @@ class PlaybackExtras { final end = ch.endTimeOffset ?? (i + 1 < chapters.length ? chapters[i + 1].startTimeOffset : null); if (end == null) continue; - synthetic.add(PlexMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end)); + synthetic.add(MediaMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end)); } return PlaybackExtras(chapters: chapters, markers: synthetic); diff --git a/lib/media/media_stream.dart b/lib/media/media_stream.dart new file mode 100644 index 00000000..b3a193f9 --- /dev/null +++ b/lib/media/media_stream.dart @@ -0,0 +1,49 @@ +/// Type of an embedded or sidecar stream within a media file. +enum MediaStreamKind { video, audio, subtitle, unknown } + +/// A single audio, video, or subtitle stream inside a media part. +class MediaStream { + /// Backend-opaque stream identifier. + final String id; + final MediaStreamKind kind; + final int? index; + final String? codec; + final String? language; + final String? languageCode; + final String? title; + final String? displayTitle; + final bool selected; + + // Audio + final int? channels; + + // Video + final double? frameRate; + + // Subtitle + final bool forced; + + /// Backend-resolved location for sidecar subtitle download. For Plex this + /// is the Plex-specific `/library/streams/{id}` path; for Jellyfin this is + /// the `DeliveryUrl` returned by `/Items/{id}/PlaybackInfo`. Null for + /// embedded streams. + final String? sidecarPath; + + const MediaStream({ + required this.id, + required this.kind, + this.index, + this.codec, + this.language, + this.languageCode, + this.title, + this.displayTitle, + this.selected = false, + this.channels, + this.frameRate, + this.forced = false, + this.sidecarPath, + }); + + bool get isExternal => sidecarPath != null && sidecarPath!.isNotEmpty; +} diff --git a/lib/media/media_version.dart b/lib/media/media_version.dart new file mode 100644 index 00000000..7f84e13c --- /dev/null +++ b/lib/media/media_version.dart @@ -0,0 +1,117 @@ +import '../utils/codec_utils.dart'; +import '../utils/formatters.dart'; +import 'media_part.dart'; + +/// Convert backend bitrates reported in bits-per-second to app-standard kbps. +int? bitrateKbpsFromBps(int? bps) { + if (bps == null || bps <= 0) return null; + return (bps / 1000).round(); +} + +/// A single media variant available for an item — represents one quality level +/// or transcode profile of the underlying file. An item with multiple versions +/// (e.g. 4K + 1080p re-encode) exposes one [MediaVersion] per option. +class MediaVersion { + /// Backend-opaque version identifier. + final String id; + final int? width; + final int? height; + final String? videoResolution; // "1080", "4k", "sd" + final String? videoCodec; + final int? bitrate; + final String? container; + final List parts; + + /// Human-readable name for this version (e.g. "Director's Cut"). + /// Plex doesn't surface a name on `Media` entries, so this is null on the + /// Plex path and set from `MediaSource.Name` on the Jellyfin path when the + /// names differ across sources. + final String? name; + + const MediaVersion({ + required this.id, + this.width, + this.height, + this.videoResolution, + this.videoCodec, + this.bitrate, + this.container, + this.parts = const [], + this.name, + }); + + /// Defaults to true when file-access fields are absent. Plex only populates + /// them when metadata is fetched with `checkFiles=1`. + bool get isPlayable => parts.isEmpty || parts.first.isPlayable; + + /// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)". + /// When [name] is set, it prefixes the technical label so a user can tell + /// "Director's Cut · 1080p H.264 MKV" apart from "Theatrical Cut · 1080p + /// H.264 MKV" when the underlying tech specs collide. + String get displayLabel { + final parts = []; + + if (videoResolution != null && videoResolution!.isNotEmpty) { + parts.add('${videoResolution}p'); + } else if (height != null) { + parts.add('${height}p'); + } + + if (videoCodec != null && videoCodec!.isNotEmpty) { + parts.add(CodecUtils.formatVideoCodec(videoCodec!)); + } + + if (container != null && container!.isNotEmpty) { + parts.add(container!.toUpperCase()); + } + + String label = parts.isNotEmpty ? parts.join(' ') : 'Unknown'; + + if (bitrate != null && bitrate! > 0) { + label += ' (${ByteFormatter.formatBitrate(bitrate!)})'; + } + + if (name != null && name!.isNotEmpty) { + return parts.isEmpty && (bitrate == null || bitrate! <= 0) ? name! : '$name · $label'; + } + return label; + } + + /// Version signature used for matching equivalent versions across episodes. + /// Format: "resolution:codec:container". + String get signature { + final res = videoResolution ?? ''; + final codec = videoCodec ?? ''; + final cont = container ?? ''; + return '$res:$codec:$cont'.toLowerCase(); + } + + String get _resolutionPart => (videoResolution ?? '').toLowerCase(); + String get _codecPart => (videoCodec ?? '').toLowerCase(); + + /// Find the best matching version index from a set of accepted signatures. + /// Tier 1: exact match. Tier 2: resolution+codec. Tier 3: resolution only. + /// Returns null if no accepted signature matches. + static int? findMatchingIndex(List versions, Set acceptedSignatures) { + if (versions.isEmpty || acceptedSignatures.isEmpty) return null; + + for (final sig in acceptedSignatures) { + final parts = sig.split(':'); + if (parts.length != 3) continue; + final targetRes = parts[0]; + final targetCodec = parts[1]; + + for (int i = 0; i < versions.length; i++) { + if (versions[i].signature == sig) return i; + } + for (int i = 0; i < versions.length; i++) { + if (versions[i]._resolutionPart == targetRes && versions[i]._codecPart == targetCodec) return i; + } + for (int i = 0; i < versions.length; i++) { + if (versions[i]._resolutionPart == targetRes) return i; + } + } + + return null; + } +} diff --git a/lib/media/play_queue.dart b/lib/media/play_queue.dart new file mode 100644 index 00000000..97698352 --- /dev/null +++ b/lib/media/play_queue.dart @@ -0,0 +1,122 @@ +import 'media_item.dart'; + +/// Backend-neutral play queue — a flat ordered list of items with a current +/// cursor. Implementations differ in whether the queue is server-resourced +/// (Plex) or client-only (Jellyfin). +sealed class PlayQueue { + /// Items in playback order. + List get items; + + /// Index of the currently-playing item, or `null` if the queue has not + /// started. + int? get currentIndex; + + /// Whether the queue has been shuffled. + bool get shuffled; + + /// Backend that minted this queue. + String get backendId; + + MediaItem? get current => + currentIndex != null && currentIndex! >= 0 && currentIndex! < items.length ? items[currentIndex!] : null; + + bool get hasNext => currentIndex != null && currentIndex! + 1 < items.length; + bool get hasPrevious => currentIndex != null && currentIndex! > 0; +} + +/// Plex play queue — coordinated server-side via `/playQueues` so multiple +/// devices can view/control the same queue. +class PlexServerPlayQueue extends PlayQueue { + /// Plex `playQueueID` — addresses the queue for subsequent fetches. + final int playQueueId; + + @override + final List items; + + @override + final int? currentIndex; + + @override + final bool shuffled; + + /// Plex `playQueueSelectedItemID` of the active item. + final int? selectedItemId; + + /// Plex `playQueueVersion` — server-side optimistic concurrency token. + final int? version; + + /// Plex `playQueueSourceURI` — used for "Up Next" derivation. + final String? sourceUri; + + PlexServerPlayQueue({ + required this.playQueueId, + required this.items, + this.currentIndex, + this.shuffled = false, + this.selectedItemId, + this.version, + this.sourceUri, + }); + + @override + String get backendId => 'plex'; + + PlexServerPlayQueue copyWith({ + int? playQueueId, + List? items, + int? currentIndex, + bool? shuffled, + int? selectedItemId, + int? version, + String? sourceUri, + }) { + return PlexServerPlayQueue( + playQueueId: playQueueId ?? this.playQueueId, + items: items ?? this.items, + currentIndex: currentIndex ?? this.currentIndex, + shuffled: shuffled ?? this.shuffled, + selectedItemId: selectedItemId ?? this.selectedItemId, + version: version ?? this.version, + sourceUri: sourceUri ?? this.sourceUri, + ); + } +} + +/// Client-only play queue used by Jellyfin and any backend without a +/// server-side queue concept. Each [LocalPlayQueue] is anchored by a +/// client-generated UUID so callers can address it like a Plex queue. +class LocalPlayQueue extends PlayQueue { + /// Client-generated UUID identifying this queue for the session. + final String id; + + @override + final List items; + + @override + final int? currentIndex; + + @override + final bool shuffled; + + /// Server kind that owns this queue's items (typically `"jellyfin"`). + @override + final String backendId; + + LocalPlayQueue({ + required this.id, + required this.items, + required this.backendId, + this.currentIndex, + this.shuffled = false, + }); + + LocalPlayQueue copyWith({String? id, List? items, int? currentIndex, bool? shuffled, String? backendId}) { + return LocalPlayQueue( + id: id ?? this.id, + items: items ?? this.items, + currentIndex: currentIndex ?? this.currentIndex, + shuffled: shuffled ?? this.shuffled, + backendId: backendId ?? this.backendId, + ); + } +} diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart new file mode 100644 index 00000000..26cc86ae --- /dev/null +++ b/lib/media/server_capabilities.dart @@ -0,0 +1,219 @@ +/// How the alpha-jump bar behaves for libraries on this backend. +enum AlphaBarMode { + /// No alpha bar — hide entirely. + none, + + /// Plex: server reports per-letter cumulative offsets via `/firstCharacter`, + /// taps scroll the grid to the offset. + scrollSnap, + + /// Jellyfin: bar acts as a filter button — taps set `NameStartsWith` query + /// param, results re-fetch. + nameStartsWithFilter, +} + +/// Static capability flags advertised by a [MediaServerClient]. UI consults +/// these to gate feature affordances per server (e.g. hide Live TV when no +/// connected server supports it). +/// +/// These describe what the *backend kind* supports in this app's current +/// implementation — not necessarily what the wire protocol can do. As more +/// Jellyfin features are wired in over time, the corresponding flags flip +/// without changing call sites. +class ServerCapabilities { + /// Server-side `PlayQueue` resource (Plex `/playQueues`) — enables shared + /// queue state across devices and Watch Together coordination. + final bool serverSidePlayQueue; + + /// Server-side editable playlists (Plex `/playlists`, Jellyfin + /// `/Playlists`). + final bool serverSidePlaylists; + + /// This backend kind has a Live TV / DVR API the app can talk to. Whether + /// a *specific* server has Live TV configured is a runtime concern — + /// [MultiServerProvider.checkLiveTvAvailability] probes each server and + /// only those with channels surface in [MultiServerProvider.liveTvServers]. + final bool liveTv; + + /// Server has DVR/recording lineups (Plex `/livetv/dvrs`). Channel listing + /// is gated by [liveTv]; this flag enables the additional recordings/scheduling + /// UI. Jellyfin's DVR API isn't wired in this app yet, so it stays false even + /// when [liveTv] is true. + final bool liveTvDvr; + + /// Server proxies subtitle search (e.g. OpenSubtitles). + final bool subtitleSearch; + + /// Server can transcode video. + final bool videoTranscoding; + + /// Server supports server-side downloads / "sync" (the queued-from-server + /// model). Both Plex and Jellyfin support client-driven downloads, which + /// is a separate concept. + final bool serverSideSync; + + /// Server provides curated recommendation hubs (Plex Discover). Jellyfin + /// returns synthesized hubs but with sparser categorisation. + final bool richHubs; + + /// Numeric ratings (Plex 0–10 via [Item.userRating]). Jellyfin offers + /// only a binary like/dislike, so star sliders should be hidden. + final bool numericUserRating; + + /// External subtitle search/marketplace (Plex `/library/metadata/{id}/subtitles`). + /// Hides the "Search subtitles" affordance when false. + final bool externalSubtitleSearch; + + /// Persisting per-track audio/subtitle preferences server-side. Plex uses + /// `/library/metadata/{id}/prefs` + `selectStream`; Jellyfin saves selected + /// stream indexes from `/Sessions/Playing/Progress` when the user's Jellyfin + /// remember-selection settings are enabled. When false, in-player switching + /// still works but choices don't follow the user across devices. + final bool trackPreferencePersistence; + + /// Multi-endpoint connection model with relay/local/remote racing + /// (Plex `findBestWorkingConnection`). Jellyfin servers expose a single + /// URL, so this is false there. + final bool endpointFailover; + + /// Watch progress can be queued offline and replayed when reconnected + /// ([OfflineWatchSyncService]). Jellyfin reports inline only today. + final bool offlineWatchQueue; + + /// Discord rich-presence integration. Plex-only because the RPC payload + /// uses Plex-shaped session/metadata. + final bool discordRpc; + + /// Server exposes a metadata edit endpoint (Plex + /// `/library/metadata/{id}` PUT). Hides the "Manage" affordances when + /// false. + final bool richMetadataEdit; + + /// How the alpha-jump bar should behave for this backend's libraries. + final AlphaBarMode alphaBar; + + /// Server can supply thumbnails for the player's seek-bar scrub preview. + /// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite + /// sheets. Both backends are wired through [ScrubPreviewSource]; the flag + /// gates whether the player attempts the load at all. + final bool scrubThumbnails; + + /// Library section exposes a folder hierarchy (Plex + /// `/library/sections/{id}/folders`). Jellyfin has no equivalent endpoint, + /// so the "Folders" grouping option is hidden when this is false. + final bool folderGrouping; + + const ServerCapabilities({ + this.serverSidePlayQueue = false, + this.serverSidePlaylists = false, + this.liveTv = false, + this.liveTvDvr = false, + this.subtitleSearch = false, + this.videoTranscoding = true, + this.serverSideSync = false, + this.richHubs = false, + this.numericUserRating = false, + this.externalSubtitleSearch = false, + this.trackPreferencePersistence = false, + this.endpointFailover = false, + this.offlineWatchQueue = false, + this.discordRpc = false, + this.richMetadataEdit = false, + this.alphaBar = AlphaBarMode.none, + this.scrubThumbnails = false, + this.folderGrouping = false, + }); + + /// Defaults for a fully-featured Plex server. + static const ServerCapabilities plex = ServerCapabilities( + serverSidePlayQueue: true, + serverSidePlaylists: true, + liveTv: true, + liveTvDvr: true, + subtitleSearch: true, + videoTranscoding: true, + serverSideSync: true, + richHubs: true, + numericUserRating: true, + externalSubtitleSearch: true, + trackPreferencePersistence: true, + endpointFailover: true, + offlineWatchQueue: true, + discordRpc: true, + richMetadataEdit: true, + alphaBar: AlphaBarMode.scrollSnap, + scrubThumbnails: true, + folderGrouping: true, + ); + + /// Defaults for a Jellyfin server. + /// + /// `videoTranscoding` is `true` — `JellyfinClient.getPlaybackInitialization` + /// negotiates via `POST /Items/{id}/PlaybackInfo` and uses the server's + /// `TranscodingUrl` when a non-original quality preset is selected. + /// + /// `liveTv` is `true` because Jellyfin exposes `/LiveTv/Channels` and + /// `/LiveTv/Programs`. Detection + channel listing are wired today; + /// EPG and tuning are follow-ups. + static const ServerCapabilities jellyfin = ServerCapabilities( + serverSidePlayQueue: false, + serverSidePlaylists: true, + liveTv: true, + liveTvDvr: false, + subtitleSearch: false, + videoTranscoding: true, + serverSideSync: false, + richHubs: false, + numericUserRating: false, + externalSubtitleSearch: false, + trackPreferencePersistence: true, + endpointFailover: false, + offlineWatchQueue: false, + discordRpc: false, + richMetadataEdit: false, + alphaBar: AlphaBarMode.nameStartsWithFilter, + scrubThumbnails: true, + ); + + ServerCapabilities copyWith({ + bool? serverSidePlayQueue, + bool? serverSidePlaylists, + bool? liveTv, + bool? liveTvDvr, + bool? subtitleSearch, + bool? videoTranscoding, + bool? serverSideSync, + bool? richHubs, + bool? numericUserRating, + bool? externalSubtitleSearch, + bool? trackPreferencePersistence, + bool? endpointFailover, + bool? offlineWatchQueue, + bool? discordRpc, + bool? richMetadataEdit, + AlphaBarMode? alphaBar, + bool? scrubThumbnails, + bool? folderGrouping, + }) { + return ServerCapabilities( + serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue, + serverSidePlaylists: serverSidePlaylists ?? this.serverSidePlaylists, + liveTv: liveTv ?? this.liveTv, + liveTvDvr: liveTvDvr ?? this.liveTvDvr, + subtitleSearch: subtitleSearch ?? this.subtitleSearch, + videoTranscoding: videoTranscoding ?? this.videoTranscoding, + serverSideSync: serverSideSync ?? this.serverSideSync, + richHubs: richHubs ?? this.richHubs, + numericUserRating: numericUserRating ?? this.numericUserRating, + externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch, + trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence, + endpointFailover: endpointFailover ?? this.endpointFailover, + offlineWatchQueue: offlineWatchQueue ?? this.offlineWatchQueue, + discordRpc: discordRpc ?? this.discordRpc, + richMetadataEdit: richMetadataEdit ?? this.richMetadataEdit, + alphaBar: alphaBar ?? this.alphaBar, + scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails, + folderGrouping: folderGrouping ?? this.folderGrouping, + ); + } +} diff --git a/lib/mixins/deletion_aware.dart b/lib/mixins/deletion_aware.dart index f6313ad7..81f820cf 100644 --- a/lib/mixins/deletion_aware.dart +++ b/lib/mixins/deletion_aware.dart @@ -11,16 +11,16 @@ import 'event_aware.dart'; /// Example usage: /// ```dart /// class _MyScreenState extends State with DeletionAware { -/// List _items = []; +/// List _items = []; /// /// @override -/// Set? get deletionRatingKeys => -/// _items.map((e) => e.ratingKey).toSet(); +/// Set? get deletionIds => +/// _items.map((e) => e.id).toSet(); /// /// @override /// void onDeletionEvent(DeletionEvent event) { /// setState(() { -/// _items.removeWhere((e) => e.ratingKey == event.ratingKey); +/// _items.removeWhere((e) => e.id == event.itemId); /// }); /// } /// } @@ -36,22 +36,22 @@ mixin DeletionAware on State { /// Override to specify which global keys this screen cares about. /// /// Use format `serverId:ratingKey`. - /// Return null to fall back to [deletionRatingKeys] matching. + /// Return null to fall back to [deletionIds] matching. Set? get deletionGlobalKeys => null; - /// Override to specify which ratingKeys this screen cares about. + /// Override to specify which item ids this screen cares about. /// /// Return null to receive ALL events (not recommended for performance). /// Return an empty set to receive no events. /// /// The set should include: - /// - Direct items displayed (e.g., episode ratingKeys in a season view) - /// - Parent items that affect display (e.g., show ratingKey for seasons) - Set? get deletionRatingKeys; + /// - Direct items displayed (e.g., episode ids in a season view) + /// - Parent items that affect display (e.g., show id for seasons) + Set? get deletionIds; /// Called when a relevant deletion event occurs. /// - /// Only called if [deletionRatingKeys] is null or contains an affected key. + /// Only called if [deletionIds] is null or contains an affected key. void onDeletionEvent(DeletionEvent event); @override @@ -62,7 +62,7 @@ mixin DeletionAware on State { mounted: () => mounted, serverId: () => deletionServerId, globalKeys: () => deletionGlobalKeys, - ratingKeys: () => deletionRatingKeys, + itemIds: () => deletionIds, onEvent: onDeletionEvent, ); } diff --git a/lib/mixins/event_aware.dart b/lib/mixins/event_aware.dart index b9b78792..a366120a 100644 --- a/lib/mixins/event_aware.dart +++ b/lib/mixins/event_aware.dart @@ -11,7 +11,7 @@ StreamSubscription subscribeToHierarchicalEvents? Function() globalKeys, - required Set? Function() ratingKeys, + required Set? Function() itemIds, required void Function(E event) onEvent, }) { return notifier.stream.listen((event) { @@ -28,8 +28,8 @@ StreamSubscription subscribeToHierarchicalEvents on State { - /// The Plex client to use for fetching updated metadata - /// Each screen must provide access to their client - PlexClient get client; + /// Override to enable backend-aware item refresh. [updateItem] resolves + /// the right [MediaServerClient] for the item's server. When null, + /// [updateItem] is a no-op. + String? get itemServerId => null; /// Updates a single item in the screen's list(s) after watch state changes /// @@ -19,12 +20,14 @@ mixin ItemUpdatable on State { /// /// If the fetch fails, the error is silently caught and the item will /// be updated on the next full refresh. - Future updateItem(String ratingKey) async { + Future updateItem(String itemId) async { try { - final updatedMetadata = await client.getMetadataWithImages(ratingKey); - if (updatedMetadata != null) { + final serverId = itemServerId; + if (serverId == null) return; + final updatedItem = await context.tryGetMediaClientForServer(serverId)?.fetchItem(itemId); + if (updatedItem != null) { setState(() { - updateItemInLists(ratingKey, updatedMetadata); + updateItemInLists(itemId, updatedItem); }); } } catch (e) { @@ -40,12 +43,12 @@ mixin ItemUpdatable on State { /// Example: /// ```dart /// @override - /// void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { - /// final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + /// void updateItemInLists(String itemId, MediaItem updatedItem) { + /// final index = _items.indexWhere((item) => item.id == itemId); /// if (index != -1) { - /// _items[index] = updatedMetadata; + /// _items[index] = updatedItem; /// } /// } /// ``` - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata); + void updateItemInLists(String itemId, MediaItem updatedItem); } diff --git a/lib/mixins/library_tab_state.dart b/lib/mixins/library_tab_state.dart index a505e1c1..5dde1da2 100644 --- a/lib/mixins/library_tab_state.dart +++ b/lib/mixins/library_tab_state.dart @@ -1,15 +1,23 @@ import 'package:flutter/material.dart'; +import '../media/media_library.dart'; +import '../media/media_server_client.dart'; import '../services/plex_client.dart'; -import '../models/plex_library.dart'; import '../utils/provider_extensions.dart'; /// Mixin providing common functionality for library tab screens /// Provides server-specific client resolution for multi-server support mixin LibraryTabStateMixin on State { /// The library being displayed - PlexLibrary get library; + MediaLibrary get library; - /// Get the correct PlexClient for this library's server - /// Throws an exception if no client is available - PlexClient getClientForLibrary() => context.getClientForLibrary(library); + /// Get the [PlexClient] for this library's server. Throws if unavailable. + /// Use [getMediaClientForLibrary] in code paths that work for both Plex + /// and Jellyfin via the [MediaServerClient] interface — this getter is + /// for Plex-only methods (collections, metadata edit, etc.). + PlexClient getClientForLibrary() => context.getPlexClientForLibrary(library); + + /// Get a backend-neutral [MediaServerClient] for this library's server. + /// Throws if unavailable. Prefer this over [getClientForLibrary] for any + /// flow that doesn't strictly need Plex-only APIs. + MediaServerClient getMediaClientForLibrary() => context.getMediaClientForLibrary(library); } diff --git a/lib/mixins/paginated_item_loader.dart b/lib/mixins/paginated_item_loader.dart index 6eb1d40a..c2bd3f0a 100644 --- a/lib/mixins/paginated_item_loader.dart +++ b/lib/mixins/paginated_item_loader.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; -import '../models/plex_metadata.dart'; -import '../services/plex_client.dart'; -import '../utils/plex_http_client.dart'; -import '../utils/plex_http_exception.dart'; +import '../media/library_query.dart'; +import '../media/media_item.dart'; +import '../utils/media_server_http_client.dart'; +import '../exceptions/media_server_exceptions.dart'; /// Sparse-loading state + fetch orchestration for paginated item grids/lists. /// @@ -22,7 +22,7 @@ import '../utils/plex_http_exception.dart'; /// 3. On dispose, subclass calls [disposePagination]. mixin PaginatedItemLoader on State { /// Sparse map of loaded items, keyed by position. - final Map loadedItems = {}; + final Map loadedItems = {}; /// Total items on the server. 0 until the first page completes. int totalSize = 0; @@ -43,12 +43,12 @@ mixin PaginatedItemLoader on State { VoidCallback? _scheduledRetry; /// Fetch a page of items. Subclass implements this — typically delegating - /// to a paginated `PlexClient` method that returns a [LibraryContentResult]. - Future fetchPage(int start, int size, AbortController? abort); + /// to a paginated client method that returns a [LibraryPage] of [MediaItem]. + Future> fetchPage(int start, int size, AbortController? abort); /// Hook fired after each successful page merge. Default: no-op. /// Override for image prefetch, syncing a base-class `items` list, etc. - void onPageLoaded(int start, List items) {} + void onPageLoaded(int start, List items) {} /// Synchronously clear pagination state and bump the generation counter. /// Call from inside the subclass's `setState` before awaiting @@ -69,7 +69,7 @@ mixin PaginatedItemLoader on State { /// Fetch the first page. Await from outside `setState`. Mutates /// [loadedItems] and [totalSize] on success; throws on failure. - Future loadInitialPage(int pageSize) async { + Future> loadInitialPage(int pageSize) async { final generation = _requestId; final result = await fetchPage(0, pageSize, _cancelToken); if (generation != _requestId || !mounted) return result; @@ -77,7 +77,7 @@ mixin PaginatedItemLoader on State { for (var i = 0; i < result.items.length; i++) { loadedItems[i] = result.items[i]; } - totalSize = result.totalSize; + totalSize = result.totalCount; onPageLoaded(0, result.items); return result; } @@ -160,7 +160,7 @@ mixin PaginatedItemLoader on State { /// [totalSize] even if [index] wasn't in the sparse map (evicted). void removeLoadedItemAndShift(int index) { loadedItems.remove(index); - final shifted = {}; + final shifted = {}; for (final entry in loadedItems.entries) { if (entry.key > index) { shifted[entry.key - 1] = entry.value; @@ -229,14 +229,14 @@ mixin PaginatedItemLoader on State { for (var i = 0; i < result.items.length; i++) { loadedItems[start + i] = result.items[i]; } - if (result.totalSize != totalSize) totalSize = result.totalSize; + if (result.totalCount != totalSize) totalSize = result.totalCount; }); _retryCount = 0; onPageLoaded(start, result.items); return true; } catch (e) { - if (e is PlexHttpException && e.type == PlexHttpErrorType.cancelled) return false; + if (e is MediaServerHttpException && e.type == MediaServerHttpErrorType.cancelled) return false; _retryCount++; final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4))); _retryTimer?.cancel(); diff --git a/lib/mixins/server_bound_media_mixin.dart b/lib/mixins/server_bound_media_mixin.dart index cb9f20c8..ed14f136 100644 --- a/lib/mixins/server_bound_media_mixin.dart +++ b/lib/mixins/server_bound_media_mixin.dart @@ -1,13 +1,14 @@ import 'package:flutter/widgets.dart'; -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; import '../services/plex_client.dart'; import '../utils/global_key_utils.dart'; import '../utils/provider_extensions.dart'; -/// Shared helpers for screens bound to a single [PlexMetadata] item/server. +/// Shared helpers for screens bound to a single [MediaItem]/server. mixin ServerBoundMediaMixin on State { - PlexMetadata get serverBoundMetadata; + MediaItem get serverBoundMetadata; bool get isServerBoundOffline => false; @@ -16,6 +17,16 @@ mixin ServerBoundMediaMixin on State { String toServerBoundGlobalKey(String ratingKey, {String? serverId}) => buildGlobalKey(serverId ?? serverBoundServerId ?? '', ratingKey); - PlexClient? getServerBoundClient(BuildContext context) => - context.getClientForMetadataOrNull(serverBoundMetadata, isOffline: isServerBoundOffline); + /// Returns the [PlexClient] for the bound server, or null when offline / + /// the server is Jellyfin / not registered. Use [getServerBoundMediaClient] + /// for backend-neutral flows. + PlexClient? getServerBoundPlexClient(BuildContext context) { + if (isServerBoundOffline) return null; + return context.tryGetPlexClientForServer(serverBoundMetadata.serverId); + } + + /// Returns a backend-neutral [MediaServerClient] for the bound server, or + /// null when offline / not registered. + MediaServerClient? getServerBoundMediaClient(BuildContext context) => + context.getMediaClientForItemOrNull(serverBoundMetadata, isOffline: isServerBoundOffline); } diff --git a/lib/mixins/tab_navigation_mixin.dart b/lib/mixins/tab_navigation_mixin.dart index 5dce3ff8..19e59f71 100644 --- a/lib/mixins/tab_navigation_mixin.dart +++ b/lib/mixins/tab_navigation_mixin.dart @@ -14,7 +14,11 @@ import '../widgets/focusable_tab_chip.dart'; /// /// Subclasses must provide [tabChipFocusNodes] — one [FocusNode] per tab. mixin TabNavigationMixin on State, TickerProviderStateMixin { - late final TabController tabController; + /// Mutable so [initTabNavigation] can be called more than once during a + /// single State lifetime — the libraries screen rebuilds the controller + /// when the visible tab set changes (Jellyfin shows Browse only; + /// switching back to a Plex library goes from 1 tab to 4). + late TabController tabController; /// When true, suppress auto-focus in tabs (used when navigating via tab bar). bool suppressAutoFocus = false; diff --git a/lib/mixins/watch_state_aware.dart b/lib/mixins/watch_state_aware.dart index 8fe7c11f..b08de69e 100644 --- a/lib/mixins/watch_state_aware.dart +++ b/lib/mixins/watch_state_aware.dart @@ -11,16 +11,16 @@ import 'event_aware.dart'; /// Example usage: /// ```dart /// class _MyScreenState extends State with WatchStateAware { -/// List _items = []; +/// List _items = []; /// /// @override -/// Set? get watchedRatingKeys => -/// _items.map((e) => e.ratingKey).toSet(); +/// Set? get watchedIds => +/// _items.map((e) => e.id).toSet(); /// /// @override /// void onWatchStateChanged(WatchStateEvent event) { /// // Refresh affected item -/// _refreshItem(event.ratingKey); +/// _refreshItem(event.itemId); /// } /// } /// ``` @@ -35,22 +35,22 @@ mixin WatchStateAware on State { /// Override to specify which global keys this screen cares about. /// /// Use format `serverId:ratingKey`. - /// Return null to fall back to [watchedRatingKeys] matching. + /// Return null to fall back to [watchedIds] matching. Set? get watchedGlobalKeys => null; - /// Override to specify which ratingKeys this screen cares about. + /// Override to specify which item ids this screen cares about. /// /// Return null to receive ALL events (not recommended for performance). /// Return an empty set to receive no events. /// /// The set should include: - /// - Direct items displayed (e.g., episode ratingKeys in a season view) - /// - Parent items that affect display (e.g., show ratingKey for on-deck) - Set? get watchedRatingKeys; + /// - Direct items displayed (e.g., episode ids in a season view) + /// - Parent items that affect display (e.g., show id for on-deck) + Set? get watchedIds; /// Called when a relevant watch state change occurs. /// - /// Only called if [watchedRatingKeys] is null or contains an affected key. + /// Only called if [watchedIds] is null or contains an affected key. void onWatchStateChanged(WatchStateEvent event); @override @@ -61,7 +61,7 @@ mixin WatchStateAware on State { mounted: () => mounted, serverId: () => watchStateServerId, globalKeys: () => watchedGlobalKeys, - ratingKeys: () => watchedRatingKeys, + itemIds: () => watchedIds, onEvent: onWatchStateChanged, ); } diff --git a/lib/models/jellyfin/jellyfin_user_profile.dart b/lib/models/jellyfin/jellyfin_user_profile.dart new file mode 100644 index 00000000..a7aa1f73 --- /dev/null +++ b/lib/models/jellyfin/jellyfin_user_profile.dart @@ -0,0 +1,50 @@ +import '../../media/media_server_user_profile.dart'; + +/// Jellyfin user playback preferences, sourced from `User.Configuration` +/// (returned by `/Users/{userId}` or `/Users/Me`). Jellyfin exposes only a +/// single ranked audio/subtitle language, so the multi-list accessors on +/// [MediaServerUserProfile] return null. +class JellyfinUserProfile implements MediaServerUserProfile { + @override + final bool autoSelectAudio; + + @override + final String? defaultAudioLanguage; + + @override + final String? defaultSubtitleLanguage; + + /// Server-reported subtitle mode (None / Default / Always / OnlyForced / + /// Smart). + @override + final SubtitlePlaybackMode? subtitleMode; + + const JellyfinUserProfile({ + required this.autoSelectAudio, + this.defaultAudioLanguage, + this.defaultSubtitleLanguage, + this.subtitleMode, + }); + + @override + List? get defaultAudioLanguages => null; + + @override + List? get defaultSubtitleLanguages => null; + + /// Build from `/Users/Me` (or `/Users/{userId}`) response — pulls the + /// `Configuration` block; missing values fall back to server defaults + /// (auto-select on, no language preference). + factory JellyfinUserProfile.fromUserDto(Map json) { + final config = json['Configuration'] as Map? ?? const {}; + final audio = config['AudioLanguagePreference'] as String?; + final subtitle = config['SubtitleLanguagePreference'] as String?; + final playDefault = config['PlayDefaultAudioTrack'] as bool? ?? true; + return JellyfinUserProfile( + autoSelectAudio: playDefault, + defaultAudioLanguage: (audio == null || audio.isEmpty) ? null : audio, + defaultSubtitleLanguage: (subtitle == null || subtitle.isEmpty) ? null : subtitle, + subtitleMode: SubtitlePlaybackMode.fromServerValue(config['SubtitleMode']), + ); + } +} diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart index 6706cb70..1c6220f2 100644 --- a/lib/models/livetv_channel.dart +++ b/lib/models/livetv_channel.dart @@ -26,6 +26,11 @@ Object? _readChannelNumber(Map json, String _) => Object? _readFavoriteChannelId(Map json, String _) => json['id'] as String? ?? json['key'] as String? ?? ''; +String favoriteChannelKey(String source, String id) => '$source\u0000$id'; + +String liveTvChannelScopeKey(LiveTvChannel channel) => + '${channel.serverId ?? ''}\u0000${channel.liveDvrKey ?? ''}\u0000${channel.key}'; + /// Represents a Live TV channel from the EPG @JsonSerializable(createToJson: false) class LiveTvChannel with MultiServerFields { @@ -53,6 +58,12 @@ class LiveTvChannel with MultiServerFields { @override @JsonKey(includeFromJson: false, includeToJson: false) final String? serverName; + @JsonKey(includeFromJson: false, includeToJson: false) + final String? liveDvrKey; + @JsonKey(includeFromJson: false, includeToJson: false) + final String? favoriteSource; + @JsonKey(includeFromJson: false, includeToJson: false) + final String? favoriteStoreKey; LiveTvChannel({ required this.key, @@ -68,11 +79,20 @@ class LiveTvChannel with MultiServerFields { this.drm, this.serverId, this.serverName, + this.liveDvrKey, + this.favoriteSource, + this.favoriteStoreKey, }); factory LiveTvChannel.fromJson(Map json) => _$LiveTvChannelFromJson(json); - LiveTvChannel copyWith({String? serverId, String? serverName}) { + LiveTvChannel copyWith({ + String? serverId, + String? serverName, + String? liveDvrKey, + String? favoriteSource, + String? favoriteStoreKey, + }) { return LiveTvChannel( key: key, identifier: identifier, @@ -87,6 +107,9 @@ class LiveTvChannel with MultiServerFields { drm: drm, serverId: serverId ?? this.serverId, serverName: serverName ?? this.serverName, + liveDvrKey: liveDvrKey ?? this.liveDvrKey, + favoriteSource: favoriteSource ?? this.favoriteSource, + favoriteStoreKey: favoriteStoreKey ?? this.favoriteStoreKey, ); } @@ -110,6 +133,8 @@ class FavoriteChannel { factory FavoriteChannel.fromJson(Map json) => _$FavoriteChannelFromJson(json); + String get stableKey => favoriteChannelKey(source, id); + Map toJson() => { 'source': source, 'id': id, diff --git a/lib/models/livetv_channel.g.dart b/lib/models/livetv_channel.g.dart index 69c9a811..d391a94c 100644 --- a/lib/models/livetv_channel.g.dart +++ b/lib/models/livetv_channel.g.dart @@ -6,26 +6,24 @@ part of 'livetv_channel.dart'; // JsonSerializableGenerator // ************************************************************************** -LiveTvChannel _$LiveTvChannelFromJson(Map json) => - LiveTvChannel( - key: _readChannelKey(json, 'key') as String, - identifier: _readChannelIdentifier(json, 'identifier') as String?, - callSign: json['callSign'] as String?, - title: _readChannelTitle(json, 'title') as String?, - thumb: json['thumb'] as String?, - art: json['art'] as String?, - number: _readChannelNumber(json, 'number') as String?, - hd: json['hd'] == null ? false : flexibleBool(json['hd']), - lineup: json['lineup'] as String?, - slug: json['slug'] as String?, - drm: flexibleBool(json['drm']), - ); +LiveTvChannel _$LiveTvChannelFromJson(Map json) => LiveTvChannel( + key: _readChannelKey(json, 'key') as String, + identifier: _readChannelIdentifier(json, 'identifier') as String?, + callSign: json['callSign'] as String?, + title: _readChannelTitle(json, 'title') as String?, + thumb: json['thumb'] as String?, + art: json['art'] as String?, + number: _readChannelNumber(json, 'number') as String?, + hd: json['hd'] == null ? false : flexibleBool(json['hd']), + lineup: json['lineup'] as String?, + slug: json['slug'] as String?, + drm: flexibleBool(json['drm']), +); -FavoriteChannel _$FavoriteChannelFromJson(Map json) => - FavoriteChannel( - source: json['source'] as String? ?? '', - id: _readFavoriteChannelId(json, 'id') as String, - title: json['title'] as String?, - thumb: json['thumb'] as String?, - vcn: json['vcn'] as String?, - ); +FavoriteChannel _$FavoriteChannelFromJson(Map json) => FavoriteChannel( + source: json['source'] as String? ?? '', + id: _readFavoriteChannelId(json, 'id') as String, + title: json['title'] as String?, + thumb: json['thumb'] as String?, + vcn: json['vcn'] as String?, +); diff --git a/lib/models/livetv_dvr.g.dart b/lib/models/livetv_dvr.g.dart index ff53c06f..11776174 100644 --- a/lib/models/livetv_dvr.g.dart +++ b/lib/models/livetv_dvr.g.dart @@ -20,15 +20,12 @@ LiveTvDvr _$LiveTvDvrFromJson(Map json) => LiveTvDvr( country: json['country'] as String?, language: json['language'] as String?, status: json['status'] as String?, - channelMappings: json['ChannelMapping'] == null - ? const [] - : _parseChannelMappings(json['ChannelMapping']), + channelMappings: json['ChannelMapping'] == null ? const [] : _parseChannelMappings(json['ChannelMapping']), ); -ChannelMapping _$ChannelMappingFromJson(Map json) => - ChannelMapping( - channelKey: json['channelKey'] as String?, - deviceIdentifier: json['deviceIdentifier'] as String?, - enabled: flexibleBool(json['enabled']), - lineupIdentifier: json['lineupIdentifier'] as String?, - ); +ChannelMapping _$ChannelMappingFromJson(Map json) => ChannelMapping( + channelKey: json['channelKey'] as String?, + deviceIdentifier: json['deviceIdentifier'] as String?, + enabled: flexibleBool(json['enabled']), + lineupIdentifier: json['lineupIdentifier'] as String?, +); diff --git a/lib/models/livetv_hub_result.dart b/lib/models/livetv_hub_result.dart index 642e1d62..44edfbd0 100644 --- a/lib/models/livetv_hub_result.dart +++ b/lib/models/livetv_hub_result.dart @@ -1,4 +1,4 @@ -import 'plex_metadata.dart'; +import '../media/media_item.dart'; import 'livetv_program.dart'; /// A hub from the live TV discover endpoint, with both display and EPG data. @@ -12,7 +12,7 @@ class LiveTvHubResult { /// A single item in a live TV hub, holding both display metadata and EPG timing. class LiveTvHubEntry { - final PlexMetadata metadata; + final MediaItem metadata; final LiveTvProgram program; LiveTvHubEntry({required this.metadata, required this.program}); diff --git a/lib/models/play_queue_response.dart b/lib/models/play_queue_response.dart deleted file mode 100644 index 3fdc3ad1..00000000 --- a/lib/models/play_queue_response.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import '../utils/json_utils.dart'; -import 'plex_metadata.dart'; - -part 'play_queue_response.g.dart'; - -/// Response from Plex play queue API -/// Contains queue metadata and a window of items -@JsonSerializable(createToJson: false) -class PlayQueueResponse { - final int playQueueID; - final int? playQueueSelectedItemID; - final int? playQueueSelectedItemOffset; - final String? playQueueSelectedMetadataItemID; - @JsonKey(fromJson: flexibleBool) - final bool playQueueShuffled; - final String? playQueueSourceURI; - final int? playQueueTotalCount; - final int playQueueVersion; - final int? size; // Number of items in this response window - @JsonKey(name: 'Metadata') - final List? items; - - PlayQueueResponse({ - required this.playQueueID, - this.playQueueSelectedItemID, - this.playQueueSelectedItemOffset, - this.playQueueSelectedMetadataItemID, - required this.playQueueShuffled, - this.playQueueSourceURI, - required this.playQueueTotalCount, - required this.playQueueVersion, - this.size, - this.items, - }); - - factory PlayQueueResponse.fromJson(Map json, {String? serverId, String? serverName}) { - // The API returns data wrapped in MediaContainer - final container = json['MediaContainer'] as Map? ?? json; - final response = _$PlayQueueResponseFromJson(container); - - // Tag all items with server info - if (response.items != null && (serverId != null || serverName != null)) { - final taggedItems = response.items! - .map((item) => item.copyWith(serverId: serverId, serverName: serverName)) - .toList(); - return PlayQueueResponse( - playQueueID: response.playQueueID, - playQueueSelectedItemID: response.playQueueSelectedItemID, - playQueueSelectedItemOffset: response.playQueueSelectedItemOffset, - playQueueSelectedMetadataItemID: response.playQueueSelectedMetadataItemID, - playQueueShuffled: response.playQueueShuffled, - playQueueSourceURI: response.playQueueSourceURI, - playQueueTotalCount: response.playQueueTotalCount, - playQueueVersion: response.playQueueVersion, - size: response.size, - items: taggedItems, - ); - } - - return response; - } - - /// Get the current selected item from the queue - PlexMetadata? get selectedItem { - if (items == null || playQueueSelectedItemID == null) return null; - try { - return items!.firstWhere((item) => item.playQueueItemID == playQueueSelectedItemID); - } catch (e) { - return null; - } - } - - /// Get the index of the selected item in the current window - int? get selectedItemIndex { - if (items == null || playQueueSelectedItemID == null) return null; - return items!.indexWhere((item) => item.playQueueItemID == playQueueSelectedItemID); - } -} diff --git a/lib/models/play_queue_response.g.dart b/lib/models/play_queue_response.g.dart deleted file mode 100644 index 576098f0..00000000 --- a/lib/models/play_queue_response.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'play_queue_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlayQueueResponse _$PlayQueueResponseFromJson(Map json) => - PlayQueueResponse( - playQueueID: (json['playQueueID'] as num).toInt(), - playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?) - ?.toInt(), - playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?) - ?.toInt(), - playQueueSelectedMetadataItemID: - json['playQueueSelectedMetadataItemID'] as String?, - playQueueShuffled: flexibleBool(json['playQueueShuffled']), - playQueueSourceURI: json['playQueueSourceURI'] as String?, - playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(), - playQueueVersion: (json['playQueueVersion'] as num).toInt(), - size: (json['size'] as num?)?.toInt(), - items: (json['Metadata'] as List?) - ?.map((e) => PlexMetadata.fromJson(e as Map)) - .toList(), - ); diff --git a/lib/models/plex/play_queue_response.dart b/lib/models/plex/play_queue_response.dart new file mode 100644 index 00000000..b669d749 --- /dev/null +++ b/lib/models/plex/play_queue_response.dart @@ -0,0 +1,46 @@ +import '../../media/media_item.dart'; + +/// Response from Plex play queue API. +/// Contains queue metadata and a window of items in neutral [MediaItem] form. +class PlayQueueResponse { + final int playQueueID; + final int? playQueueSelectedItemID; + final int? playQueueSelectedItemOffset; + final String? playQueueSelectedMetadataItemID; + final bool playQueueShuffled; + final String? playQueueSourceURI; + final int? playQueueTotalCount; + final int playQueueVersion; + final int? size; // Number of items in this response window + final List? items; + + PlayQueueResponse({ + required this.playQueueID, + this.playQueueSelectedItemID, + this.playQueueSelectedItemOffset, + this.playQueueSelectedMetadataItemID, + required this.playQueueShuffled, + this.playQueueSourceURI, + required this.playQueueTotalCount, + required this.playQueueVersion, + this.size, + this.items, + }); + + /// Get the current selected item from the queue. Items in a Plex + /// `PlayQueueResponse` are always [PlexMediaItem]; the cast is safe. + MediaItem? get selectedItem { + if (items == null || playQueueSelectedItemID == null) return null; + try { + return items!.firstWhere((item) => item is PlexMediaItem && item.playQueueItemId == playQueueSelectedItemID); + } catch (e) { + return null; + } + } + + /// Get the index of the selected item in the current window + int? get selectedItemIndex { + if (items == null || playQueueSelectedItemID == null) return null; + return items!.indexWhere((item) => item is PlexMediaItem && item.playQueueItemId == playQueueSelectedItemID); + } +} diff --git a/lib/models/plex_activity.dart b/lib/models/plex/plex_activity.dart similarity index 100% rename from lib/models/plex_activity.dart rename to lib/models/plex/plex_activity.dart diff --git a/lib/models/plex_activity.g.dart b/lib/models/plex/plex_activity.g.dart similarity index 100% rename from lib/models/plex_activity.g.dart rename to lib/models/plex/plex_activity.g.dart diff --git a/lib/models/plex_config.dart b/lib/models/plex/plex_config.dart similarity index 100% rename from lib/models/plex_config.dart rename to lib/models/plex/plex_config.dart diff --git a/lib/models/plex_home.dart b/lib/models/plex/plex_home.dart similarity index 100% rename from lib/models/plex_home.dart rename to lib/models/plex/plex_home.dart diff --git a/lib/models/plex_home.g.dart b/lib/models/plex/plex_home.g.dart similarity index 100% rename from lib/models/plex_home.g.dart rename to lib/models/plex/plex_home.g.dart diff --git a/lib/models/plex_home_user.dart b/lib/models/plex/plex_home_user.dart similarity index 100% rename from lib/models/plex_home_user.dart rename to lib/models/plex/plex_home_user.dart diff --git a/lib/models/plex_home_user.g.dart b/lib/models/plex/plex_home_user.g.dart similarity index 100% rename from lib/models/plex_home_user.g.dart rename to lib/models/plex/plex_home_user.g.dart diff --git a/lib/models/plex_match_result.dart b/lib/models/plex/plex_match_result.dart similarity index 96% rename from lib/models/plex_match_result.dart rename to lib/models/plex/plex_match_result.dart index 10d1bbb0..3d6c22e8 100644 --- a/lib/models/plex_match_result.dart +++ b/lib/models/plex/plex_match_result.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import '../utils/json_utils.dart'; +import '../../utils/json_utils.dart'; part 'plex_match_result.g.dart'; diff --git a/lib/models/plex/plex_match_result.g.dart b/lib/models/plex/plex_match_result.g.dart new file mode 100644 index 00000000..d840331e --- /dev/null +++ b/lib/models/plex/plex_match_result.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_match_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexMatchResult _$PlexMatchResultFromJson(Map json) => PlexMatchResult( + guid: readStringField(json, 'guid') as String? ?? '', + name: readStringField(json, 'name') as String? ?? '', + year: flexibleInt(json['year']), + score: flexibleInt(json['score']), + thumb: readStringField(json, 'thumb') as String?, + summary: readStringField(json, 'summary') as String?, + type: readStringField(json, 'type') as String?, + matched: json['matched'] == null ? false : flexibleBool(json['matched']), +); + +Map _$PlexMatchResultToJson(PlexMatchResult instance) => { + 'guid': instance.guid, + 'name': instance.name, + 'year': instance.year, + 'score': instance.score, + 'thumb': instance.thumb, + 'summary': instance.summary, + 'type': instance.type, + 'matched': instance.matched, +}; diff --git a/lib/models/plex_subtitle_search_result.dart b/lib/models/plex/plex_subtitle_search_result.dart similarity index 97% rename from lib/models/plex_subtitle_search_result.dart rename to lib/models/plex/plex_subtitle_search_result.dart index f4a9c1a2..a7d4ab85 100644 --- a/lib/models/plex_subtitle_search_result.dart +++ b/lib/models/plex/plex_subtitle_search_result.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import '../utils/json_utils.dart'; +import '../../utils/json_utils.dart'; part 'plex_subtitle_search_result.g.dart'; diff --git a/lib/models/plex_subtitle_search_result.g.dart b/lib/models/plex/plex_subtitle_search_result.g.dart similarity index 70% rename from lib/models/plex_subtitle_search_result.g.dart rename to lib/models/plex/plex_subtitle_search_result.g.dart index 20fd2b45..0b47238a 100644 --- a/lib/models/plex_subtitle_search_result.g.dart +++ b/lib/models/plex/plex_subtitle_search_result.g.dart @@ -6,9 +6,7 @@ part of 'plex_subtitle_search_result.dart'; // JsonSerializableGenerator // ************************************************************************** -PlexSubtitleSearchResult _$PlexSubtitleSearchResultFromJson( - Map json, -) => PlexSubtitleSearchResult( +PlexSubtitleSearchResult _$PlexSubtitleSearchResultFromJson(Map json) => PlexSubtitleSearchResult( id: _flexibleIntOrZero(json['id']), key: readStringField(json, 'key') as String? ?? '', codec: readStringField(json, 'codec') as String?, @@ -18,21 +16,13 @@ PlexSubtitleSearchResult _$PlexSubtitleSearchResultFromJson( providerTitle: readStringField(json, 'providerTitle') as String?, title: readStringField(json, 'title') as String?, displayTitle: readStringField(json, 'displayTitle') as String?, - hearingImpaired: json['hearingImpaired'] == null - ? false - : flexibleBool(json['hearingImpaired']), - perfectMatch: json['perfectMatch'] == null - ? false - : flexibleBool(json['perfectMatch']), - downloaded: json['downloaded'] == null - ? false - : flexibleBool(json['downloaded']), + hearingImpaired: json['hearingImpaired'] == null ? false : flexibleBool(json['hearingImpaired']), + perfectMatch: json['perfectMatch'] == null ? false : flexibleBool(json['perfectMatch']), + downloaded: json['downloaded'] == null ? false : flexibleBool(json['downloaded']), forced: json['forced'] == null ? false : flexibleBool(json['forced']), ); -Map _$PlexSubtitleSearchResultToJson( - PlexSubtitleSearchResult instance, -) => { +Map _$PlexSubtitleSearchResultToJson(PlexSubtitleSearchResult instance) => { 'id': instance.id, 'key': instance.key, 'codec': instance.codec, diff --git a/lib/models/plex_user_profile.dart b/lib/models/plex/plex_user_profile.dart similarity index 87% rename from lib/models/plex_user_profile.dart rename to lib/models/plex/plex_user_profile.dart index ea30376b..acb03cee 100644 --- a/lib/models/plex_user_profile.dart +++ b/lib/models/plex/plex_user_profile.dart @@ -1,18 +1,25 @@ import 'package:json_annotation/json_annotation.dart'; +import '../../media/media_server_user_profile.dart'; + part 'plex_user_profile.g.dart'; /// Represents a Plex user's profile preferences /// Fetched from https://clients.plex.tv/api/v2/user @JsonSerializable() -class PlexUserProfile { +class PlexUserProfile implements MediaServerUserProfile { @JsonKey(defaultValue: true) + @override final bool autoSelectAudio; @JsonKey(defaultValue: 0) final int defaultAudioAccessibility; + @override final String? defaultAudioLanguage; + @override final List? defaultAudioLanguages; + @override final String? defaultSubtitleLanguage; + @override final List? defaultSubtitleLanguages; @JsonKey(defaultValue: 0) final int autoSelectSubtitle; @@ -26,6 +33,9 @@ class PlexUserProfile { final int mediaReviewsVisibility; final List? mediaReviewsLanguages; + @override + SubtitlePlaybackMode? get subtitleMode => null; + PlexUserProfile({ required this.autoSelectAudio, required this.defaultAudioAccessibility, diff --git a/lib/models/plex/plex_user_profile.g.dart b/lib/models/plex/plex_user_profile.g.dart new file mode 100644 index 00000000..208b5e0a --- /dev/null +++ b/lib/models/plex/plex_user_profile.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_user_profile.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexUserProfile _$PlexUserProfileFromJson(Map json) => PlexUserProfile( + autoSelectAudio: json['autoSelectAudio'] as bool? ?? true, + defaultAudioAccessibility: (json['defaultAudioAccessibility'] as num?)?.toInt() ?? 0, + defaultAudioLanguage: json['defaultAudioLanguage'] as String?, + defaultAudioLanguages: (json['defaultAudioLanguages'] as List?)?.map((e) => e as String).toList(), + defaultSubtitleLanguage: json['defaultSubtitleLanguage'] as String?, + defaultSubtitleLanguages: (json['defaultSubtitleLanguages'] as List?)?.map((e) => e as String).toList(), + autoSelectSubtitle: (json['autoSelectSubtitle'] as num?)?.toInt() ?? 0, + defaultSubtitleAccessibility: (json['defaultSubtitleAccessibility'] as num?)?.toInt() ?? 0, + defaultSubtitleForced: (json['defaultSubtitleForced'] as num?)?.toInt() ?? 1, + watchedIndicator: (json['watchedIndicator'] as num?)?.toInt() ?? 1, + mediaReviewsVisibility: (json['mediaReviewsVisibility'] as num?)?.toInt() ?? 0, + mediaReviewsLanguages: (json['mediaReviewsLanguages'] as List?)?.map((e) => e as String).toList(), +); + +Map _$PlexUserProfileToJson(PlexUserProfile instance) => { + 'autoSelectAudio': instance.autoSelectAudio, + 'defaultAudioAccessibility': instance.defaultAudioAccessibility, + 'defaultAudioLanguage': instance.defaultAudioLanguage, + 'defaultAudioLanguages': instance.defaultAudioLanguages, + 'defaultSubtitleLanguage': instance.defaultSubtitleLanguage, + 'defaultSubtitleLanguages': instance.defaultSubtitleLanguages, + 'autoSelectSubtitle': instance.autoSelectSubtitle, + 'defaultSubtitleAccessibility': instance.defaultSubtitleAccessibility, + 'defaultSubtitleForced': instance.defaultSubtitleForced, + 'watchedIndicator': instance.watchedIndicator, + 'mediaReviewsVisibility': instance.mediaReviewsVisibility, + 'mediaReviewsLanguages': instance.mediaReviewsLanguages, +}; diff --git a/lib/models/plex_video_playback_data.dart b/lib/models/plex/plex_video_playback_data.dart similarity index 80% rename from lib/models/plex_video_playback_data.dart rename to lib/models/plex/plex_video_playback_data.dart index bde84b61..501b0866 100644 --- a/lib/models/plex_video_playback_data.dart +++ b/lib/models/plex/plex_video_playback_data.dart @@ -1,5 +1,5 @@ -import 'plex_media_info.dart'; -import 'plex_media_version.dart'; +import '../../media/media_source_info.dart'; +import '../../media/media_version.dart'; /// Consolidated data model containing all information needed for video playback. /// This model combines data from multiple Plex API endpoints to reduce redundant requests. @@ -8,13 +8,13 @@ class PlexVideoPlaybackData { final String? videoUrl; /// Media information including audio/subtitle tracks and chapters - final PlexMediaInfo? mediaInfo; + final MediaSourceInfo? mediaInfo; /// Available media versions/qualities for this content - final List availableVersions; + final List availableVersions; /// Markers for intro/credits skip functionality - final List markers; + final List markers; PlexVideoPlaybackData({ required this.videoUrl, diff --git a/lib/models/plex_filter.g.dart b/lib/models/plex_filter.g.dart deleted file mode 100644 index 74e5e046..00000000 --- a/lib/models/plex_filter.g.dart +++ /dev/null @@ -1,38 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'plex_filter.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlexFilter _$PlexFilterFromJson(Map json) => PlexFilter( - filter: json['filter'] as String? ?? '', - filterType: json['filterType'] as String? ?? 'string', - key: json['key'] as String? ?? '', - title: json['title'] as String? ?? '', - type: json['type'] as String? ?? 'filter', -); - -Map _$PlexFilterToJson(PlexFilter instance) => - { - 'filter': instance.filter, - 'filterType': instance.filterType, - 'key': instance.key, - 'title': instance.title, - 'type': instance.type, - }; - -PlexFilterValue _$PlexFilterValueFromJson(Map json) => - PlexFilterValue( - key: json['key'] as String? ?? '', - title: json['title'] as String? ?? '', - type: json['type'] as String?, - ); - -Map _$PlexFilterValueToJson(PlexFilterValue instance) => - { - 'key': instance.key, - 'title': instance.title, - 'type': ?instance.type, - }; diff --git a/lib/models/plex_first_character.dart b/lib/models/plex_first_character.dart deleted file mode 100644 index f5f1ad21..00000000 --- a/lib/models/plex_first_character.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'plex_first_character.g.dart'; - -@JsonSerializable() -class PlexFirstCharacter { - @JsonKey(defaultValue: '') - final String key; - @JsonKey(defaultValue: '') - final String title; - @JsonKey(defaultValue: 0) - final int size; - - PlexFirstCharacter({required this.key, required this.title, required this.size}); - - factory PlexFirstCharacter.fromJson(Map json) => _$PlexFirstCharacterFromJson(json); - - Map toJson() => _$PlexFirstCharacterToJson(this); -} diff --git a/lib/models/plex_first_character.g.dart b/lib/models/plex_first_character.g.dart deleted file mode 100644 index b618e8a1..00000000 --- a/lib/models/plex_first_character.g.dart +++ /dev/null @@ -1,21 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'plex_first_character.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlexFirstCharacter _$PlexFirstCharacterFromJson(Map json) => - PlexFirstCharacter( - key: json['key'] as String? ?? '', - title: json['title'] as String? ?? '', - size: (json['size'] as num?)?.toInt() ?? 0, - ); - -Map _$PlexFirstCharacterToJson(PlexFirstCharacter instance) => - { - 'key': instance.key, - 'title': instance.title, - 'size': instance.size, - }; diff --git a/lib/models/plex_hub.dart b/lib/models/plex_hub.dart deleted file mode 100644 index 5a295e60..00000000 --- a/lib/models/plex_hub.dart +++ /dev/null @@ -1,82 +0,0 @@ -import '../utils/json_utils.dart'; -import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText; -import 'mixins/multi_server_fields.dart'; -import 'plex_metadata.dart'; - -/// Represents a Plex hub/recommendation section (e.g., Trending Movies, Top Thrillers) -class PlexHub with MultiServerFields { - final String hubKey; - final String title; - final String type; - final String? hubIdentifier; - final int size; - final bool more; - final List items; - - @override - final String? serverId; - @override - final String? serverName; - - /// When set, this hub was split from a multi-library hub and should only - /// show items belonging to this library section. - final int? librarySectionID; - - PlexHub({ - required this.hubKey, - required this.title, - required this.type, - this.hubIdentifier, - required this.size, - required this.more, - required this.items, - this.serverId, - this.serverName, - this.librarySectionID, - }); - - factory PlexHub.fromJson(Map json, {String? serverId, String? serverName}) { - final metadataList = []; - - // Helper function to parse entries from a JSON list - void parseEntries(List? entries, {bool isDirectory = false}) { - if (entries == null) return; - for (final item in entries) { - try { - Map entry = item as Map; - if (isDirectory && !entry.containsKey('type')) { - // Directory items often represent shows but might miss the type field. - // Default to 'show' if it looks like a show, else 'folder'. - entry = Map.from(entry); - entry['type'] = (entry.containsKey('leafCount') || entry.containsKey('childCount')) ? 'show' : 'folder'; - } - var parsed = PlexMetadata.fromJsonWithImages(entry); - if (serverId != null || serverName != null) { - parsed = parsed.copyWith(serverId: serverId, serverName: serverName); - } - metadataList.add(parsed); - } catch (e) { - // Skip items that fail to parse - } - } - } - - // Hubs can contain either Metadata or Directory entries - parseEntries(json['Metadata'] as List?); - parseEntries(json['Directory'] as List?, isDirectory: true); - - return PlexHub( - hubKey: json['key'] as String? ?? '', - title: kBlurArtwork - ? obfuscateText(json['title'] as String? ?? 'Unknown') - : json['title'] as String? ?? 'Unknown', - type: json['type'] as String? ?? 'hub', - hubIdentifier: json['hubIdentifier'] as String?, - size: (json['size'] as num?)?.toInt() ?? metadataList.length, - more: flexibleBool(json['more']), - items: metadataList, - serverId: serverId, - serverName: serverName, - ); - } -} diff --git a/lib/models/plex_library.dart b/lib/models/plex_library.dart deleted file mode 100644 index 8cfa30a7..00000000 --- a/lib/models/plex_library.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import 'mixins/multi_server_fields.dart'; -import '../utils/global_key_utils.dart'; -import '../utils/json_utils.dart'; - -part 'plex_library.g.dart'; - -@JsonSerializable() -class PlexLibrary with MultiServerFields { - @JsonKey(readValue: readStringField) - final String key; - final String title; - final String type; - final String? agent; - final String? scanner; - final String? language; - final String? uuid; - final int? updatedAt; - final int? createdAt; - final int? hidden; - - // Multi-server support fields (from MultiServerFields mixin) - @override - @JsonKey(includeFromJson: false, includeToJson: false) - final String? serverId; - @override - @JsonKey(includeFromJson: false, includeToJson: false) - final String? serverName; - - /// Whether this is a shared library (individually shared items, not a real section) - @JsonKey(includeFromJson: false, includeToJson: false) - final bool isShared; - - /// Global unique identifier across all servers (serverId:key) - String get globalKey => serverId != null ? buildGlobalKey(serverId!, key) : key; - - PlexLibrary({ - required this.key, - required this.title, - required this.type, - this.agent, - this.scanner, - this.language, - this.uuid, - this.updatedAt, - this.createdAt, - this.hidden, - this.serverId, - this.serverName, - this.isShared = false, - }); - - factory PlexLibrary.fromJson(Map json) => _$PlexLibraryFromJson(json); - - Map toJson() => _$PlexLibraryToJson(this); - - /// Create a copy of this library with optional field overrides - PlexLibrary copyWith({ - String? key, - String? title, - String? type, - String? agent, - String? scanner, - String? language, - String? uuid, - int? updatedAt, - int? createdAt, - int? hidden, - String? serverId, - String? serverName, - bool? isShared, - }) { - return PlexLibrary( - key: key ?? this.key, - title: title ?? this.title, - type: type ?? this.type, - agent: agent ?? this.agent, - scanner: scanner ?? this.scanner, - language: language ?? this.language, - uuid: uuid ?? this.uuid, - updatedAt: updatedAt ?? this.updatedAt, - createdAt: createdAt ?? this.createdAt, - hidden: hidden ?? this.hidden, - serverId: serverId ?? this.serverId, - serverName: serverName ?? this.serverName, - isShared: isShared ?? this.isShared, - ); - } -} diff --git a/lib/models/plex_library.g.dart b/lib/models/plex_library.g.dart deleted file mode 100644 index c527e046..00000000 --- a/lib/models/plex_library.g.dart +++ /dev/null @@ -1,34 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'plex_library.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlexLibrary _$PlexLibraryFromJson(Map json) => PlexLibrary( - key: readStringField(json, 'key') as String, - title: json['title'] as String, - type: json['type'] as String, - agent: json['agent'] as String?, - scanner: json['scanner'] as String?, - language: json['language'] as String?, - uuid: json['uuid'] as String?, - updatedAt: (json['updatedAt'] as num?)?.toInt(), - createdAt: (json['createdAt'] as num?)?.toInt(), - hidden: (json['hidden'] as num?)?.toInt(), -); - -Map _$PlexLibraryToJson(PlexLibrary instance) => - { - 'key': instance.key, - 'title': instance.title, - 'type': instance.type, - 'agent': instance.agent, - 'scanner': instance.scanner, - 'language': instance.language, - 'uuid': instance.uuid, - 'updatedAt': instance.updatedAt, - 'createdAt': instance.createdAt, - 'hidden': instance.hidden, - }; diff --git a/lib/models/plex_match_result.g.dart b/lib/models/plex_match_result.g.dart deleted file mode 100644 index 37514316..00000000 --- a/lib/models/plex_match_result.g.dart +++ /dev/null @@ -1,31 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'plex_match_result.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlexMatchResult _$PlexMatchResultFromJson(Map json) => - PlexMatchResult( - guid: readStringField(json, 'guid') as String? ?? '', - name: readStringField(json, 'name') as String? ?? '', - year: flexibleInt(json['year']), - score: flexibleInt(json['score']), - thumb: readStringField(json, 'thumb') as String?, - summary: readStringField(json, 'summary') as String?, - type: readStringField(json, 'type') as String?, - matched: json['matched'] == null ? false : flexibleBool(json['matched']), - ); - -Map _$PlexMatchResultToJson(PlexMatchResult instance) => - { - 'guid': instance.guid, - 'name': instance.name, - 'year': instance.year, - 'score': instance.score, - 'thumb': instance.thumb, - 'summary': instance.summary, - 'type': instance.type, - 'matched': instance.matched, - }; diff --git a/lib/models/plex_media_version.dart b/lib/models/plex_media_version.dart deleted file mode 100644 index 2d9925d0..00000000 --- a/lib/models/plex_media_version.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/formatters.dart'; -import '../utils/codec_utils.dart'; -import '../utils/json_utils.dart'; - -part 'plex_media_version.g.dart'; - -int _flexibleIntOrZero(Object? v) => flexibleInt(v) ?? 0; - -Map? _firstPart(Map json) { - final parts = flexibleList(json['Part']); - return (parts != null && parts.isNotEmpty) ? parts.first as Map : null; -} - -Object? _readPartKey(Map json, String key) => _firstPart(json)?['key']?.toString() ?? ''; -Object? _readPartAccessible(Map json, String key) => _firstPart(json)?['accessible']; -Object? _readPartExists(Map json, String key) => _firstPart(json)?['exists']; - -/// Tri-state: distinguishes "Plex told us false" from "field absent". -/// `flexibleBool` collapses both to false, which would mark every version -/// unplayable on servers that omit these fields. -bool? _flexibleBoolNullable(Object? v) => switch (v) { - final bool b => b, - final int n => n == 1, - final String s => s == '1', - _ => null, -}; - -@JsonSerializable(createToJson: false) -class PlexMediaVersion { - @JsonKey(fromJson: _flexibleIntOrZero) - final int id; - @JsonKey(readValue: readStringField) - final String? videoResolution; - @JsonKey(readValue: readStringField) - final String? videoCodec; - @JsonKey(fromJson: flexibleInt) - final int? bitrate; - @JsonKey(fromJson: flexibleInt) - final int? width; - @JsonKey(fromJson: flexibleInt) - final int? height; - @JsonKey(readValue: readStringField) - final String? container; - @JsonKey(readValue: _readPartKey) - final String partKey; - @JsonKey(readValue: _readPartAccessible, fromJson: _flexibleBoolNullable) - final bool? accessible; - @JsonKey(readValue: _readPartExists, fromJson: _flexibleBoolNullable) - final bool? exists; - - PlexMediaVersion({ - required this.id, - this.videoResolution, - this.videoCodec, - this.bitrate, - this.width, - this.height, - this.container, - required this.partKey, - this.accessible, - this.exists, - }); - - /// Creates a PlexMediaVersion from Plex API Media object. - /// Values may be String or int depending on the response format (XML vs JSON). - factory PlexMediaVersion.fromJson(Map json) => _$PlexMediaVersionFromJson(json); - - /// Defaults to true when fields are absent — only an explicit false from - /// Plex (requires `checkFiles=1` on the request) marks the version unplayable. - bool get isPlayable => accessible != false && exists != false; - - /// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)" - String get displayLabel { - final parts = []; - - // Add resolution - if (videoResolution != null && videoResolution!.isNotEmpty) { - parts.add('${videoResolution}p'); - } else if (height != null) { - parts.add('${height}p'); - } - - // Add codec - if (videoCodec != null && videoCodec!.isNotEmpty) { - parts.add(CodecUtils.formatVideoCodec(videoCodec!)); - } - - // Add container - if (container != null && container!.isNotEmpty) { - parts.add(container!.toUpperCase()); - } - - // Build main label - String label = parts.isNotEmpty ? parts.join(' ') : 'Unknown'; - - // Add bitrate in parentheses - if (bitrate != null && bitrate! > 0) { - label += ' (${ByteFormatter.formatBitrate(bitrate!)})'; - } - - return label; - } - - /// Version signature for matching across episodes. - /// Format: "resolution:codec:container" (e.g., "1080:h264:mkv") - String get signature { - final res = videoResolution ?? ''; - final codec = videoCodec ?? ''; - final cont = container ?? ''; - return '$res:$codec:$cont'.toLowerCase(); - } - - String get _resolutionPart => (videoResolution ?? '').toLowerCase(); - String get _codecPart => (videoCodec ?? '').toLowerCase(); - - /// Find the best matching version index from a set of accepted signatures. - /// Uses tiered matching: exact → resolution+codec → resolution only. - /// Returns null if no accepted signature matches at all. - static int? findMatchingIndex(List versions, Set acceptedSignatures) { - if (versions.isEmpty || acceptedSignatures.isEmpty) return null; - - for (final sig in acceptedSignatures) { - final parts = sig.split(':'); - if (parts.length != 3) continue; - final targetRes = parts[0]; - final targetCodec = parts[1]; - - // Tier 1: exact match - for (int i = 0; i < versions.length; i++) { - if (versions[i].signature == sig) return i; - } - - // Tier 2: resolution + codec - for (int i = 0; i < versions.length; i++) { - if (versions[i]._resolutionPart == targetRes && versions[i]._codecPart == targetCodec) return i; - } - - // Tier 3: resolution only - for (int i = 0; i < versions.length; i++) { - if (versions[i]._resolutionPart == targetRes) return i; - } - } - - return null; - } - - @override - String toString() => displayLabel; -} diff --git a/lib/models/plex_media_version.g.dart b/lib/models/plex_media_version.g.dart deleted file mode 100644 index 097e7b46..00000000 --- a/lib/models/plex_media_version.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'plex_media_version.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlexMediaVersion _$PlexMediaVersionFromJson(Map json) => - PlexMediaVersion( - id: _flexibleIntOrZero(json['id']), - videoResolution: readStringField(json, 'videoResolution') as String?, - videoCodec: readStringField(json, 'videoCodec') as String?, - bitrate: flexibleInt(json['bitrate']), - width: flexibleInt(json['width']), - height: flexibleInt(json['height']), - container: readStringField(json, 'container') as String?, - partKey: _readPartKey(json, 'partKey') as String, - accessible: _flexibleBoolNullable( - _readPartAccessible(json, 'accessible'), - ), - exists: _flexibleBoolNullable(_readPartExists(json, 'exists')), - ); diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart deleted file mode 100644 index b4b6c142..00000000 --- a/lib/models/plex_metadata.dart +++ /dev/null @@ -1,580 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - -import '../services/settings_service.dart' show EpisodePosterMode; -import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText; -import 'mixins/multi_server_fields.dart'; -import 'plex_media_version.dart'; -import 'plex_role.dart'; -import '../utils/global_key_utils.dart'; -import '../utils/json_utils.dart'; - -part 'plex_metadata.g.dart'; - -Object? _readRatingKey(Map json, String key) => (json['ratingKey'] ?? json['key'] ?? '').toString(); - -List? _tagsFromJson(List? json) => json?.cast>().map((e) => e['tag'] as String).toList(); - -/// Media type enum for type-safe media type handling -enum PlexMediaType { - movie, - show, - season, - episode, - artist, - album, - track, - collection, - playlist, - clip, - photo, - unknown; - - /// Whether this type represents video content - bool get isVideo => this == movie || this == episode || this == clip; - - /// Whether this type is part of a show hierarchy - bool get isShowRelated => this == show || this == season || this == episode; - - /// Whether this type represents music content - bool get isMusic => this == artist || this == album || this == track; - - /// Whether this type can be played directly - bool get isPlayable => isVideo || this == track; - - /// Plex API type number for metadata editing endpoints - int get typeNumber => switch (this) { - PlexMediaType.movie => 1, - PlexMediaType.show => 2, - PlexMediaType.season => 3, - PlexMediaType.episode => 4, - PlexMediaType.artist => 8, - PlexMediaType.album => 9, - PlexMediaType.track => 10, - _ => 0, - }; -} - -/// Shared suffix of both unmatched-agent URL schemes: legacy -/// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`. -const _unmatchedAgentMarker = 'agents.none://'; - -@JsonSerializable() -class PlexMetadata with MultiServerFields { - @JsonKey(readValue: _readRatingKey) - final String ratingKey; - final String? key; - final String? guid; - final String? studio; - final String? type; - final String? title; - final String? titleSort; - final String? contentRating; - final String? summary; - final double? rating; - final double? audienceRating; - final double? userRating; - final int? year; - final String? originallyAvailableAt; // Full release date (YYYY-MM-DD) - final String? thumb; - final String? art; - final int? duration; - final int? addedAt; - final int? updatedAt; - final int? lastViewedAt; // Timestamp when item was last viewed - final String? grandparentTitle; // Show title for episodes - final String? grandparentThumb; // Show poster for episodes - final String? grandparentArt; // Show art for episodes - final String? grandparentRatingKey; // Show rating key for episodes - final String? parentTitle; // Season title for episodes - final String? parentThumb; // Season poster for episodes - final String? parentRatingKey; // Season rating key for episodes - final int? parentIndex; // Season number - final int? index; // Episode number - final String? grandparentTheme; // Show theme music - final int? viewOffset; // Resume position in ms - final int? viewCount; - final int? leafCount; // Total number of episodes in a series/season - final int? viewedLeafCount; // Number of watched episodes in a series/season - @JsonKey(fromJson: flexibleInt) - final int? childCount; // Number of items in a collection or playlist - @JsonKey(name: 'Role') - final List? role; // Cast members - @JsonKey(name: 'Media', includeToJson: false) - final List? mediaVersions; // Available media versions/editions - @JsonKey(name: 'Genre', fromJson: _tagsFromJson, includeToJson: false) - final List? genre; - @JsonKey(name: 'Director', fromJson: _tagsFromJson, includeToJson: false) - final List? director; - @JsonKey(name: 'Writer', fromJson: _tagsFromJson, includeToJson: false) - final List? writer; - @JsonKey(name: 'Producer', fromJson: _tagsFromJson, includeToJson: false) - final List? producer; - @JsonKey(name: 'Country', fromJson: _tagsFromJson, includeToJson: false) - final List? country; - @JsonKey(name: 'Collection', fromJson: _tagsFromJson, includeToJson: false) - final List? collection; - @JsonKey(name: 'Label', fromJson: _tagsFromJson, includeToJson: false) - final List? label; - @JsonKey(name: 'Style', fromJson: _tagsFromJson, includeToJson: false) - final List? style; - @JsonKey(name: 'Mood', fromJson: _tagsFromJson, includeToJson: false) - final List? mood; - final String? audioLanguage; // Per-media preferred audio language - final String? subtitleLanguage; // Per-media preferred subtitle language - @JsonKey(fromJson: flexibleInt) - final int? subtitleMode; // Per-media subtitle mode (0=manual, 1=foreign audio, 2=always, -1=account default) - final int? playlistItemID; // Playlist item ID (for dumb playlists only) - final int? playQueueItemID; // Play queue item ID (unique even for duplicates) - final int? librarySectionID; // Library section ID this item belongs to - final String? librarySectionTitle; // Library section title this item belongs to - final String? ratingImage; // Rating source URI (e.g. rottentomatoes://image.rating.ripe) - final String? audienceRatingImage; // Audience rating source URI - final String? tagline; - final String? originalTitle; - final String? editionTitle; // Edition name for movies (e.g., "Director's Cut", "Extended") - final String? subtype; // Clip subtype: "trailer", "behindTheScenes", "deleted", etc. - final int? extraType; // Numeric extra type identifier - final String? primaryExtraKey; // Points to main trailer (e.g., "/library/metadata/52601") - - // Multi-server support fields (from MultiServerFields mixin) - @override - @JsonKey(includeFromJson: false, includeToJson: false) - final String? serverId; - @override - @JsonKey(includeFromJson: false, includeToJson: false) - final String? serverName; - - // Clear logo URL (extracted from Image array, but serialized for offline storage) - final String? clearLogo; - - // Square background art URL (extracted from Image array, used for near-square hero layouts) - final String? backgroundSquare; - - /// Global unique identifier across all servers (serverId:ratingKey) - String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey; - - /// Global unique identifier of this item's library section, matching - /// [PlexLibrary.globalKey]. Null when either [serverId] or [librarySectionID] - /// is missing. - String? get librarySectionGlobalKey => - serverId != null && librarySectionID != null ? buildGlobalKey(serverId!, librarySectionID!.toString()) : null; - - /// Parent rating keys for hierarchical invalidation. - /// For an episode: [seasonRatingKey, showRatingKey] - /// For a season: [showRatingKey] - /// For a movie: [] - List get parentChain => [?parentRatingKey, ?grandparentRatingKey]; - - /// Whether this item represents a library section (shared whole-library, not a media item). - /// These have keys like `/library/sections/5/all` instead of `/library/metadata/12345`. - bool get isLibrarySection => key != null && key!.startsWith('/library/sections/'); - - /// Whether this item has no metadata agent match. Unmatched items get a - /// synthetic `*.agents.none://` guid from the server. - bool get isUnmatched => guid == null || guid!.isEmpty || guid!.contains(_unmatchedAgentMarker); - - /// Extract the library section ID from a library-section item's key. - /// Returns null if this is not a library section item. - String? get librarySectionKey { - if (!isLibrarySection) return null; - final match = RegExp(r'/library/sections/(\d+)').firstMatch(key!); - return match?.group(1); - } - - /// Parsed media type enum for type-safe comparisons - PlexMediaType get mediaType { - if (type == null) return PlexMediaType.unknown; - return switch (type!.toLowerCase()) { - 'movie' => PlexMediaType.movie, - 'show' => PlexMediaType.show, - 'season' => PlexMediaType.season, - 'episode' => PlexMediaType.episode, - 'artist' => PlexMediaType.artist, - 'album' => PlexMediaType.album, - 'track' => PlexMediaType.track, - 'collection' => PlexMediaType.collection, - 'playlist' => PlexMediaType.playlist, - 'clip' => PlexMediaType.clip, - 'photo' => PlexMediaType.photo, - _ => PlexMediaType.unknown, - }; - } - - PlexMetadata({ - required this.ratingKey, - this.key, - this.guid, - this.studio, - this.type, - this.title, - this.titleSort, - this.contentRating, - this.summary, - this.rating, - this.audienceRating, - this.userRating, - this.year, - this.originallyAvailableAt, - this.thumb, - this.art, - this.duration, - this.addedAt, - this.updatedAt, - this.lastViewedAt, - this.grandparentTitle, - this.grandparentThumb, - this.grandparentArt, - this.grandparentRatingKey, - this.parentTitle, - this.parentThumb, - this.parentRatingKey, - this.parentIndex, - this.index, - this.grandparentTheme, - this.viewOffset, - this.viewCount, - this.leafCount, - this.viewedLeafCount, - this.childCount, - this.role, - this.mediaVersions, - this.genre, - this.director, - this.writer, - this.producer, - this.country, - this.collection, - this.label, - this.style, - this.mood, - this.audioLanguage, - this.subtitleLanguage, - this.subtitleMode, - this.playlistItemID, - this.playQueueItemID, - this.librarySectionID, - this.librarySectionTitle, - this.ratingImage, - this.audienceRatingImage, - this.tagline, - this.originalTitle, - this.editionTitle, - this.subtype, - this.extraType, - this.primaryExtraKey, - this.serverId, - this.serverName, - this.clearLogo, - this.backgroundSquare, - }); - - /// Create a copy of this metadata with optional field overrides - PlexMetadata copyWith({ - String? ratingKey, - String? key, - String? guid, - String? studio, - String? type, - String? title, - String? titleSort, - String? contentRating, - String? summary, - double? rating, - double? audienceRating, - double? userRating, - int? year, - String? originallyAvailableAt, - String? thumb, - String? art, - int? duration, - int? addedAt, - int? updatedAt, - int? lastViewedAt, - String? grandparentTitle, - String? grandparentThumb, - String? grandparentArt, - String? grandparentRatingKey, - String? parentTitle, - String? parentThumb, - String? parentRatingKey, - int? parentIndex, - int? index, - String? grandparentTheme, - int? viewOffset, - int? viewCount, - int? leafCount, - int? viewedLeafCount, - int? childCount, - List? role, - List? mediaVersions, - List? genre, - List? director, - List? writer, - List? producer, - List? country, - List? collection, - List? label, - List? style, - List? mood, - String? audioLanguage, - String? subtitleLanguage, - int? subtitleMode, - int? playlistItemID, - int? playQueueItemID, - int? librarySectionID, - String? librarySectionTitle, - String? ratingImage, - String? audienceRatingImage, - String? tagline, - String? originalTitle, - String? editionTitle, - String? subtype, - int? extraType, - String? primaryExtraKey, - String? serverId, - String? serverName, - String? clearLogo, - String? backgroundSquare, - }) { - return PlexMetadata( - ratingKey: ratingKey ?? this.ratingKey, - key: key ?? this.key, - guid: guid ?? this.guid, - studio: studio ?? this.studio, - type: type ?? this.type, - title: title ?? this.title, - titleSort: titleSort ?? this.titleSort, - contentRating: contentRating ?? this.contentRating, - summary: summary ?? this.summary, - rating: rating ?? this.rating, - audienceRating: audienceRating ?? this.audienceRating, - userRating: userRating ?? this.userRating, - year: year ?? this.year, - originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, - thumb: thumb ?? this.thumb, - art: art ?? this.art, - duration: duration ?? this.duration, - addedAt: addedAt ?? this.addedAt, - updatedAt: updatedAt ?? this.updatedAt, - lastViewedAt: lastViewedAt ?? this.lastViewedAt, - grandparentTitle: grandparentTitle ?? this.grandparentTitle, - grandparentThumb: grandparentThumb ?? this.grandparentThumb, - grandparentArt: grandparentArt ?? this.grandparentArt, - grandparentRatingKey: grandparentRatingKey ?? this.grandparentRatingKey, - parentTitle: parentTitle ?? this.parentTitle, - parentThumb: parentThumb ?? this.parentThumb, - parentRatingKey: parentRatingKey ?? this.parentRatingKey, - parentIndex: parentIndex ?? this.parentIndex, - index: index ?? this.index, - grandparentTheme: grandparentTheme ?? this.grandparentTheme, - viewOffset: viewOffset ?? this.viewOffset, - viewCount: viewCount ?? this.viewCount, - leafCount: leafCount ?? this.leafCount, - viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, - childCount: childCount ?? this.childCount, - role: role ?? this.role, - mediaVersions: mediaVersions ?? this.mediaVersions, - genre: genre ?? this.genre, - director: director ?? this.director, - writer: writer ?? this.writer, - producer: producer ?? this.producer, - country: country ?? this.country, - collection: collection ?? this.collection, - label: label ?? this.label, - style: style ?? this.style, - mood: mood ?? this.mood, - audioLanguage: audioLanguage ?? this.audioLanguage, - subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, - subtitleMode: subtitleMode ?? this.subtitleMode, - playlistItemID: playlistItemID ?? this.playlistItemID, - playQueueItemID: playQueueItemID ?? this.playQueueItemID, - librarySectionID: librarySectionID ?? this.librarySectionID, - librarySectionTitle: librarySectionTitle ?? this.librarySectionTitle, - ratingImage: ratingImage ?? this.ratingImage, - audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage, - tagline: tagline ?? this.tagline, - originalTitle: originalTitle ?? this.originalTitle, - editionTitle: editionTitle ?? this.editionTitle, - subtype: subtype ?? this.subtype, - extraType: extraType ?? this.extraType, - primaryExtraKey: primaryExtraKey ?? this.primaryExtraKey, - serverId: serverId ?? this.serverId, - serverName: serverName ?? this.serverName, - clearLogo: clearLogo ?? this.clearLogo, - backgroundSquare: backgroundSquare ?? this.backgroundSquare, - ); - } - - /// Extract an image URL by type from the Image array in raw JSON - static String? _extractImageFromJson(Map json, String imageType) { - if (!json.containsKey('Image')) return null; - - final images = json['Image'] as List?; - if (images == null) return null; - - for (final image in images) { - if (image is Map && image['type'] == imageType) { - return image['url'] as String?; - } - } - return null; - } - - /// Create from JSON with Image array fields extracted - factory PlexMetadata.fromJsonWithImages(Map json) { - final clearLogoUrl = _extractImageFromJson(json, 'clearLogo'); - final backgroundSquareUrl = _extractImageFromJson(json, 'backgroundSquare'); - if (clearLogoUrl == null && backgroundSquareUrl == null) { - return PlexMetadata.fromJson(json); - } - final enriched = Map.from(json); - if (clearLogoUrl != null) enriched['clearLogo'] = clearLogoUrl; - if (backgroundSquareUrl != null) enriched['backgroundSquare'] = backgroundSquareUrl; - return PlexMetadata.fromJson(enriched); - } - - /// Returns the best hero art path based on the container's aspect ratio. - /// Uses backgroundSquare when the container is closer to 1:1 than 16:9. - String? heroArt({required double containerAspectRatio}) { - // Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39 - if (containerAspectRatio < 1.39 && backgroundSquare != null) { - return backgroundSquare; - } - return art; - } - - // Helper to get the display title (show name for episodes/seasons, title otherwise) - String get displayTitle { - final itemType = type?.toLowerCase(); - - // For episodes and seasons, prefer grandparent title (show name) - if ((itemType == 'episode' || itemType == 'season') && grandparentTitle != null) { - return grandparentTitle!; - } - // For seasons without grandparent, check if this IS the show (parentTitle might have show name) - if (itemType == 'season' && parentTitle != null) { - return parentTitle!; - } - return title ?? ''; - } - - // Helper to get the subtitle (episode/season title) - String? get displaySubtitle { - final itemType = type?.toLowerCase(); - - if (itemType == 'episode' || itemType == 'season') { - // If we showed grandparent/parent as title, show this item's title as subtitle - if (grandparentTitle != null || (itemType == 'season' && parentTitle != null)) { - return title; - } - } - return null; - } - - /// Returns the appropriate image path based on episode poster mode. - /// For episodes: - /// - seriesPoster: grandparentThumb (series poster) - /// - seasonPoster: parentThumb (season poster) - /// - episodeThumbnail: thumb (16:9 episode still) - /// For seasons: returns grandparentThumb (series poster), or art/thumb in mixed hub context - /// For movies/shows/seasons in mixed hub context: returns art (16:9 background) - /// For other types: returns thumb - String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { - final itemType = type?.toLowerCase(); - - if (itemType == 'episode') { - switch (mode) { - case EpisodePosterMode.episodeThumbnail: - return thumb; // 16:9 episode thumbnail - case EpisodePosterMode.seasonPoster: - return parentThumb ?? grandparentThumb ?? thumb; - case EpisodePosterMode.seriesPoster: - return grandparentThumb ?? thumb; - } - } else if (itemType == 'season') { - // In mixed hub with episode thumbnail mode, use art/thumb (16:9) - if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail) { - return art ?? thumb; - } - // Otherwise use series poster (2:3) - if (grandparentThumb != null) { - return grandparentThumb!; - } - } - - // For movies/shows in mixed hub context with episode thumbnail mode, use art (16:9) - if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && (itemType == 'movie' || itemType == 'show')) { - return art ?? thumb; - } - - return thumb; - } - - /// Returns true if this item should use 16:9 aspect ratio. - /// Episodes use 16:9 when in episodeThumbnail mode. - /// Clips (trailers, extras) always use 16:9. - /// Movies, shows, and seasons use 16:9 in mixed hub context with episodeThumbnail mode. - bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) { - final itemType = type?.toLowerCase(); - // Clips (trailers, extras) are always 16:9 - if (itemType == 'clip') return true; - if (itemType == 'episode' && mode == EpisodePosterMode.episodeThumbnail) { - return true; - } - // Movies, shows, and seasons use 16:9 in mixed hubs with episode thumbnail mode - if (mixedHubContext && - mode == EpisodePosterMode.episodeThumbnail && - (itemType == 'movie' || itemType == 'show' || itemType == 'season')) { - return true; - } - return false; - } - - /// Returns true if this item has started but not finished playback - /// Only applicable for individual items (movies, episodes) - bool get hasActiveProgress { - if (duration == null || viewOffset == null) return false; - return viewOffset! > 0 && viewOffset! < duration!; - } - - /// Returns true if this container (show/season) has some but not all episodes watched - bool get isPartiallyWatched => - viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!; - - // Helper to determine if content is watched - bool get isWatched { - // For series/seasons, check if all episodes are watched - if (leafCount != null && viewedLeafCount != null) { - return viewedLeafCount! >= leafCount!; - } - - // For individual items (movies, episodes), check viewCount - return viewCount != null && viewCount! > 0; - } - - factory PlexMetadata.fromJson(Map json) { - try { - return _$PlexMetadataFromJson(kBlurArtwork ? _obfuscateJson(json) : json); - } on TypeError catch (e, st) { - Sentry.captureException( - e, - stackTrace: st, - withScope: (scope) { - scope.setContexts('json', json); - }, - ); - rethrow; - } - } - - static Map _obfuscateJson(Map json) { - final copy = Map.from(json); - for (final key in const ['title', 'summary', 'tagline', 'grandparentTitle', 'parentTitle', 'studio']) { - if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String); - } - return copy; - } - - Map toJson() => _$PlexMetadataToJson(this); -} diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart deleted file mode 100644 index 43d0be10..00000000 --- a/lib/models/plex_metadata.g.dart +++ /dev/null @@ -1,134 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'plex_metadata.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( - ratingKey: _readRatingKey(json, 'ratingKey') as String, - key: json['key'] as String?, - guid: json['guid'] as String?, - studio: json['studio'] as String?, - type: json['type'] as String?, - title: json['title'] as String?, - titleSort: json['titleSort'] as String?, - contentRating: json['contentRating'] as String?, - summary: json['summary'] as String?, - rating: (json['rating'] as num?)?.toDouble(), - audienceRating: (json['audienceRating'] as num?)?.toDouble(), - userRating: (json['userRating'] as num?)?.toDouble(), - year: (json['year'] as num?)?.toInt(), - originallyAvailableAt: json['originallyAvailableAt'] as String?, - thumb: json['thumb'] as String?, - art: json['art'] as String?, - duration: (json['duration'] as num?)?.toInt(), - addedAt: (json['addedAt'] as num?)?.toInt(), - updatedAt: (json['updatedAt'] as num?)?.toInt(), - lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(), - grandparentTitle: json['grandparentTitle'] as String?, - grandparentThumb: json['grandparentThumb'] as String?, - grandparentArt: json['grandparentArt'] as String?, - grandparentRatingKey: json['grandparentRatingKey'] as String?, - parentTitle: json['parentTitle'] as String?, - parentThumb: json['parentThumb'] as String?, - parentRatingKey: json['parentRatingKey'] as String?, - parentIndex: (json['parentIndex'] as num?)?.toInt(), - index: (json['index'] as num?)?.toInt(), - grandparentTheme: json['grandparentTheme'] as String?, - viewOffset: (json['viewOffset'] as num?)?.toInt(), - viewCount: (json['viewCount'] as num?)?.toInt(), - leafCount: (json['leafCount'] as num?)?.toInt(), - viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(), - childCount: flexibleInt(json['childCount']), - role: (json['Role'] as List?) - ?.map((e) => PlexRole.fromJson(e as Map)) - .toList(), - mediaVersions: (json['Media'] as List?) - ?.map((e) => PlexMediaVersion.fromJson(e as Map)) - .toList(), - genre: _tagsFromJson(json['Genre'] as List?), - director: _tagsFromJson(json['Director'] as List?), - writer: _tagsFromJson(json['Writer'] as List?), - producer: _tagsFromJson(json['Producer'] as List?), - country: _tagsFromJson(json['Country'] as List?), - collection: _tagsFromJson(json['Collection'] as List?), - label: _tagsFromJson(json['Label'] as List?), - style: _tagsFromJson(json['Style'] as List?), - mood: _tagsFromJson(json['Mood'] as List?), - audioLanguage: json['audioLanguage'] as String?, - subtitleLanguage: json['subtitleLanguage'] as String?, - subtitleMode: flexibleInt(json['subtitleMode']), - playlistItemID: (json['playlistItemID'] as num?)?.toInt(), - playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(), - librarySectionID: (json['librarySectionID'] as num?)?.toInt(), - librarySectionTitle: json['librarySectionTitle'] as String?, - ratingImage: json['ratingImage'] as String?, - audienceRatingImage: json['audienceRatingImage'] as String?, - tagline: json['tagline'] as String?, - originalTitle: json['originalTitle'] as String?, - editionTitle: json['editionTitle'] as String?, - subtype: json['subtype'] as String?, - extraType: (json['extraType'] as num?)?.toInt(), - primaryExtraKey: json['primaryExtraKey'] as String?, - clearLogo: json['clearLogo'] as String?, - backgroundSquare: json['backgroundSquare'] as String?, -); - -Map _$PlexMetadataToJson(PlexMetadata instance) => - { - 'ratingKey': instance.ratingKey, - 'key': instance.key, - 'guid': instance.guid, - 'studio': instance.studio, - 'type': instance.type, - 'title': instance.title, - 'titleSort': instance.titleSort, - 'contentRating': instance.contentRating, - 'summary': instance.summary, - 'rating': instance.rating, - 'audienceRating': instance.audienceRating, - 'userRating': instance.userRating, - 'year': instance.year, - 'originallyAvailableAt': instance.originallyAvailableAt, - 'thumb': instance.thumb, - 'art': instance.art, - 'duration': instance.duration, - 'addedAt': instance.addedAt, - 'updatedAt': instance.updatedAt, - 'lastViewedAt': instance.lastViewedAt, - 'grandparentTitle': instance.grandparentTitle, - 'grandparentThumb': instance.grandparentThumb, - 'grandparentArt': instance.grandparentArt, - 'grandparentRatingKey': instance.grandparentRatingKey, - 'parentTitle': instance.parentTitle, - 'parentThumb': instance.parentThumb, - 'parentRatingKey': instance.parentRatingKey, - 'parentIndex': instance.parentIndex, - 'index': instance.index, - 'grandparentTheme': instance.grandparentTheme, - 'viewOffset': instance.viewOffset, - 'viewCount': instance.viewCount, - 'leafCount': instance.leafCount, - 'viewedLeafCount': instance.viewedLeafCount, - 'childCount': instance.childCount, - 'Role': instance.role, - 'audioLanguage': instance.audioLanguage, - 'subtitleLanguage': instance.subtitleLanguage, - 'subtitleMode': instance.subtitleMode, - 'playlistItemID': instance.playlistItemID, - 'playQueueItemID': instance.playQueueItemID, - 'librarySectionID': instance.librarySectionID, - 'librarySectionTitle': instance.librarySectionTitle, - 'ratingImage': instance.ratingImage, - 'audienceRatingImage': instance.audienceRatingImage, - 'tagline': instance.tagline, - 'originalTitle': instance.originalTitle, - 'editionTitle': instance.editionTitle, - 'subtype': instance.subtype, - 'extraType': instance.extraType, - 'primaryExtraKey': instance.primaryExtraKey, - 'clearLogo': instance.clearLogo, - 'backgroundSquare': instance.backgroundSquare, - }; diff --git a/lib/models/plex_playlist.dart b/lib/models/plex_playlist.dart deleted file mode 100644 index 28b32760..00000000 --- a/lib/models/plex_playlist.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../widgets/plex_optimized_image.dart' show kBlurArtwork, obfuscateText; -import 'mixins/multi_server_fields.dart'; -import '../utils/global_key_utils.dart'; -import '../utils/json_utils.dart'; - -part 'plex_playlist.g.dart'; - -@JsonSerializable() -class PlexPlaylist with MultiServerFields { - @JsonKey(readValue: readStringField) - final String ratingKey; - final String key; - final String type; // "playlist" - final String title; - final String? summary; - final bool smart; - final String playlistType; // video, audio, photo - final int? duration; - final int? leafCount; // Number of items in playlist - final String? composite; // Composite thumbnail image - final int? addedAt; - final int? updatedAt; - final int? lastViewedAt; - final int? viewCount; - final String? content; // For smart playlists - generator URI - final String? guid; - final String? thumb; - - // Multi-server support fields (from MultiServerFields mixin) - @override - @JsonKey(includeFromJson: false, includeToJson: false) - final String? serverId; - @override - @JsonKey(includeFromJson: false, includeToJson: false) - final String? serverName; - - PlexPlaylist({ - required this.ratingKey, - required this.key, - required this.type, - required this.title, - this.summary, - required this.smart, - required this.playlistType, - this.duration, - this.leafCount, - this.composite, - this.addedAt, - this.updatedAt, - this.lastViewedAt, - this.viewCount, - this.content, - this.guid, - this.thumb, - this.serverId, - this.serverName, - }); - - /// Helper to get display image (composite or thumb) - String? get displayImage => composite ?? thumb; - - /// Helper to get display title (consistent with PlexMetadata) - String get displayTitle => title; - - /// Helper to determine if playlist is editable - bool get isEditable => !smart; - - /// Get globally unique key across all servers - String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey; - - // Properties for MediaCard compatibility with PlexMetadata interface - - /// Playlists are not "watched" in the traditional sense - bool get isWatched => false; - - /// Playlists don't have resume positions - int? get viewOffset => null; - - /// Playlists don't have parent/episode indices - int? get parentIndex => null; - int? get index => null; - - /// Playlists don't have parent titles or subtitles - String? get parentTitle => null; - String? get displaySubtitle => null; - - /// Playlists don't have year, rating, or content metadata - int? get year => null; - String? get contentRating => null; - double? get rating => null; - String? get studio => null; - - /// Use leafCount as the equivalent of childCount - int? get childCount => leafCount; - - /// Playlists don't track viewed leaf count - int? get viewedLeafCount => null; - - factory PlexPlaylist.fromJson(Map json) { - if (kBlurArtwork) { - final copy = Map.from(json); - for (final key in const ['title', 'summary']) { - if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String); - } - return _$PlexPlaylistFromJson(copy); - } - return _$PlexPlaylistFromJson(json); - } - - Map toJson() => _$PlexPlaylistToJson(this); - - /// Create a copy with optional field updates - PlexPlaylist copyWith({ - String? ratingKey, - String? key, - String? type, - String? title, - String? summary, - bool? smart, - String? playlistType, - int? duration, - int? leafCount, - String? composite, - int? addedAt, - int? updatedAt, - int? lastViewedAt, - int? viewCount, - String? content, - String? guid, - String? thumb, - String? serverId, - String? serverName, - }) { - return PlexPlaylist( - ratingKey: ratingKey ?? this.ratingKey, - key: key ?? this.key, - type: type ?? this.type, - title: title ?? this.title, - summary: summary ?? this.summary, - smart: smart ?? this.smart, - playlistType: playlistType ?? this.playlistType, - duration: duration ?? this.duration, - leafCount: leafCount ?? this.leafCount, - composite: composite ?? this.composite, - addedAt: addedAt ?? this.addedAt, - updatedAt: updatedAt ?? this.updatedAt, - lastViewedAt: lastViewedAt ?? this.lastViewedAt, - viewCount: viewCount ?? this.viewCount, - content: content ?? this.content, - guid: guid ?? this.guid, - thumb: thumb ?? this.thumb, - serverId: serverId ?? this.serverId, - serverName: serverName ?? this.serverName, - ); - } -} diff --git a/lib/models/plex_playlist.g.dart b/lib/models/plex_playlist.g.dart deleted file mode 100644 index 63d5512b..00000000 --- a/lib/models/plex_playlist.g.dart +++ /dev/null @@ -1,48 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'plex_playlist.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlexPlaylist _$PlexPlaylistFromJson(Map json) => PlexPlaylist( - ratingKey: readStringField(json, 'ratingKey') as String, - key: json['key'] as String, - type: json['type'] as String, - title: json['title'] as String, - summary: json['summary'] as String?, - smart: json['smart'] as bool, - playlistType: json['playlistType'] as String, - duration: (json['duration'] as num?)?.toInt(), - leafCount: (json['leafCount'] as num?)?.toInt(), - composite: json['composite'] as String?, - addedAt: (json['addedAt'] as num?)?.toInt(), - updatedAt: (json['updatedAt'] as num?)?.toInt(), - lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(), - viewCount: (json['viewCount'] as num?)?.toInt(), - content: json['content'] as String?, - guid: json['guid'] as String?, - thumb: json['thumb'] as String?, -); - -Map _$PlexPlaylistToJson(PlexPlaylist instance) => - { - 'ratingKey': instance.ratingKey, - 'key': instance.key, - 'type': instance.type, - 'title': instance.title, - 'summary': instance.summary, - 'smart': instance.smart, - 'playlistType': instance.playlistType, - 'duration': instance.duration, - 'leafCount': instance.leafCount, - 'composite': instance.composite, - 'addedAt': instance.addedAt, - 'updatedAt': instance.updatedAt, - 'lastViewedAt': instance.lastViewedAt, - 'viewCount': instance.viewCount, - 'content': instance.content, - 'guid': instance.guid, - 'thumb': instance.thumb, - }; diff --git a/lib/models/plex_role.dart b/lib/models/plex_role.dart deleted file mode 100644 index 14a76d6e..00000000 --- a/lib/models/plex_role.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; - -part 'plex_role.g.dart'; - -@JsonSerializable() -class PlexRole { - @JsonKey(fromJson: flexibleInt) - final int? id; - final String? filter; - final String tag; - final String? tagKey; - final String? role; - final String? thumb; - @JsonKey(fromJson: flexibleInt) - final int? count; - - PlexRole({this.id, this.filter, required this.tag, this.tagKey, this.role, this.thumb, this.count}); - - factory PlexRole.fromJson(Map json) => _$PlexRoleFromJson(json); - - Map toJson() => _$PlexRoleToJson(this); -} diff --git a/lib/models/plex_role.g.dart b/lib/models/plex_role.g.dart deleted file mode 100644 index 4e2f5cfd..00000000 --- a/lib/models/plex_role.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'plex_role.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlexRole _$PlexRoleFromJson(Map json) => PlexRole( - id: flexibleInt(json['id']), - filter: json['filter'] as String?, - tag: json['tag'] as String, - tagKey: json['tagKey'] as String?, - role: json['role'] as String?, - thumb: json['thumb'] as String?, - count: flexibleInt(json['count']), -); - -Map _$PlexRoleToJson(PlexRole instance) => { - 'id': instance.id, - 'filter': instance.filter, - 'tag': instance.tag, - 'tagKey': instance.tagKey, - 'role': instance.role, - 'thumb': instance.thumb, - 'count': instance.count, -}; diff --git a/lib/models/plex_user_profile.g.dart b/lib/models/plex_user_profile.g.dart deleted file mode 100644 index bdba7d4a..00000000 --- a/lib/models/plex_user_profile.g.dart +++ /dev/null @@ -1,49 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'plex_user_profile.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PlexUserProfile _$PlexUserProfileFromJson( - Map json, -) => PlexUserProfile( - autoSelectAudio: json['autoSelectAudio'] as bool? ?? true, - defaultAudioAccessibility: - (json['defaultAudioAccessibility'] as num?)?.toInt() ?? 0, - defaultAudioLanguage: json['defaultAudioLanguage'] as String?, - defaultAudioLanguages: (json['defaultAudioLanguages'] as List?) - ?.map((e) => e as String) - .toList(), - defaultSubtitleLanguage: json['defaultSubtitleLanguage'] as String?, - defaultSubtitleLanguages: (json['defaultSubtitleLanguages'] as List?) - ?.map((e) => e as String) - .toList(), - autoSelectSubtitle: (json['autoSelectSubtitle'] as num?)?.toInt() ?? 0, - defaultSubtitleAccessibility: - (json['defaultSubtitleAccessibility'] as num?)?.toInt() ?? 0, - defaultSubtitleForced: (json['defaultSubtitleForced'] as num?)?.toInt() ?? 1, - watchedIndicator: (json['watchedIndicator'] as num?)?.toInt() ?? 1, - mediaReviewsVisibility: - (json['mediaReviewsVisibility'] as num?)?.toInt() ?? 0, - mediaReviewsLanguages: (json['mediaReviewsLanguages'] as List?) - ?.map((e) => e as String) - .toList(), -); - -Map _$PlexUserProfileToJson(PlexUserProfile instance) => - { - 'autoSelectAudio': instance.autoSelectAudio, - 'defaultAudioAccessibility': instance.defaultAudioAccessibility, - 'defaultAudioLanguage': instance.defaultAudioLanguage, - 'defaultAudioLanguages': instance.defaultAudioLanguages, - 'defaultSubtitleLanguage': instance.defaultSubtitleLanguage, - 'defaultSubtitleLanguages': instance.defaultSubtitleLanguages, - 'autoSelectSubtitle': instance.autoSelectSubtitle, - 'defaultSubtitleAccessibility': instance.defaultSubtitleAccessibility, - 'defaultSubtitleForced': instance.defaultSubtitleForced, - 'watchedIndicator': instance.watchedIndicator, - 'mediaReviewsVisibility': instance.mediaReviewsVisibility, - 'mediaReviewsLanguages': instance.mediaReviewsLanguages, - }; diff --git a/lib/models/trackers/tracker_context.dart b/lib/models/trackers/tracker_context.dart index ded5522e..8efd0825 100644 --- a/lib/models/trackers/tracker_context.dart +++ b/lib/models/trackers/tracker_context.dart @@ -1,4 +1,4 @@ -import '../../utils/plex_external_ids.dart'; +import '../../utils/external_ids.dart'; import 'anime_ids.dart'; /// Immutable per-playback context passed from the coordinator to each @@ -9,7 +9,7 @@ import 'anime_ids.dart'; /// in the Fribb mapping). General-purpose trackers (Simkl) prefer Plex IDs; /// anime-only trackers (MAL, AniList) no-op when [anime] is null. class TrackerContext { - final PlexExternalIds external; + final ExternalIds external; final AnimeIds? anime; final bool isMovie; @@ -35,7 +35,7 @@ class TrackerContext { }); factory TrackerContext.movie({ - required PlexExternalIds external, + required ExternalIds external, required AnimeIds? anime, required String ratingKey, required String? libraryGlobalKey, @@ -50,7 +50,7 @@ class TrackerContext { } factory TrackerContext.episode({ - required PlexExternalIds external, + required ExternalIds external, required AnimeIds? anime, required String ratingKey, required String? libraryGlobalKey, diff --git a/lib/models/trakt/trakt_ids.dart b/lib/models/trakt/trakt_ids.dart index 698fad6e..8542508a 100644 --- a/lib/models/trakt/trakt_ids.dart +++ b/lib/models/trakt/trakt_ids.dart @@ -1,4 +1,4 @@ -import '../../utils/plex_external_ids.dart'; +import '../../utils/external_ids.dart'; /// External IDs for matching Plex items against Trakt's catalog. /// @@ -32,5 +32,5 @@ class TraktIds { tvdb: (json['tvdb'] as num?)?.toInt(), ); - factory TraktIds.fromExternal(PlexExternalIds ids) => TraktIds(imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb); + factory TraktIds.fromExternal(ExternalIds ids) => TraktIds(imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb); } diff --git a/lib/models/user_switch_response.dart b/lib/models/user_switch_response.dart index 2476ea8f..66b80502 100644 --- a/lib/models/user_switch_response.dart +++ b/lib/models/user_switch_response.dart @@ -1,4 +1,4 @@ -import 'plex_user_profile.dart'; +import 'plex/plex_user_profile.dart'; class UserSwitchResponse { final int id; diff --git a/lib/profiles/active_plex_identity.dart b/lib/profiles/active_plex_identity.dart new file mode 100644 index 00000000..3a5989bf --- /dev/null +++ b/lib/profiles/active_plex_identity.dart @@ -0,0 +1,71 @@ +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; +import 'active_profile_provider.dart'; +import 'profile_connection_registry.dart'; + +class ActivePlexIdentity { + const ActivePlexIdentity({required this.account, this.userUuid}); + + final PlexAccountConnection account; + final String? userUuid; +} + +Future resolveActivePlexIdentity({ + required ActiveProfileProvider activeProfile, + required ConnectionRegistry connections, + required ProfileConnectionRegistry profileConnections, + PlexAccountConnection? preferredAccount, +}) async { + await activeProfile.initialize(); + final profile = activeProfile.active; + + String? userUuidForPreferred() { + if (profile == null) return null; + if (profile.parentConnectionId == preferredAccount?.id) { + return profile.plexHomeUserUuid; + } + return null; + } + + if (preferredAccount != null) { + final preferredUserUuid = userUuidForPreferred(); + if (preferredUserUuid != null) { + return ActivePlexIdentity(account: preferredAccount, userUuid: preferredUserUuid); + } + if (profile != null) { + final pcs = await profileConnections.listForProfile(profile.id); + for (final pc in pcs) { + if (pc.connectionId == preferredAccount.id) { + return ActivePlexIdentity( + account: preferredAccount, + userUuid: pc.userIdentifier.isEmpty ? null : pc.userIdentifier, + ); + } + } + } + return ActivePlexIdentity(account: preferredAccount); + } + + final parentId = profile?.parentConnectionId; + if (parentId != null) { + final account = await connections.getPlexAccount(parentId); + if (account != null) { + return ActivePlexIdentity(account: account, userUuid: profile?.plexHomeUserUuid); + } + } + + if (profile != null) { + final pcs = await profileConnections.listForProfile(profile.id); + for (final pc in pcs) { + final account = await connections.getPlexAccount(pc.connectionId); + if (account != null) { + return ActivePlexIdentity(account: account, userUuid: pc.userIdentifier.isEmpty ? null : pc.userIdentifier); + } + } + return null; + } + + final accounts = await connections.listPlexAccounts(); + if (accounts.isEmpty) return null; + return ActivePlexIdentity(account: accounts.first); +} diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart new file mode 100644 index 00000000..22298bb1 --- /dev/null +++ b/lib/profiles/active_profile_binder.dart @@ -0,0 +1,518 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; +import '../exceptions/media_server_exceptions.dart'; +import '../providers/multi_server_provider.dart'; +import '../services/multi_server_manager.dart'; +import '../services/plex_auth_service.dart'; +import '../utils/app_logger.dart'; +import 'active_profile_provider.dart'; +import 'plex_home_switch.dart'; +import 'profile.dart'; +import 'profile_connection.dart'; +import 'profile_connection_registry.dart'; + +/// Callback invoked when a Plex Home user PIN is required mid-activation. +/// Returns the entered PIN, or `null` to abort. The implementation +/// (typically in `main_screen.dart`) should call `showPinEntryDialog`. +typedef PlexHomePinPrompt = Future Function(Profile profile, {String? errorMessage}); + +@visibleForTesting +bool shouldUsePlexHomeTokenCache({required bool preVerified, required bool hasBoundOnce, required bool plexProtected}) { + return preVerified || (!hasBoundOnce && !plexProtected); +} + +/// Wires the active [Profile] into [MultiServerManager] + [MultiServerProvider]. +/// +/// Both kinds bind connections in two layers: +/// +/// 1. **Parent (plex_home only)**: Plex Home profiles have an implicit +/// parent [PlexAccountConnection] (referenced by `parentConnectionId`, +/// not stored in the join table). The binder reuses the cached +/// `/home/users/{uuid}/switch` token if available, otherwise mints one +/// via [pinPrompt] when Plex's `protected` flag is set. +/// 2. **Join rows**: every [ProfileConnection] row for the profile — +/// borrowed Plex accounts (each with its own `userToken`) and Jellyfin +/// servers. The same path runs for local Plezy profiles, which only +/// have join rows. +/// +/// After both layers, servers not in the bound set are removed from +/// [MultiServerManager], and the bound id set is pushed into +/// [MultiServerProvider]. An empty bound set is propagated as `{}` so a +/// profile with no connections shows nothing — falling back to "all +/// visible" would leak servers attached to other profiles. +class ActiveProfileBinder { + ActiveProfileBinder({ + required this.activeProfile, + required this.connections, + required this.profileConnections, + required this.serverManager, + required this.multiServerProvider, + required this.pinPrompt, + PlexAuthService? plexAuth, + }) : _plexAuth = plexAuth; + + final ActiveProfileProvider activeProfile; + final ConnectionRegistry connections; + final ProfileConnectionRegistry profileConnections; + final MultiServerManager serverManager; + final MultiServerProvider multiServerProvider; + final PlexHomePinPrompt pinPrompt; + + PlexAuthService? _plexAuth; + + bool _started = false; + bool _isSwitching = false; + String? _lastBoundProfileId; + String? _bindingProfileId; + bool _pendingRebind = false; + // Set when something asks for a rebind of the *currently-active* profile + // while a rebind is already in flight. The normal `_pendingRebind` path + // only loops when the active id has drifted — this flag covers same-id + // re-runs, e.g. after a borrow upserts a new join row. + bool _pendingSameIdRebind = false; + + /// True after the binder has successfully bound at least one profile in + /// this session. Once set, subsequent rebinds bypass the user-token + /// cache and always call `/home/users/{uuid}/switch` — that round-trip + /// is the only way Plex re-validates the user's PIN. Cold-start auto-resume + /// still uses the cache unless the user enabled profile selection on open. + bool _hasBoundOnce = false; + + /// Plex Home profile ids whose PIN was just verified by the activation + /// UI via a successful `/home/users/{uuid}/switch` round-trip. Consumed + /// once by [_bindPlexHome] to permit the freshly cached user-token for + /// that single rebind and avoid a duplicate PIN prompt. + final Set _plexHomePreVerified = {}; + + bool get isSwitching => _isSwitching; + + @visibleForTesting + String? get debugLastBoundProfileId => _lastBoundProfileId; + + void markPlexHomePreVerified(String profileId) { + _plexHomePreVerified.add(profileId); + } + + @visibleForTesting + bool consumePlexHomePreVerified(String profileId) { + return _plexHomePreVerified.remove(profileId); + } + + void start() { + if (_started) return; + _started = true; + activeProfile.addListener(_onActiveProfileChanged); + // Defer the first rebind: _runRebindOnce calls markBindingStarted → + // notifyListeners on ActiveProfileProvider, and start() is invoked from + // Provider's create callback during the build phase. + // Notifying synchronously there re-enters the widget tree before this + // provider's value has been assigned and crashes the inspector. + scheduleMicrotask(() { + if (!_started) return; + unawaited(_rebind()); + }); + } + + void _onActiveProfileChanged() { + final id = activeProfile.activeId; + if (_isSwitching) { + // Ignore our own markBindingStarted/markBindingFinished + // notifications. They don't mean the active profile changed, and a + // failed bind intentionally leaves `_lastBoundProfileId` unset so the + // same profile can be retried later. + if (id == _bindingProfileId) return; + // A rebind is already in flight — flag a follow-up so the loop in + // [_rebind] picks up the new active id once the current pass settles. + // Otherwise the switch is silently dropped (the early-return on + // `_isSwitching` would leave storage saying B is active while the + // binder is still wired to A). + _pendingRebind = true; + return; + } + if (id == _lastBoundProfileId) return; + unawaited(_rebind()); + } + + /// Force the binder to re-run for the currently-active profile, even + /// when the active id hasn't changed. Used by flows that mutate the + /// active profile's connection set in-place — e.g. the borrow screen + /// upserts a new join row and needs the binder to pick it up so the new + /// server's libraries appear without an app restart. + /// + /// Safe to call while a rebind is in flight; the request is queued and + /// the loop runs an extra pass when the current one settles. + Future rebindActive() async { + if (_isSwitching) { + _pendingSameIdRebind = true; + return; + } + await _rebind(); + } + + /// Convenience: rebind only when [profileId] matches the active profile. + /// No-op otherwise — the change will be picked up on next activation. + /// Use this from screens that mutate a specific profile's connections. + Future rebindIfActive(String profileId) async { + if (activeProfile.activeId != profileId) return; + await rebindActive(); + } + + Future _rebind() async { + if (_isSwitching) return; + _isSwitching = true; + try { + do { + _pendingRebind = false; + _pendingSameIdRebind = false; + await _runRebindOnce(); + // Loop only when the active id has drifted to something we haven't + // bound yet, OR when an explicit same-id rebind was queued (borrow + // / connection-list mutation while a rebind was in flight). Bare + // `_pendingRebind` would spin forever if the user taps the active + // profile while we're binding (id matches, no work to do, flag + // re-asserts). + } while (_pendingSameIdRebind || (_pendingRebind && activeProfile.activeId != _lastBoundProfileId)); + } finally { + _isSwitching = false; + } + } + + Future _runRebindOnce() async { + _bindingProfileId = activeProfile.activeId; + activeProfile.markBindingStarted(); + var success = false; + String? attemptedProfileId; + try { + final profile = activeProfile.active; + if (profile == null) { + // No active profile is a valid quiescent state (e.g. fresh sign-in + // before the picker fires) — report success so the picker, if it's + // waiting, doesn't surface a spurious "switch failed" error. Also + // clear the runtime filter so stale clients from the previous + // profile cannot leak into the no-selection state. + for (final serverId in serverManager.serverIds.toList()) { + serverManager.removeServer(serverId); + } + multiServerProvider.setVisibleServerIds({}); + success = true; + return; + } + attemptedProfileId = profile.id; + appLogger.i('ActiveProfileBinder: rebinding for ${profile.displayName} (${profile.id})'); + + final visibleServerIds = {}; + final localProfileHasJoinRows = + profile.isLocal && (await profileConnections.listForProfile(profile.id)).isNotEmpty; + + if (profile.isPlexHome) { + visibleServerIds.addAll(await _bindPlexHome(profile)); + } + // 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 + // account is bound by `_bindPlexHome` above and isn't represented in + // the join table. + visibleServerIds.addAll(await _bindJoinRows(profile)); + + // Remove servers the profile no longer has access to. Always set the + // filter to the bound set (even when empty) so a profile with no + // connections shows nothing — falling back to "all visible" on empty + // would leak servers attached to other profiles. + for (final serverId in serverManager.serverIds.toList()) { + if (!visibleServerIds.contains(serverId)) { + serverManager.removeServer(serverId); + } + } + multiServerProvider.setVisibleServerIds(visibleServerIds); + success = (profile.isLocal && !localProfileHasJoinRows) || visibleServerIds.isNotEmpty; + // Once we've bound a profile with real servers in this session, + // we've crossed the cold-start boundary — every subsequent rebind + // is a user-initiated switch and must re-prompt for PIN where + // applicable. See [_hasBoundOnce] for the security rationale. + if (success) _hasBoundOnce = true; + } catch (e, st) { + appLogger.e('ActiveProfileBinder: rebind failed', error: e, stackTrace: st); + success = false; + } finally { + if (success) { + _lastBoundProfileId = attemptedProfileId; + } else if (_lastBoundProfileId == attemptedProfileId) { + _lastBoundProfileId = null; + } + activeProfile.markBindingFinished(success: success); + _bindingProfileId = null; + } + } + + Future> _bindPlexHome(Profile profile) async { + final parentId = profile.parentConnectionId; + final homeUuid = profile.plexHomeUserUuid; + if (parentId == null || homeUuid == null) { + appLogger.w('ActiveProfileBinder: ${profile.displayName} missing parent/uuid metadata'); + return const {}; + } + final account = await connections.getPlexAccount(parentId); + if (account == null) { + appLogger.w('ActiveProfileBinder: parent connection $parentId for ${profile.displayName} not found'); + return const {}; + } + final auth = await _ensureAuth(); + + // Fast path: reuse the previously-minted user-token from the + // [ProfileConnection] row for this profile's parent connection. + // Cold-start auto-resume can use cached tokens for unprotected Plex Home + // users only. Protected users must revalidate their PIN unless + // activateProfileWithPin has already minted the token in this same + // activation (pre-verified flag), in which case using the cache once skips + // a redundant second prompt without weakening the security model. + final preVerified = consumePlexHomePreVerified(profile.id); + final useCache = shouldUsePlexHomeTokenCache( + preVerified: preVerified, + hasBoundOnce: _hasBoundOnce, + plexProtected: profile.plexProtected, + ); + String? cachedToken; + if (useCache) { + final pc = await profileConnections.get(profile.id, parentId); + cachedToken = pc?.hasToken == true ? pc!.userToken : null; + } + appLogger.d( + 'ActiveProfileBinder: cache lookup for ${profile.displayName} (account=${account.id}, ' + 'uuid=$homeUuid, useCache=$useCache, preVerified=$preVerified): ${cachedToken == null ? (useCache ? "MISS" : "BYPASS") : "HIT"}', + ); + if (cachedToken != null) { + try { + final servers = await auth.fetchServers(cachedToken); + if (servers.isNotEmpty) { + appLogger.i('ActiveProfileBinder: using cached token for ${profile.displayName} (${servers.length} servers)'); + return _connectFromServers(account, cachedToken, servers, profile.displayName); + } + appLogger.w( + 'ActiveProfileBinder: cached token returned 0 servers for ${profile.displayName} — wiping and re-minting', + ); + await profileConnections.recordToken(profile.id, parentId, ''); + } on MediaServerHttpException catch (e) { + if (e.statusCode == 401 || e.statusCode == 403) { + appLogger.w( + 'ActiveProfileBinder: cached token rejected (${e.statusCode}) for ${profile.displayName} — falling back to /switch', + ); + await profileConnections.recordToken(profile.id, parentId, ''); + } else { + appLogger.w( + 'ActiveProfileBinder: fetchServers failed with cached token for ${profile.displayName}', + error: e, + ); + return const {}; + } + } catch (e, st) { + appLogger.w( + 'ActiveProfileBinder: fetchServers failed with cached token for ${profile.displayName}', + error: e, + stackTrace: st, + ); + return const {}; + } + } + + appLogger.i('ActiveProfileBinder: minting fresh user-token via /switch for ${profile.displayName}'); + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: account.accountToken, + homeUserUuid: homeUuid, + requiresPin: profile.plexProtected, + promptForPin: ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage), + logLabel: profile.displayName, + ); + if (!result.succeeded) return const {}; + // Persist the minted user-token onto the parent ProfileConnection + // row. Plex Home profiles don't normally have a join row for the + // parent (the borrow flow is for *other* connections layered onto + // the profile), so creating one here gives the token a stable home + // alongside the rest of the profile's tokens — same shape as the + // local-profile path that `_bindLocalPlexConnection` already uses. + await profileConnections.upsert( + ProfileConnection( + profileId: profile.id, + connectionId: parentId, + userToken: result.userToken, + userIdentifier: homeUuid, + tokenAcquiredAt: DateTime.now(), + ), + ); + appLogger.i( + 'ActiveProfileBinder: persisted user-token for ${profile.displayName} ' + '(account=${account.id}, uuid=$homeUuid, tokenLen=${result.userToken!.length})', + ); + return _connectPlexServers(account, result.userToken!, profile.displayName); + } + + /// Bind every [ProfileConnection] row for [profile]. Used by both kinds: + /// for local profiles, this is the entire bind. For plex_home profiles, + /// this handles connections borrowed on top of the parent account (the + /// parent itself is bound by [_bindPlexHome] and is implicit — not in the + /// join table). Skips Plex rows whose `connectionId` matches the parent + /// (defensive guard — sync code shouldn't insert one, but treating it as + /// a borrow would re-mint a redundant token). + Future> _bindJoinRows(Profile profile) async { + final pcs = await profileConnections.listForProfile(profile.id); + if (pcs.isEmpty) { + if (profile.isLocal) { + appLogger.w('ActiveProfileBinder: ${profile.displayName} has no connections'); + } + return const {}; + } + final all = await connections.list(); + final byId = {for (final c in all) c.id: c}; + final parentId = profile.parentConnectionId; + + final visible = {}; + for (final pc in pcs) { + if (parentId != null && pc.connectionId == parentId) continue; + final conn = byId[pc.connectionId]; + if (conn == null) { + appLogger.w('ActiveProfileBinder: missing connection ${pc.connectionId} for ${profile.displayName}'); + continue; + } + switch (conn) { + case PlexAccountConnection(): + visible.addAll(await _bindLocalPlexConnection(profile: profile, conn: conn, pc: pc)); + case JellyfinConnection(): + final id = await _bindJellyfin(conn); + if (id != null) visible.add(id); + } + } + return visible; + } + + Future> _bindLocalPlexConnection({ + required Profile profile, + required PlexAccountConnection conn, + required ProfileConnection pc, + }) async { + final auth = await _ensureAuth(); + String? userToken = pc.userToken; + List? servers; + + if (userToken != null && userToken.isNotEmpty) { + try { + servers = await auth.fetchServers(userToken); + } on MediaServerHttpException catch (e) { + if (e.statusCode == 401 || e.statusCode == 403) { + appLogger.w( + 'ActiveProfileBinder: cached local Plex token rejected (${e.statusCode}) for ${profile.displayName} — re-minting', + ); + await profileConnections.recordToken(profile.id, conn.id, ''); + userToken = null; + } else { + appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e); + return const {}; + } + } catch (e, st) { + appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st); + return const {}; + } + } + + if (userToken == null || userToken.isEmpty) { + if (pc.userIdentifier.isEmpty) { + appLogger.w('ActiveProfileBinder: ${profile.displayName} has no Plex Home user identifier'); + return const {}; + } + final minted = await _mintLocalPlexToken(auth: auth, profile: profile, conn: conn, pc: pc); + if (minted == null) return const {}; + userToken = minted; + try { + servers = await auth.fetchServers(userToken); + } catch (e, st) { + appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st); + return const {}; + } + } + + final ids = await _connectFromServers(conn, userToken, servers ?? const [], profile.displayName); + await profileConnections.markUsed(profile.id, conn.id); + return ids; + } + + Future _mintLocalPlexToken({ + required PlexAuthService auth, + required Profile profile, + required PlexAccountConnection conn, + required ProfileConnection pc, + }) async { + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: conn.accountToken, + homeUserUuid: pc.userIdentifier, + // Local profiles don't carry the protected flag; the loop will + // re-prompt if Plex disagrees. + requiresPin: false, + promptForPin: ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage), + logLabel: profile.displayName, + ); + if (!result.succeeded) return null; + final userToken = result.userToken!; + await profileConnections.recordToken(profile.id, conn.id, userToken); + return userToken; + } + + Future> _connectPlexServers(PlexAccountConnection account, String userToken, String profileLabel) async { + final auth = await _ensureAuth(); + final List servers; + try { + servers = await auth.fetchServers(userToken); + } catch (e, st) { + appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st); + return const {}; + } + return _connectFromServers(account, userToken, servers, profileLabel); + } + + Future> _connectFromServers( + PlexAccountConnection account, + String userToken, + List servers, + String profileLabel, + ) async { + if (servers.isEmpty) { + appLogger.w('ActiveProfileBinder: no servers for $profileLabel on ${account.accountLabel}'); + return const {}; + } + final updatedConn = account.copyWith(servers: servers); + final boundIds = await serverManager.refreshTokensForProfile(updatedConn); + appLogger.i('ActiveProfileBinder: bound ${boundIds.length}/${servers.length} Plex servers for $profileLabel'); + // Return only the ids that actually connected — the visibility filter + // pushed downstream must not include unreachable servers, otherwise + // the UI lists them and downstream calls 404/timeout per interaction. + return boundIds; + } + + Future _bindJellyfin(JellyfinConnection conn) async { + final ok = await serverManager.addJellyfinConnection(conn); + // `addJellyfinConnection` registers the client even when the health probe + // returns authError. Keep that server in the active profile's visibility + // filter so the re-auth banner can surface it instead of hiding it as if + // the profile had no server. + if (ok || serverManager.authErrorServerIds.contains(conn.serverMachineId)) { + return conn.serverMachineId; + } + return null; + } + + Future _ensureAuth() async { + return _plexAuth ??= await PlexAuthService.create(); + } + + void dispose() { + if (!_started) return; + activeProfile.removeListener(_onActiveProfileChanged); + _plexHomePreVerified.clear(); + _plexAuth?.dispose(); + _plexAuth = null; + _started = false; + } +} diff --git a/lib/profiles/active_profile_provider.dart b/lib/profiles/active_profile_provider.dart new file mode 100644 index 00000000..edbf47b0 --- /dev/null +++ b/lib/profiles/active_profile_provider.dart @@ -0,0 +1,280 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; +import '../mixins/disposable_change_notifier_mixin.dart'; +import '../models/plex/plex_home_user.dart'; +import '../services/storage_service.dart'; +import '../utils/app_logger.dart'; +import 'plex_home_service.dart'; +import 'profile.dart'; +import 'profile_merge.dart'; +import 'profile_registry.dart'; + +/// Holds the currently active [Profile] and a merged list of all available +/// profiles — local rows from [ProfileRegistry] plus virtual Plex Home +/// profiles built from [PlexHomeService]'s live cache. +/// +/// The active id (in storage) can reference either a local row or a Plex +/// Home virtual id like `plex-home-{connId}-{uuid}`. Resolution checks +/// local profiles first, then live home users; if neither matches we fall +/// back to the first profile in the merged list. +class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin { + ActiveProfileProvider({ + required ProfileRegistry registry, + required PlexHomeService plexHome, + required ConnectionRegistry connections, + StorageService? storage, + }) : _registry = registry, + _plexHome = plexHome, + _connections = connections, + _storage = storage; + + final ProfileRegistry _registry; + final PlexHomeService _plexHome; + final ConnectionRegistry _connections; + StorageService? _storage; + + Profile? _active; + List _profiles = const []; + List _localProfiles = const []; + Map> _plexHomeUsers = const {}; + Map _connectionsById = const {}; + + StreamSubscription>? _localSub; + StreamSubscription>? _connSub; + StreamSubscription>>? _plexHomeSub; + Future? _initializeFuture; + bool _initialized = false; + + bool _isBinding = false; + bool _lastBindingSucceeded = true; + final List> _bindingSettleWaiters = []; + + Profile? get active => _active; + String? get activeId => _active?.id; + List get profiles => _profiles; + bool get hasMultipleProfiles => _profiles.length > 1; + bool get isInitialized => _initialized; + + /// True while [ActiveProfileBinder] is wiring servers/tokens for the + /// active profile. The picker reads this so it can stay open (and stay + /// behind any PIN dialog the binder pops) until binding settles. + bool get isBinding => _isBinding; + + /// Outcome of the most recent settled bind. False after a PIN cancel, + /// failed `/home/users/{uuid}/switch`, or any error inside the binder. + bool get lastBindingSucceeded => _lastBindingSucceeded; + + /// Called by [ActiveProfileBinder] at the start of a rebind cycle. + /// Re-entrant — no-ops if already in [isBinding]. + void markBindingStarted() { + if (_isBinding) return; + _isBinding = true; + _lastBindingSucceeded = true; + safeNotifyListeners(); + } + + /// Called by [ActiveProfileBinder] when the rebind settles. [success] + /// means the active profile was applied successfully. A local profile with + /// no connections is successful and intentionally exposes no servers; false + /// covers the "user cancelled the PIN" path and real binding errors. + void markBindingFinished({required bool success}) { + if (!_isBinding && _lastBindingSucceeded == success) return; + _isBinding = false; + _lastBindingSucceeded = success; + safeNotifyListeners(); + } + + /// Resolves once the binder reports done. Returns immediately when no + /// rebind is in flight. The boolean reflects [lastBindingSucceeded] at + /// the moment binding settles, so the picker can decide whether to + /// dismiss or surface an error. + /// + /// Pending completers are tracked so [dispose] can settle them — without + /// that, awaiters (picker, user-profile refresh, post-bind hooks) hang + /// indefinitely when the provider is torn down mid-rebind. + Future awaitBindingSettle() { + if (!_isBinding) return Future.value(_lastBindingSucceeded); + final completer = Completer(); + _bindingSettleWaiters.add(completer); + void listener() { + if (_isBinding) return; + removeListener(listener); + if (_bindingSettleWaiters.remove(completer) && !completer.isCompleted) { + completer.complete(_lastBindingSucceeded); + } + } + + addListener(listener); + return completer.future; + } + + Future initialize() { + if (_initialized) return Future.value(); + final pending = _initializeFuture; + if (pending != null) return pending; + + final future = _initialize().catchError((Object error, StackTrace stackTrace) { + _initializeFuture = null; + Error.throwWithStackTrace(error, stackTrace); + }); + _initializeFuture = future; + return future; + } + + Future _initialize() async { + _storage ??= await StorageService.getInstance(); + // Hydrate the Plex Home cache before we read it — `_plexHome.current` + // is only populated after start() finishes its disk-cache load. + await _plexHome.start(); + + _localProfiles = await _registry.list(); + final initialConns = await _connections.list(); + _connectionsById = {for (final c in initialConns) c.id: c}; + _plexHomeUsers = _plexHome.current; + _recomputeProfiles(); + _resolveActive(); + + _localSub = _registry.watchProfiles().listen((list) { + _localProfiles = list; + _recomputeProfiles(); + _resolveActive(); + safeNotifyListeners(); + }); + _connSub = _connections.watchConnections().listen((list) { + _connectionsById = {for (final c in list) c.id: c}; + _recomputeProfiles(); + _resolveActive(); + safeNotifyListeners(); + }); + _plexHomeSub = _plexHome.stream.listen((cache) { + _plexHomeUsers = cache; + _recomputeProfiles(); + _resolveActive(); + safeNotifyListeners(); + }); + + _initialized = true; + safeNotifyListeners(); + } + + void _recomputeProfiles() { + _profiles = mergeLocalWithPlexHome( + locals: _localProfiles, + plexHomeByConnectionId: _plexHomeUsers, + connectionsById: _connectionsById, + storage: _storage, + ); + } + + void _resolveActive() { + if (_profiles.isEmpty) { + _active = null; + return; + } + final id = _storage?.getActiveProfileId(); + // No saved id means "fresh state" — leave the active profile null so + // MainScreen prompts the user via the picker. Auto-falling back to + // `_profiles.first` would let the binder bind (and possibly PIN-prompt) + // a profile the user never picked. + if (id == null) { + _active = null; + return; + } + for (final p in _profiles) { + if (p.id == id) { + _active = p; + return; + } + } + // Saved id no longer matches anything (e.g. Plex admin removed the + // home user). Clear it so storage-scoped settings do not keep reading + // and writing under the removed profile's user scope, and let the UI + // force an explicit profile choice. + _active = null; + final storage = _storage; + if (storage != null) { + unawaited(storage.clearActiveProfileId()); + } + } + + /// Activate [profile]. PIN-protected local profiles must supply a matching + /// PIN; for Plex Home profiles the binder enforces the PIN via + /// `/home/users/{uuid}/switch` after activation. + Future activate(Profile profile, {String? pin}) async { + if (profile.isLocal && profile.isPinProtected) { + final hash = profile.pinHash; + if (pin == null || hash == null || !verifyPin(pin, hash)) { + return false; + } + } + final storage = _storage; + if (storage == null) return false; + await storage.setActiveProfileId(profile.id); + final now = DateTime.now(); + await storage.markProfileUsed(profile.id, now); + _active = profile.copyWith(lastUsedAt: now); + safeNotifyListeners(); + appLogger.i('ActiveProfileProvider: activated ${profile.displayName} (${profile.id})'); + if (profile.isLocal) { + // Local rows also bump the DB's lastUsedAt so the in-DB sortable column + // stays accurate — the in-memory mark above keeps the picker snappy. + unawaited( + _registry.markUsed(profile.id, now).catchError((Object e, StackTrace s) { + appLogger.w('markUsed failed for ${profile.id}', error: e, stackTrace: s); + }), + ); + } + return true; + } + + /// Clear the selected profile in both storage and memory so the picker + /// can force an explicit choice on the next screen. + Future clearActiveProfile() async { + final storage = _storage ??= await StorageService.getInstance(); + await storage.clearActiveProfileId(); + _active = null; + safeNotifyListeners(); + } + + @visibleForTesting + Future resetForTesting() async { + await _localSub?.cancel(); + await _connSub?.cancel(); + await _plexHomeSub?.cancel(); + _localSub = null; + _connSub = null; + _plexHomeSub = null; + _profiles = const []; + _localProfiles = const []; + _plexHomeUsers = const {}; + _connectionsById = const {}; + _active = null; + _initializeFuture = null; + _initialized = false; + _isBinding = false; + _lastBindingSucceeded = true; + for (final c in _bindingSettleWaiters) { + if (!c.isCompleted) c.complete(_lastBindingSucceeded); + } + _bindingSettleWaiters.clear(); + } + + @override + void dispose() { + // Settle anyone awaiting binding before the listeners go away — leaving + // them pending traps callers in a forever-await on app teardown. + for (final c in _bindingSettleWaiters) { + if (!c.isCompleted) c.complete(_lastBindingSucceeded); + } + _bindingSettleWaiters.clear(); + _initializeFuture = null; + _localSub?.cancel(); + _connSub?.cancel(); + _plexHomeSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/profiles/plex_home_service.dart b/lib/profiles/plex_home_service.dart new file mode 100644 index 00000000..f49edd98 --- /dev/null +++ b/lib/profiles/plex_home_service.dart @@ -0,0 +1,256 @@ +import 'dart:async'; +import 'dart:convert'; + +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; +import '../models/plex/plex_home.dart'; +import '../models/plex/plex_home_user.dart'; +import '../services/plex_auth_service.dart'; +import '../services/storage_service.dart'; +import '../utils/app_logger.dart'; +import 'profile_connection_registry.dart'; + +/// Live source of truth for Plex Home users — Plex owns these, so we never +/// persist them as `Profile` rows. The service fetches `/home/users` per +/// connected Plex account, caches the raw JSON in [StorageService] for cold +/// starts, and emits a `Stream>>` that +/// UI surfaces (profile picker, active-profile resolver) merge with the +/// local Profile rows from [ProfileRegistry]. +/// +/// Stale-while-revalidate: the cache returns immediately on subscribe; +/// background refreshes happen on connection add and via the periodic ticker. +class PlexHomeService { + PlexHomeService({ + required ConnectionRegistry connections, + required ProfileConnectionRegistry profileConnections, + StorageService? storage, + Future> Function(String accountToken)? plexHomeUserFetcher, + Duration refreshInterval = const Duration(hours: 1), + }) : _connections = connections, + _profileConnections = profileConnections, + _storage = storage, + _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher, + _refreshInterval = refreshInterval; + + final ConnectionRegistry _connections; + final ProfileConnectionRegistry _profileConnections; + StorageService? _storage; + final Future> Function(String accountToken) _fetchHomeUsers; + final Duration _refreshInterval; + + final Map> _byConnection = {}; + final _controller = StreamController>>.broadcast(); + StreamSubscription>? _connSub; + Timer? _refreshTimer; + Future? _startFuture; + bool _started = false; + + /// Snapshot of the current cache (immutable view). + Map> get current => Map.unmodifiable(_byConnection); + + /// Emits the current snapshot immediately on subscribe, then forwards + /// every change from [_controller]. Without the seed emission, late + /// subscribers (e.g. the profiles management screen, which mounts long + /// after [start] fires its initial `_emit`) sit on `ConnectionState.waiting` + /// forever — `combineLatest` upstream of them never fills its slot for + /// this stream and the UI shows a perpetual spinner. + Stream>> get stream { + late StreamController>> ctrl; + StreamSubscription>>? sub; + ctrl = StreamController>>( + onListen: () { + ctrl.add(Map.unmodifiable(_byConnection)); + sub = _controller.stream.listen(ctrl.add, onError: ctrl.addError, onDone: ctrl.close); + }, + onPause: () => sub?.pause(), + onResume: () => sub?.resume(), + onCancel: () => sub?.cancel(), + ); + return ctrl.stream; + } + + Future start() { + if (_started) return Future.value(); + final pending = _startFuture; + if (pending != null) return pending; + + final future = _start().catchError((Object error, StackTrace stackTrace) { + _startFuture = null; + Error.throwWithStackTrace(error, stackTrace); + }); + _startFuture = future; + return future; + } + + Future _start() async { + _storage ??= await StorageService.getInstance(); + + final initial = await _connections.list(); + for (final conn in initial.whereType()) { + final cached = _readCache(conn.id); + if (cached != null) _byConnection[conn.id] = cached; + } + _emit(); + + _connSub = _connections.watchConnections().listen(_onChange); + _refreshTimer = Timer.periodic(_refreshInterval, (_) => unawaited(_refreshAll())); + + _started = true; + // Background refresh on startup so stale caches catch up. + unawaited(_refreshAll()); + } + + Future _onChange(List current) async { + final storage = _storage; + if (storage == null) return; + final plexConns = current.whereType().toList(); + final currentIds = plexConns.map((c) => c.id).toSet(); + + // Snapshot what's tracked *now*, before any await. Recomputing after + // the await loop would race a concurrent `_fetchAndCache` writing to + // `_byConnection` — newly-added accounts whose users that fetch was + // loading would appear "tracked" and the refresh below would skip them. + final trackedBefore = _byConnection.keys.toSet(); + final removed = trackedBefore.difference(currentIds); + final toFetch = plexConns.where((c) => !trackedBefore.contains(c.id)).toList(); + + var changed = false; + for (final id in removed) { + _byConnection.remove(id); + await storage.clearPlexHomeUsersCache(id); + // Also drop any join rows referencing the gone parent account — + // their cached `/switch` user-tokens become invalid the moment + // the parent account goes away, and the rows would otherwise + // linger as orphans. + await _profileConnections.removeAllForConnection(id); + changed = true; + } + + if (changed) _emit(); + + for (final conn in toFetch) { + unawaited(_fetchAndCache(conn)); + } + } + + Future _refreshAll() async { + final list = await _connections.list(); + for (final conn in list.whereType()) { + unawaited(_fetchAndCache(conn)); + } + } + + /// Force-refresh a single account. Useful after sign-in / borrow flows. + Future refresh(PlexAccountConnection conn) => _fetchAndCache(conn); + + Future _fetchAndCache(PlexAccountConnection conn) async { + if (conn.accountToken.isEmpty) { + appLogger.w('PlexHomeService: skipping fetch for ${conn.accountLabel} (${conn.id}) — empty token'); + return; + } + final storage = _storage ?? await StorageService.getInstance(); + _storage = storage; + try { + final users = await _fetchHomeUsers(conn.accountToken); + _byConnection[conn.id] = users; + await storage.savePlexHomeUsersCache(conn.id, users.map((u) => u.toJson()).toList()); + _emit(); + appLogger.d('PlexHomeService: cached ${users.length} home users for ${conn.accountLabel}'); + } catch (e, st) { + appLogger.w('PlexHomeService: refresh failed for ${conn.accountLabel}', error: e, stackTrace: st); + } + } + + List? _readCache(String connectionId) { + final storage = _storage; + if (storage == null) return null; + final raw = storage.getPlexHomeUsersCacheJson(connectionId); + if (raw == null) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + appLogger.w('PlexHomeService: cache for $connectionId is not a list — ignoring'); + return null; + } + return decoded.whereType>().map(PlexHomeUser.fromJson).toList(); + } catch (e, st) { + appLogger.w('PlexHomeService: failed to read cache for $connectionId', error: e, stackTrace: st); + return null; + } + } + + void _emit() { + if (!_controller.isClosed) _controller.add(Map.unmodifiable(_byConnection)); + } + + /// Build a synthetic [PlexHome] from the cached users for [connectionId]. + /// Returns `null` when no users are cached. Used by features that + /// pre-date the new model — currently the LAN companion remote, which + /// derives its shared secret from the home admin user. + PlexHome? materializePlexHome(String connectionId) { + final users = _byConnection[connectionId]; + if (users == null || users.isEmpty) return null; + return PlexHome( + id: 0, + name: '', + guestUserID: null, + guestUserUUID: '', + guestEnabled: false, + subscription: false, + users: users, + ); + } + + /// Await startup cache hydration, then materialize the home attached to + /// [connectionId]. Use this instead of [materializeFirstPlexHome] in + /// multi-account flows that already know which Plex account is active. + Future materializePlexHomeForConnection(String connectionId) async { + await start(); + return materializePlexHome(connectionId); + } + + /// Convenience wrapper: materialize the home for the first Plex account + /// in [ConnectionRegistry] (the only one most users have). + Future materializeFirstPlexHome() async { + await start(); + final all = await _connections.list(); + final first = all.whereType().firstOrNull; + if (first == null) return null; + return materializePlexHome(first.id); + } + + /// Wipe the cache (memory + disk). Used on sign-out. + /// + /// Plex-Home user-tokens used to live in [StorageService] keyed by + /// `(connectionId, homeUserUuid)`; they're now stored on + /// [ProfileConnection.userToken] and wiped by the sign-out flow's + /// `profileConnections.clear()` (see DiscoverScreen logout). This + /// method only handles the user-list cache that's still in + /// [StorageService]. + Future clearAll() async { + _byConnection.clear(); + final storage = _storage ?? await StorageService.getInstance(); + await storage.clearAllPlexHomeUsersCache(); + _emit(); + } + + Future dispose() async { + _refreshTimer?.cancel(); + _refreshTimer = null; + await _connSub?.cancel(); + _connSub = null; + _startFuture = null; + if (!_controller.isClosed) await _controller.close(); + _started = false; + } +} + +Future> _defaultHomeUserFetcher(String accountToken) async { + final auth = await PlexAuthService.create(); + try { + final home = await auth.getHomeUsers(accountToken); + return home.users; + } finally { + auth.dispose(); + } +} diff --git a/lib/profiles/plex_home_switch.dart b/lib/profiles/plex_home_switch.dart new file mode 100644 index 00000000..d57b9385 --- /dev/null +++ b/lib/profiles/plex_home_switch.dart @@ -0,0 +1,81 @@ +import '../exceptions/media_server_exceptions.dart'; +import '../services/plex_auth_service.dart'; +import '../utils/app_logger.dart'; + +/// Outcome of a Plex Home user switch attempt. +enum PlexHomeSwitchStatus { success, cancelled, failed } + +class PlexHomeSwitchResult { + final PlexHomeSwitchStatus status; + final String? userToken; + + const PlexHomeSwitchResult._(this.status, this.userToken); + + bool get succeeded => status == PlexHomeSwitchStatus.success; +} + +/// Callback that prompts the user for a Plex Home PIN during a single +/// `/home/users/{uuid}/switch` round-trip. The profile context is captured +/// by the caller. Returns the PIN string, or `null` if the user cancelled. +typedef PlexHomeSwitchPinPrompt = Future Function({String? errorMessage}); + +/// Switch into [homeUserUuid] on the account identified by [accountToken], +/// looping on Plex error code 1041 (invalid PIN). Returns the freshly minted +/// user-level token. +/// +/// Pass [requiresPin] = true when the Home user has Plex's `protected` flag +/// set; otherwise the call is attempted without a PIN first and the loop +/// only kicks in if Plex returns 1041 anyway. +/// +/// Used by both [ActiveProfileBinder] (lazy-fetching a missing token on +/// activation) and the borrow flow (minting an independent token for a +/// borrower). See `lib/profiles/active_profile_binder.dart` and +/// `lib/screens/profile/borrow_connection_screen.dart`. +Future switchPlexHomeUserWithPin({ + required PlexAuthService auth, + required String accountToken, + required String homeUserUuid, + required bool requiresPin, + required PlexHomeSwitchPinPrompt promptForPin, + String? logLabel, +}) async { + String? pin; + String? error; + while (true) { + if (requiresPin) { + pin = await promptForPin(errorMessage: error); + if (pin == null) return const PlexHomeSwitchResult._(PlexHomeSwitchStatus.cancelled, null); + } + try { + final response = await auth.switchToUser(homeUserUuid, accountToken, pin: pin); + return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, response.authToken); + } on MediaServerHttpException catch (e) { + if (e.statusCode == 403 && _isInvalidPin(e)) { + error = 'Incorrect PIN. Please try again.'; + pin = null; + // Force the next iteration to prompt even when the caller didn't + // expect a PIN — Plex disagrees about whether one is required. + requiresPin = true; + continue; + } + appLogger.e('switchPlexHomeUserWithPin failed${logLabel == null ? '' : ' for $logLabel'}', error: e); + return const PlexHomeSwitchResult._(PlexHomeSwitchStatus.failed, null); + } catch (e, st) { + appLogger.e( + 'switchPlexHomeUserWithPin failed${logLabel == null ? '' : ' for $logLabel'}', + error: e, + stackTrace: st, + ); + return const PlexHomeSwitchResult._(PlexHomeSwitchStatus.failed, null); + } + } +} + +bool _isInvalidPin(MediaServerHttpException e) { + final data = e.responseData; + if (data is! Map) return false; + final errors = data['errors']; + if (errors is! List || errors.isEmpty) return false; + final first = errors.first; + return first is Map && first['code'] == 1041; +} diff --git a/lib/profiles/profile.dart b/lib/profiles/profile.dart new file mode 100644 index 00000000..f22631af --- /dev/null +++ b/lib/profiles/profile.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +import '../models/plex/plex_home_user.dart'; + +/// Top-level profile — the user-facing identity in the app. +/// +/// Two kinds: +/// - [ProfileKind.local]: a Plezy-only profile created by the user. May have +/// an optional 4-digit PIN. +/// - [ProfileKind.plexHome]: auto-surfaced from a connected Plex account's +/// Home users. PIN protection is handled server-side by Plex via the +/// `/home/users/{uuid}/switch` flow — `pinHash` is unused. +/// +/// A profile owns 1+ connections via the `profile_connections` join table. +/// The join row carries the per-profile user-level token used to talk to +/// each connection. +class Profile { + final String id; + final ProfileKind kind; + final String displayName; + final String? avatarThumbUrl; + + /// Hashed PIN if set — only meaningful for [ProfileKind.local]. The raw + /// PIN is never persisted; see [computePinHash]. + final String? pinHash; + + /// For [ProfileKind.plexHome]: the parent Plex account's connection id. + /// `null` for local profiles. + final String? parentConnectionId; + + /// For [ProfileKind.plexHome]: the Plex Home user UUID. Used by the + /// active-profile binder to call `/home/users/{uuid}/switch`. `null` for + /// local profiles. + final String? plexHomeUserUuid; + + /// Plex Home flags — only meaningful for [ProfileKind.plexHome]. + final bool plexRestricted; + final bool plexAdmin; + + /// Plex's `protected` flag — true when the home user has a PIN that must + /// be entered before `/home/users/{uuid}/switch` will succeed. + final bool plexProtected; + + final int sortOrder; + final DateTime createdAt; + final DateTime? lastUsedAt; + + Profile({ + required this.id, + required this.kind, + required this.displayName, + this.avatarThumbUrl, + this.pinHash, + this.parentConnectionId, + this.plexHomeUserUuid, + this.plexRestricted = false, + this.plexAdmin = false, + this.plexProtected = false, + this.sortOrder = 0, + required this.createdAt, + this.lastUsedAt, + }); + + /// Construct an in-memory virtual `Profile` for a Plex Home user. These + /// are never persisted — Plex owns the Home user list, so the picker + /// reads them live from [PlexHomeService] and merges them with the local + /// rows from [ProfileRegistry]. + factory Profile.virtualPlexHome({ + required String connectionId, + required PlexHomeUser homeUser, + DateTime? lastUsedAt, + }) { + return Profile( + id: plexHomeProfileId(accountConnectionId: connectionId, homeUserUuid: homeUser.uuid), + kind: ProfileKind.plexHome, + displayName: homeUser.displayName, + avatarThumbUrl: homeUser.thumb.isNotEmpty ? homeUser.thumb : null, + parentConnectionId: connectionId, + plexHomeUserUuid: homeUser.uuid, + plexRestricted: homeUser.restricted, + plexAdmin: homeUser.admin, + plexProtected: homeUser.protected, + sortOrder: homeUser.admin ? 0 : 1, + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + lastUsedAt: lastUsedAt, + ); + } + + bool get isLocal => kind == ProfileKind.local; + bool get isPlexHome => kind == ProfileKind.plexHome; + + /// True when entering this profile requires user-supplied PIN. + /// + /// Locals: gated by their own [pinHash]. + /// Plex Home: gated by Plex's own protected flag (`plexProtected`). + bool get isPinProtected => isLocal ? (pinHash != null && pinHash!.isNotEmpty) : plexProtected; + + Profile copyWith({ + String? id, + ProfileKind? kind, + String? displayName, + String? avatarThumbUrl, + bool clearAvatar = false, + String? pinHash, + bool clearPin = false, + String? parentConnectionId, + String? plexHomeUserUuid, + bool? plexRestricted, + bool? plexAdmin, + bool? plexProtected, + int? sortOrder, + DateTime? createdAt, + DateTime? lastUsedAt, + bool clearLastUsedAt = false, + }) { + return Profile( + id: id ?? this.id, + kind: kind ?? this.kind, + displayName: displayName ?? this.displayName, + avatarThumbUrl: clearAvatar ? null : (avatarThumbUrl ?? this.avatarThumbUrl), + pinHash: clearPin ? null : (pinHash ?? this.pinHash), + parentConnectionId: parentConnectionId ?? this.parentConnectionId, + plexHomeUserUuid: plexHomeUserUuid ?? this.plexHomeUserUuid, + plexRestricted: plexRestricted ?? this.plexRestricted, + plexAdmin: plexAdmin ?? this.plexAdmin, + plexProtected: plexProtected ?? this.plexProtected, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + lastUsedAt: clearLastUsedAt ? null : (lastUsedAt ?? this.lastUsedAt), + ); + } + + Map toConfigJson() { + return switch (kind) { + ProfileKind.local => {'pinHash': pinHash}, + ProfileKind.plexHome => { + 'parentConnectionId': parentConnectionId, + 'restricted': plexRestricted, + 'admin': plexAdmin, + 'protected': plexProtected, + }, + }; + } + + factory Profile.fromRow({ + required String id, + required String kind, + required String displayName, + required String? avatarThumbUrl, + required Map json, + required int sortOrder, + required DateTime createdAt, + required DateTime? lastUsedAt, + }) { + final parsedKind = ProfileKind.fromId(kind); + return switch (parsedKind) { + ProfileKind.local => Profile( + id: id, + kind: parsedKind, + displayName: displayName, + avatarThumbUrl: avatarThumbUrl, + pinHash: json['pinHash'] as String?, + sortOrder: sortOrder, + createdAt: createdAt, + lastUsedAt: lastUsedAt, + ), + ProfileKind.plexHome => Profile( + id: id, + kind: parsedKind, + displayName: displayName, + avatarThumbUrl: avatarThumbUrl, + parentConnectionId: json['parentConnectionId'] as String?, + plexRestricted: json['restricted'] as bool? ?? false, + plexAdmin: json['admin'] as bool? ?? false, + plexProtected: (json['protected'] as bool?) ?? (json['hasPassword'] as bool? ?? false), + sortOrder: sortOrder, + createdAt: createdAt, + lastUsedAt: lastUsedAt, + ), + }; + } +} + +enum ProfileKind { + local, + plexHome; + + String get id => switch (this) { + ProfileKind.local => 'local', + ProfileKind.plexHome => 'plex_home', + }; + + static ProfileKind fromId(String id) => switch (id) { + 'local' => ProfileKind.local, + 'plex_home' => ProfileKind.plexHome, + _ => throw ArgumentError('Unknown ProfileKind id: $id'), + }; +} + +/// Salted SHA-256 of the PIN. The salt is fixed (per-app) — this is a +/// social-barrier hash, not real authentication. The threat model is +/// "kid bypassing parent's profile", not "adversary with device access". +const _pinSalt = 'plezy-app-profile-pin-v1'; + +String computePinHash(String rawPin) { + final digest = sha256.convert(utf8.encode('$_pinSalt:$rawPin')); + return digest.toString(); +} + +bool verifyPin(String rawPin, String hash) { + return computePinHash(rawPin) == hash; +} + +/// Deterministic id for a Plex Home profile so re-discovery is idempotent. +String plexHomeProfileId({required String accountConnectionId, required String homeUserUuid}) { + return 'plex-home-$accountConnectionId-$homeUserUuid'; +} + +/// Anchor on the trailing 36-char UUID — both `accountConnectionId` and +/// `homeUserUuid` may contain hyphens, so a `lastIndexOf('-')` would slice +/// inside the UUID itself. +final RegExp _trailingHomeUserUuidPattern = RegExp( + r'-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$', +); + +/// Inverse of [plexHomeProfileId]. Returns `null` if [id] doesn't match the +/// `plex-home-{accountConnectionId}-{homeUserUuid}` shape. +({String accountConnectionId, String homeUserUuid})? parsePlexHomeProfileId(String id) { + const prefix = 'plex-home-'; + if (!id.startsWith(prefix)) return null; + final rest = id.substring(prefix.length); + final match = _trailingHomeUserUuidPattern.firstMatch(rest); + if (match == null) return null; + final accountId = rest.substring(0, match.start); + if (accountId.isEmpty) return null; + return (accountConnectionId: accountId, homeUserUuid: match.group(1)!); +} diff --git a/lib/profiles/profile_activation.dart b/lib/profiles/profile_activation.dart new file mode 100644 index 00000000..1f4e1822 --- /dev/null +++ b/lib/profiles/profile_activation.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; +import '../screens/profile/pin_entry_dialog.dart'; +import '../services/plex_auth_service.dart'; +import 'active_profile_binder.dart'; +import 'active_profile_provider.dart'; +import 'plex_home_switch.dart'; +import 'profile.dart'; +import 'profile_connection.dart'; +import 'profile_connection_registry.dart'; + +/// Activate [profile] from a UI surface, prompting for the PIN when the +/// profile is protected. Returns `true` on successful activation, `false` +/// when the user cancelled the PIN dialog. Loops on wrong-PIN entries +/// until the user submits the right PIN or backs out. +/// +/// The retry loop uses the same shake-on-error pattern as Plex Home users +/// — see [showPinEntryDialog]. +/// +/// For [ProfileKind.plexHome] profiles whose `plexProtected` flag is set, +/// the PIN is validated up front via `/home/users/{uuid}/switch` so a +/// failed PIN never flips `_active`. The minted user-token is saved and +/// the profile is marked pre-verified on the binder, so it reuses the cached +/// token instead of re-prompting for the same PIN. +Future activateProfileWithPin(BuildContext context, Profile profile) async { + final active = context.read(); + + if (profile.isPlexHome) { + if (profile.plexProtected) { + final ok = await _preVerifyPlexHomePin(context, profile); + if (!ok) return false; + } + return active.activate(profile); + } + + if (!profile.isPinProtected) { + return active.activate(profile); + } + + String? errorMessage; + while (true) { + if (!context.mounted) return false; + final pin = await showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); + if (pin == null) return false; // user cancelled + final ok = await active.activate(profile, pin: pin); + if (ok) return true; + errorMessage = 'Incorrect PIN. Please try again.'; + } +} + +/// Validate [profile]'s PIN with Plex via `/home/users/{uuid}/switch`. On +/// success, persist the minted user-token and mark the profile as +/// pre-verified so [ActiveProfileBinder] reuses the cached token instead +/// of re-prompting. Returns `false` on cancel or a final failure (caller +/// must abort activation in that case). +/// +/// Returns `true` without doing anything when the profile lacks the +/// parent/uuid metadata or the parent connection is missing — the +/// binder's existing missing-metadata path will fire and silently +/// produce an empty bind, matching today's behavior. We don't want to +/// fail activation outright for users in unusual data states. +Future _preVerifyPlexHomePin(BuildContext context, Profile profile) async { + final parentId = profile.parentConnectionId; + final homeUuid = profile.plexHomeUserUuid; + if (parentId == null || homeUuid == null) return true; + + final connections = context.read(); + final all = await connections.list(); + PlexAccountConnection? account; + for (final c in all) { + if (c.id == parentId && c is PlexAccountConnection) { + account = c; + break; + } + } + if (account == null) return true; + + final auth = await PlexAuthService.create(); + try { + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: account.accountToken, + homeUserUuid: homeUuid, + requiresPin: true, + promptForPin: ({String? errorMessage}) async { + if (!context.mounted) return null; + return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); + }, + logLabel: profile.displayName, + ); + if (!result.succeeded) return false; + if (!context.mounted) return false; + final pcRegistry = context.read(); + await pcRegistry.upsert( + ProfileConnection( + profileId: profile.id, + connectionId: account.id, + userToken: result.userToken, + userIdentifier: homeUuid, + tokenAcquiredAt: DateTime.now(), + ), + ); + if (!context.mounted) return false; + context.read().markPlexHomePreVerified(profile.id); + return true; + } finally { + auth.dispose(); + } +} + +/// Verify [pin] against [profile]'s stored PIN hash *without* activating it. +/// Used by the borrow flow: we need to confirm the user knows the source +/// profile's PIN before letting them copy a connection out of it. +/// +/// Plex Home profiles can't be verified locally — their PIN lives on Plex's +/// servers. Callers should fall through to a real `/home/users/.../switch` +/// call instead. +bool verifyProfilePin(Profile profile, String pin) { + if (!profile.isLocal) return false; + final hash = profile.pinHash; + if (hash == null || hash.isEmpty) return true; + return verifyPin(pin, hash); +} diff --git a/lib/profiles/profile_avatar.dart b/lib/profiles/profile_avatar.dart new file mode 100644 index 00000000..8b3a8188 --- /dev/null +++ b/lib/profiles/profile_avatar.dart @@ -0,0 +1,88 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../utils/initials_palette.dart'; +import '../widgets/app_icon.dart'; +import 'profile.dart'; + +/// Round avatar for a [Profile]. Plex Home users with an `avatarThumbUrl` +/// render the network image; locals (and Plex Home users without a thumb) +/// fall back to the first initial on a deterministic colour. A small lock +/// badge overlays PIN-protected profiles. A neutral fill is used while the +/// active profile is still loading at app start. +class ProfileAvatar extends StatelessWidget { + final Profile? profile; + final double size; + final bool showLockBadge; + + const ProfileAvatar({super.key, required this.profile, this.size = 40, this.showLockBadge = true}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final p = profile; + final lockBadgeSize = size * 0.34; + + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + ClipOval( + child: SizedBox(width: size, height: size, child: _buildContent(theme, p)), + ), + if (showLockBadge && p != null && p.isPinProtected) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: lockBadgeSize, + height: lockBadgeSize, + alignment: Alignment.center, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + shape: BoxShape.circle, + border: Border.all(color: theme.colorScheme.surface, width: 1), + ), + child: AppIcon( + Symbols.lock_rounded, + fill: 1, + size: lockBadgeSize * 0.7, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + ); + } + + Widget _buildContent(ThemeData theme, Profile? p) { + if (p == null) { + return Container(color: theme.colorScheme.surfaceContainerHighest); + } + final thumb = p.avatarThumbUrl; + if (thumb != null && thumb.isNotEmpty) { + return CachedNetworkImage( + imageUrl: thumb, + fit: BoxFit.cover, + placeholder: (_, _) => _initialFallback(theme, p), + errorWidget: (_, _, _) => _initialFallback(theme, p), + ); + } + return _initialFallback(theme, p); + } + + Widget _initialFallback(ThemeData theme, Profile p) { + return Container( + color: colorForName(p.displayName, theme), + alignment: Alignment.center, + child: Text( + initialOf(p.displayName), + style: TextStyle(color: Colors.white, fontSize: size * 0.42, fontWeight: FontWeight.w600, height: 1.0), + ), + ); + } +} diff --git a/lib/profiles/profile_connection.dart b/lib/profiles/profile_connection.dart new file mode 100644 index 00000000..0b23c9a1 --- /dev/null +++ b/lib/profiles/profile_connection.dart @@ -0,0 +1,55 @@ +/// A binding between a [Profile] and a [Connection], carrying the +/// per-profile user-level token used when the profile is active. +/// +/// For Plex: [userToken] is a Plex Home-user token from +/// `/home/users/{uuid}/switch`; [userIdentifier] is the Home user UUID. +/// A `null` [userToken] is the lazy-fetch sentinel — the +/// `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. +class ProfileConnection { + final String profileId; + final String connectionId; + final String? userToken; + final String userIdentifier; + final bool isDefault; + final DateTime? tokenAcquiredAt; + final DateTime? lastUsedAt; + + const ProfileConnection({ + required this.profileId, + required this.connectionId, + this.userToken, + required this.userIdentifier, + this.isDefault = false, + this.tokenAcquiredAt, + this.lastUsedAt, + }); + + bool get hasToken => userToken != null && userToken!.isNotEmpty; + + ProfileConnection copyWith({ + String? profileId, + String? connectionId, + String? userToken, + bool clearUserToken = false, + String? userIdentifier, + bool? isDefault, + DateTime? tokenAcquiredAt, + bool clearTokenAcquiredAt = false, + DateTime? lastUsedAt, + bool clearLastUsedAt = false, + }) { + return ProfileConnection( + profileId: profileId ?? this.profileId, + connectionId: connectionId ?? this.connectionId, + userToken: clearUserToken ? null : (userToken ?? this.userToken), + userIdentifier: userIdentifier ?? this.userIdentifier, + isDefault: isDefault ?? this.isDefault, + tokenAcquiredAt: clearTokenAcquiredAt ? null : (tokenAcquiredAt ?? this.tokenAcquiredAt), + lastUsedAt: clearLastUsedAt ? null : (lastUsedAt ?? this.lastUsedAt), + ); + } +} diff --git a/lib/profiles/profile_connection_registry.dart b/lib/profiles/profile_connection_registry.dart new file mode 100644 index 00000000..9e5e999f --- /dev/null +++ b/lib/profiles/profile_connection_registry.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; + +import '../database/app_database.dart'; +import '../services/credential_vault.dart'; +import '../utils/app_logger.dart'; +import 'profile_connection.dart'; + +/// CRUD over the [ProfileConnections] join table. +/// +/// Mirrors [ConnectionRegistry] in shape: drift is the source of truth, +/// `watch*` streams changes, and a single [setDefault] enforces the +/// "exactly one default per profile" invariant. +class ProfileConnectionRegistry { + ProfileConnectionRegistry(this._db); + + final AppDatabase _db; + + Stream> watchAll() { + return _db.select(_db.profileConnections).watch().asyncMap((rows) async => Future.wait(rows.map(_rowToModel))); + } + + Stream> watchForProfile(String profileId) { + return (_db.select(_db.profileConnections) + ..where((t) => t.profileId.equals(profileId)) + ..orderBy(_orderingForProfile)) + .watch() + .asyncMap((rows) async => Future.wait(rows.map(_rowToModel))); + } + + Future> listForProfile(String profileId) async { + final rows = + await (_db.select(_db.profileConnections) + ..where((t) => t.profileId.equals(profileId)) + ..orderBy(_orderingForProfile)) + .get(); + return Future.wait(rows.map(_rowToModel)); + } + + static List get _orderingForProfile => [ + (t) => OrderingTerm.desc(t.isDefault), + (t) => OrderingTerm.asc(t.connectionId), + ]; + + Future> listForConnection(String connectionId) async { + final rows = await (_db.select(_db.profileConnections)..where((t) => t.connectionId.equals(connectionId))).get(); + return Future.wait(rows.map(_rowToModel)); + } + + Future> listAll() async { + final rows = await _db.select(_db.profileConnections).get(); + return Future.wait(rows.map(_rowToModel)); + } + + Future get(String profileId, String connectionId) async { + final row = await (_db.select( + _db.profileConnections, + )..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).getSingleOrNull(); + return row == null ? null : _rowToModel(row); + } + + /// Insert a new join row. If [makeDefault] is true, [pc.isDefault] is true, + /// or this is the first row for [pc.profileId], the row becomes the + /// default for that profile. + /// + /// Fast path: when no default-flip is requested, skips the transaction + /// (one cheap SELECT to detect first-row, then a single insert). + Future upsert(ProfileConnection pc, {bool makeDefault = false}) async { + final wantsDefault = makeDefault || pc.isDefault; + if (!wantsDefault) { + // Preserve the row's existing `isDefault` on update so token/metadata + // refreshes don't clobber the default flag. First-row inserts inherit + // default automatically. + final existing = await get(pc.profileId, pc.connectionId); + final bool isDefault; + if (existing != null) { + isDefault = existing.isDefault; + } else { + isDefault = !await _hasAnyForProfile(pc.profileId); + } + await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: isDefault)); + appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId}'); + return; + } + await _db.transaction(() async { + await (_db.update(_db.profileConnections)..where((t) => t.profileId.equals(pc.profileId))).write( + const ProfileConnectionsCompanion(isDefault: Value(false)), + ); + await _db.into(_db.profileConnections).insertOnConflictUpdate(await _companion(pc, isDefault: true)); + }); + appLogger.d('ProfileConnectionRegistry: upserted ${pc.profileId}/${pc.connectionId} (default)'); + } + + Future _hasAnyForProfile(String profileId) async { + final row = + await (_db.selectOnly(_db.profileConnections) + ..addColumns([_db.profileConnections.connectionId]) + ..where(_db.profileConnections.profileId.equals(profileId)) + ..limit(1)) + .getSingleOrNull(); + return row != null; + } + + Future _companion(ProfileConnection pc, {bool? isDefault}) async { + final protectedToken = pc.userToken == null ? '' : await CredentialVault.protect(pc.userToken!); + return ProfileConnectionsCompanion( + profileId: Value(pc.profileId), + connectionId: Value(pc.connectionId), + // Drift column is non-nullable with default `''`; map a null + // userToken (lazy-fetch sentinel) back to the empty-string default + // so existing rows and inserts share representation. + userToken: Value(protectedToken), + userIdentifier: Value(pc.userIdentifier), + isDefault: Value(isDefault ?? pc.isDefault), + tokenAcquiredAt: Value(pc.tokenAcquiredAt?.millisecondsSinceEpoch), + lastUsedAt: Value(pc.lastUsedAt?.millisecondsSinceEpoch), + ); + } + + /// Insert a new join row only if `(profileId, connectionId)` doesn't + /// already exist. Used by [ProfileSyncService] to surface new Plex Home + /// users without clobbering tokens cached by prior switches. + Future insertIfAbsent(ProfileConnection pc) async { + await _db.into(_db.profileConnections).insert(await _companion(pc), mode: InsertMode.insertOrIgnore); + } + + /// Cache the freshly-acquired user token (e.g. after a `/home/users/switch` + /// call). Updates `tokenAcquiredAt` to now. + Future recordToken(String profileId, String connectionId, String token) async { + await (_db.update( + _db.profileConnections, + )..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).write( + ProfileConnectionsCompanion( + userToken: Value(await CredentialVault.protect(token)), + tokenAcquiredAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + } + + /// Mark the row as recently used. + Future markUsed(String profileId, String connectionId) async { + await (_db.update(_db.profileConnections) + ..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))) + .write(ProfileConnectionsCompanion(lastUsedAt: Value(DateTime.now().millisecondsSinceEpoch))); + } + + Future remove(String profileId, String connectionId) async { + await (_db.delete( + _db.profileConnections, + )..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).go(); + // If we just removed the default, promote the oldest remaining row. + final remaining = await (_db.select(_db.profileConnections)..where((t) => t.profileId.equals(profileId))).get(); + if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) { + await (_db.update(_db.profileConnections) + ..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(remaining.first.connectionId))) + .write(const ProfileConnectionsCompanion(isDefault: Value(true))); + } + } + + /// Make [connectionId] the default for [profileId]. Clears the flag on + /// every other row for the same profile. + Future setDefault(String profileId, String connectionId) async { + await _db.transaction(() async { + await (_db.update( + _db.profileConnections, + )..where((t) => t.profileId.equals(profileId))).write(const ProfileConnectionsCompanion(isDefault: Value(false))); + await (_db.update(_db.profileConnections) + ..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))) + .write(const ProfileConnectionsCompanion(isDefault: Value(true))); + }); + } + + /// Remove every join row referencing [connectionId] (e.g. when a Connection + /// is deleted). Drift's referential integrity isn't enabled by default for + /// SQLite without `PRAGMA foreign_keys=ON`, so we cascade explicitly. + Future removeAllForConnection(String connectionId) async { + return await (_db.delete(_db.profileConnections)..where((t) => t.connectionId.equals(connectionId))).go(); + } + + /// Wipe every join row for [profileId] (e.g. when a Plex Home profile's + /// parent connection is removed). + Future removeAllForProfile(String profileId) async { + return await (_db.delete(_db.profileConnections)..where((t) => t.profileId.equals(profileId))).go(); + } + + /// Wipe the entire join table. Used by sign-out so a fresh sign-in starts + /// with no stale (profile, connection, token) rows. + Future clear() async { + await _db.delete(_db.profileConnections).go(); + } + + Future _rowToModel(ProfileConnectionRow row) async { + final hasPlaintextToken = row.userToken.isNotEmpty && !CredentialVault.isProtected(row.userToken); + final userToken = row.userToken.isEmpty ? null : await CredentialVault.reveal(row.userToken); + if (hasPlaintextToken) { + unawaited(recordToken(row.profileId, row.connectionId, userToken!)); + } + return ProfileConnection( + profileId: row.profileId, + connectionId: row.connectionId, + // Drift column is non-nullable with default `''`; the empty string is + // the on-disk lazy-fetch sentinel — surface it as null in the model. + userToken: userToken, + userIdentifier: row.userIdentifier, + isDefault: row.isDefault, + tokenAcquiredAt: row.tokenAcquiredAt == null ? null : DateTime.fromMillisecondsSinceEpoch(row.tokenAcquiredAt!), + lastUsedAt: row.lastUsedAt == null ? null : DateTime.fromMillisecondsSinceEpoch(row.lastUsedAt!), + ); + } +} diff --git a/lib/profiles/profile_merge.dart b/lib/profiles/profile_merge.dart new file mode 100644 index 00000000..953457eb --- /dev/null +++ b/lib/profiles/profile_merge.dart @@ -0,0 +1,33 @@ +import '../connection/connection.dart'; +import '../models/plex/plex_home_user.dart'; +import '../services/storage_service.dart'; +import 'profile.dart'; + +/// Merge local profiles with virtual Plex Home profiles. Each Plex Home +/// user becomes a virtual profile attached to its `connectionId`. Home +/// users whose connection isn't registered are dropped — their profile +/// can't be activated until the parent account is re-added. +List mergeLocalWithPlexHome({ + required List locals, + required Map> plexHomeByConnectionId, + required Map connectionsById, + StorageService? storage, +}) { + final out = [...locals]; + for (final entry in plexHomeByConnectionId.entries) { + final connectionId = entry.key; + if (!connectionsById.containsKey(connectionId)) continue; + for (final user in entry.value) { + out.add( + Profile.virtualPlexHome( + connectionId: connectionId, + homeUser: user, + lastUsedAt: storage?.getProfileLastUsed( + plexHomeProfileId(accountConnectionId: connectionId, homeUserUuid: user.uuid), + ), + ), + ); + } + } + return out; +} diff --git a/lib/profiles/profile_registry.dart b/lib/profiles/profile_registry.dart new file mode 100644 index 00000000..74c7a428 --- /dev/null +++ b/lib/profiles/profile_registry.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +import '../database/app_database.dart'; +import '../utils/app_logger.dart'; +import 'profile.dart'; + +/// CRUD over the persisted [Profiles] table — local profiles only. +/// +/// Plex Home users are NOT stored here; Plex owns those, and +/// [PlexHomeService] fetches them live and caches them in +/// [StorageService] for cold-start UX. UI surfaces should subscribe to +/// both this registry and [PlexHomeService] (typically via [ProfilesView] +/// or [ActiveProfileProvider]) to render the merged picker list. +class ProfileRegistry { + ProfileRegistry(this._db); + + final AppDatabase _db; + + Stream> watchProfiles() { + return (_db.select(_db.profiles) + ..where((t) => t.kind.equals(ProfileKind.local.id)) + ..orderBy([(t) => OrderingTerm.asc(t.sortOrder), (t) => OrderingTerm.asc(t.createdAt)])) + .watch() + .map((rows) => rows.map(_rowToProfile).whereType().toList()); + } + + Future> list() async { + final rows = + await (_db.select(_db.profiles) + ..where((t) => t.kind.equals(ProfileKind.local.id)) + ..orderBy([(t) => OrderingTerm.asc(t.sortOrder), (t) => OrderingTerm.asc(t.createdAt)])) + .get(); + return rows.map(_rowToProfile).whereType().toList(); + } + + Future get(String id) async { + final row = await (_db.select(_db.profiles)..where((t) => t.id.equals(id))).getSingleOrNull(); + return row == null ? null : _rowToProfile(row); + } + + Future upsert(Profile profile) async { + final row = ProfilesCompanion( + id: Value(profile.id), + kind: Value(profile.kind.id), + displayName: Value(profile.displayName), + avatarThumbUrl: Value(profile.avatarThumbUrl), + configJson: Value(jsonEncode(profile.toConfigJson())), + sortOrder: Value(profile.sortOrder), + createdAt: Value(profile.createdAt.millisecondsSinceEpoch), + lastUsedAt: Value(profile.lastUsedAt?.millisecondsSinceEpoch), + ); + await _db.into(_db.profiles).insertOnConflictUpdate(row); + appLogger.d('ProfileRegistry: upserted ${profile.kind.id}/${profile.id}'); + } + + Future remove(String id) async { + await (_db.delete(_db.profiles)..where((t) => t.id.equals(id))).go(); + appLogger.d('ProfileRegistry: removed $id'); + } + + Future markUsed(String id, DateTime at) async { + await (_db.update( + _db.profiles, + )..where((t) => t.id.equals(id))).write(ProfilesCompanion(lastUsedAt: Value(at.millisecondsSinceEpoch))); + } + + /// One-shot cleanup: drop any `kind='plex_home'` rows left over from the + /// pre-refactor data model. Plex Home users are no longer persisted. + Future dropAllPlexHomeRows() async { + return (_db.delete(_db.profiles)..where((t) => t.kind.equals(ProfileKind.plexHome.id))).go(); + } + + Future reorder(List idsInOrder) async { + await _db.transaction(() async { + for (var i = 0; i < idsInOrder.length; i++) { + await (_db.update( + _db.profiles, + )..where((t) => t.id.equals(idsInOrder[i]))).write(ProfilesCompanion(sortOrder: Value(i))); + } + }); + } + + Future clear() async { + await _db.delete(_db.profiles).go(); + } + + Profile? _rowToProfile(ProfileRow row) { + try { + final json = jsonDecode(row.configJson) as Map; + return Profile.fromRow( + id: row.id, + kind: row.kind, + displayName: row.displayName, + avatarThumbUrl: row.avatarThumbUrl, + json: json, + sortOrder: row.sortOrder, + createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt), + lastUsedAt: row.lastUsedAt == null ? null : DateTime.fromMillisecondsSinceEpoch(row.lastUsedAt!), + ); + } catch (e, st) { + appLogger.e('ProfileRegistry: failed to decode profile ${row.id}', error: e, stackTrace: st); + return null; + } + } +} diff --git a/lib/profiles/profiles_view.dart b/lib/profiles/profiles_view.dart new file mode 100644 index 00000000..c5d24ab2 --- /dev/null +++ b/lib/profiles/profiles_view.dart @@ -0,0 +1,158 @@ +import 'dart:async'; + +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; +import '../models/plex/plex_home_user.dart'; +import '../services/storage_service.dart'; +import 'plex_home_service.dart'; +import 'profile.dart'; +import 'profile_connection.dart'; +import 'profile_connection_registry.dart'; +import 'profile_merge.dart'; +import 'profile_registry.dart'; + +/// Snapshot for picker / manage-profiles UIs: every visible profile +/// (local rows from [ProfileRegistry] + virtual Plex Home profiles built +/// from [PlexHomeService]'s live cache) plus the data needed to render +/// per-profile connection chips. +class ProfilesView { + final List profiles; + + /// Per-profile borrowed connections. Does **not** include the Plex Home + /// parent — that's implicit via [Profile.parentConnectionId]. Plex Home + /// profiles can have entries here too (e.g. borrowed Jellyfin servers). + final Map> connectionsByProfile; + + final Map connectionsById; + + const ProfilesView({required this.profiles, required this.connectionsByProfile, required this.connectionsById}); + + static const empty = ProfilesView(profiles: [], connectionsByProfile: {}, connectionsById: {}); + + int countFor(Profile profile) { + if (profile.isPlexHome) return profile.parentConnectionId == null ? 0 : 1; + return connectionsByProfile[profile.id]?.length ?? 0; + } +} + +/// Join-table rows that should be shown as explicit, user-manageable +/// connections for [profile]. +/// +/// Plex Home profiles own their parent account implicitly through +/// [Profile.parentConnectionId]. A parent [ProfileConnection] row may still +/// exist as a token cache, but UI should not render it as a removable +/// borrowed connection. +List visibleProfileConnections(Profile profile, List pcs) { + final parentId = profile.parentConnectionId; + if (!profile.isPlexHome || parentId == null) return pcs; + return pcs.where((pc) => pc.connectionId != parentId).toList(); +} + +/// Combine [ProfileRegistry], [ProfileConnectionRegistry], +/// [ConnectionRegistry], and [PlexHomeService] into a single stream. +/// Plex Home profiles are constructed on the fly from the live cache; they +/// are never persisted as Profile rows. +Stream watchProfilesView({ + required ProfileRegistry profiles, + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, + required PlexHomeService plexHome, + StorageService? storage, +}) { + return _combineLatest4< + List, + List, + List, + Map>, + ProfilesView + >( + profiles.watchProfiles(), + profileConnections.watchAll(), + connections.watchConnections(), + plexHome.stream, + (locals, pcs, conns, homes) => _build(locals: locals, pcs: pcs, conns: conns, homes: homes, storage: storage), + ); +} + +ProfilesView _build({ + required List locals, + required List pcs, + required List conns, + required Map> homes, + required StorageService? storage, +}) { + final connectionsById = {for (final c in conns) c.id: c}; + final all = mergeLocalWithPlexHome( + locals: locals, + plexHomeByConnectionId: homes, + connectionsById: connectionsById, + storage: storage, + ); + return ProfilesView(profiles: all, connectionsByProfile: _groupByProfile(pcs), connectionsById: connectionsById); +} + +Map> _groupByProfile(List pcs) { + final out = >{}; + for (final pc in pcs) { + out.putIfAbsent(pc.profileId, () => []).add(pc); + } + return out; +} + +/// Lightweight `combineLatest4` — emits the combined value once each input +/// has produced a value, then on every subsequent tick from any input. +Stream _combineLatest4( + Stream a, + Stream b, + Stream c, + Stream d, + R Function(A, B, C, D) combine, +) { + late StreamController controller; + StreamSubscription? subA; + StreamSubscription? subB; + StreamSubscription? subC; + StreamSubscription? subD; + A? lastA; + B? lastB; + C? lastC; + D? lastD; + var hasA = false, hasB = false, hasC = false, hasD = false; + + void emit() { + if (hasA && hasB && hasC && hasD) controller.add(combine(lastA as A, lastB as B, lastC as C, lastD as D)); + } + + controller = StreamController( + onListen: () { + subA = a.listen((v) { + lastA = v; + hasA = true; + emit(); + }, onError: controller.addError); + subB = b.listen((v) { + lastB = v; + hasB = true; + emit(); + }, onError: controller.addError); + subC = c.listen((v) { + lastC = v; + hasC = true; + emit(); + }, onError: controller.addError); + subD = d.listen((v) { + lastD = v; + hasD = true; + emit(); + }, onError: controller.addError); + }, + onCancel: () async { + await subA?.cancel(); + await subB?.cancel(); + await subC?.cancel(); + await subD?.cancel(); + await controller.close(); + }, + ); + return controller.stream; +} diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index beb186a4..29a56a1a 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -4,19 +4,26 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; import '../models/companion_remote/remote_command.dart'; import '../models/companion_remote/remote_session.dart'; -import '../models/plex_home.dart'; +import '../models/plex/plex_home.dart'; +import '../profiles/active_plex_identity.dart'; +import '../profiles/active_profile_provider.dart'; +import '../profiles/profile.dart'; +import '../profiles/profile_connection_registry.dart'; import '../services/companion_remote/companion_remote_peer_service.dart'; import '../services/companion_remote/lan_discovery_service.dart'; +import '../services/companion_remote/remote_auth_context.dart'; import '../services/companion_remote/remote_auth_service.dart'; -import '../services/storage_service.dart'; import '../utils/app_logger.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; export '../services/companion_remote/lan_discovery_service.dart' show DiscoveredHost; typedef CommandReceivedCallback = void Function(RemoteCommand command); +typedef PlexHomeResolver = Future Function(String connectionId); class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin { RemoteSession? _session; @@ -35,13 +42,11 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin // Reconnection context (only hostAddresses and hostClientId are connection-specific) List? _lastHostAddresses; String? _lastHostClientId; + String? _lastAuthContextId; // Crypto context (derived in memory, never persisted) - List? _homeSecret; - List? _discoveryKey; - String? _clientIdentifier; - String? _userUUID; - List? _homeUserUUIDs; + List _authContexts = const []; + String? _cryptoProfileId; int get reconnectAttempts => _reconnectAttempts; @@ -101,21 +106,48 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin safeNotifyListeners(); } - /// Initialize crypto context from Plex home data. - /// Must be called before startHostServer or connectToDiscoveredHost. - Future initializeCrypto(PlexHome? home, StorageService storage) async { + /// Initialize crypto context from Plex home data plus the active profile's + /// connection. The clientIdentifier is the parent Plex account's + /// `clientIdentifier` (used as the LAN device id) and the userUUID is the + /// active home user's uuid (used to scope per-user LAN traffic). + Future initializeCrypto({ + required PlexHome? home, + required PlexAccountConnection? account, + required Profile? activeProfile, + String? activeUserUuid, + }) async { if (home == null || home.adminUser == null) { appLogger.w('CompanionRemote: Cannot init crypto — no home data'); return false; } + if (account == null) { + appLogger.w('CompanionRemote: Cannot init crypto — no Plex account'); + return false; + } try { final auth = RemoteAuthService.instance; - _homeSecret = await auth.deriveHomeSecretFromHome(home); - _discoveryKey = await auth.deriveDiscoveryKey(_homeSecret!); - _clientIdentifier = storage.getClientIdentifier(); - _userUUID = storage.getCurrentUserUUID(); - _homeUserUUIDs = home.users.map((u) => u.uuid).toList(); + final homeSecret = await auth.deriveHomeSecretFromHome(home); + final discoveryKey = await auth.deriveDiscoveryKey(homeSecret); + final userUuid = activeUserUuid ?? activeProfile?.plexHomeUserUuid ?? home.adminUser!.uuid; + final allowedUserUuids = { + for (final user in home.users) + if (user.uuid.isNotEmpty) user.uuid, + if (userUuid.isNotEmpty) userUuid, + }.toList(); + _authContexts = [ + RemoteAuthContext( + id: auth.computeAuthContextId(homeSecret), + backend: 'plex', + connectionId: account.id, + homeSecret: homeSecret, + discoveryKey: discoveryKey, + clientIdentifier: account.clientIdentifier.isNotEmpty ? account.clientIdentifier : account.id, + userUuid: userUuid, + allowedUserUuids: allowedUserUuids, + ), + ]; + _cryptoProfileId = activeProfile?.id; appLogger.d('CompanionRemote: Crypto context initialized'); return true; @@ -125,16 +157,292 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin } } - bool get isCryptoReady => _homeSecret != null && _discoveryKey != null && _clientIdentifier != null; + Future initializeJellyfinCrypto({ + required JellyfinConnection connection, + required Profile? activeProfile, + }) async { + if (connection.accessToken.isEmpty || connection.userId.isEmpty || connection.serverMachineId.isEmpty) { + appLogger.w('CompanionRemote: Cannot init Jellyfin crypto — incomplete connection'); + return false; + } - /// Convenience: ensure crypto is initialized using providers from context. - /// Returns true if crypto is ready (already initialized or just initialized). - Future ensureCryptoReady(PlexHome? home) async { - if (isCryptoReady) return true; - final storage = await StorageService.getInstance(); - return initializeCrypto(home, storage); + try { + final auth = RemoteAuthService.instance; + final homeSecret = await auth.deriveJellyfinSecret( + serverMachineId: connection.serverMachineId, + userId: connection.userId, + ); + final discoveryKey = await auth.deriveDiscoveryKey(homeSecret); + _authContexts = [ + RemoteAuthContext( + id: auth.computeAuthContextId(homeSecret), + backend: 'jellyfin', + connectionId: connection.id, + homeSecret: homeSecret, + discoveryKey: discoveryKey, + clientIdentifier: connection.deviceId.isNotEmpty ? connection.deviceId : connection.id, + userUuid: connection.userId, + allowedUserUuids: [connection.userId], + ), + ]; + _cryptoProfileId = activeProfile?.id; + + appLogger.d('CompanionRemote: Jellyfin crypto context initialized'); + return true; + } catch (e) { + appLogger.e('CompanionRemote: Failed to init Jellyfin crypto', error: e); + return false; + } } + RemoteAuthContext? get _primaryAuthContext => _authContexts.isEmpty ? null : _authContexts.first; + + bool get isCryptoReady => _authContexts.isNotEmpty; + + /// Convenience: ensure crypto is initialized for every remote identity + /// attached to the active profile. + /// Returns true if crypto is ready (already initialized or just initialized). + Future ensureCryptoReady( + PlexHome? home, { + required ConnectionRegistry connections, + required ActiveProfileProvider activeProfile, + required ProfileConnectionRegistry profileConnections, + ActivePlexIdentity? identity, + PlexAccountConnection? account, + PlexHomeResolver? plexHomeForConnection, + }) async { + await activeProfile.initialize(); + final profile = activeProfile.active; + if (profile == null) { + appLogger.w('CompanionRemote: Cannot init crypto — no active profile'); + return false; + } + + final nextContexts = await _buildAuthContextsForProfile( + profile: profile, + connections: connections, + profileConnections: profileConnections, + fallbackHome: home, + identity: identity, + preferredAccount: account, + plexHomeForConnection: plexHomeForConnection, + ); + + if (nextContexts.isEmpty) { + if (isCryptoReady) await _prepareForCryptoRebuild(); + appLogger.w('CompanionRemote: Cannot init crypto — no active profile identities'); + return false; + } + + if (_cryptoProfileId == profile.id && _sameAuthContexts(_authContexts, nextContexts)) { + return true; + } + + await _prepareForCryptoRebuild(); + _authContexts = nextContexts; + _cryptoProfileId = profile.id; + appLogger.d('CompanionRemote: Crypto contexts initialized (${nextContexts.length})'); + return true; + } + + Future> _buildAuthContextsForProfile({ + required Profile profile, + required ConnectionRegistry connections, + required ProfileConnectionRegistry profileConnections, + required PlexHome? fallbackHome, + required ActivePlexIdentity? identity, + required PlexAccountConnection? preferredAccount, + required PlexHomeResolver? plexHomeForConnection, + }) async { + final contexts = []; + final seen = {}; + final all = await connections.list(); + final byId = {for (final c in all) c.id: c}; + + Future resolvePlexHome(PlexAccountConnection account) async { + if (fallbackHome != null && + (identity?.account.id == account.id || preferredAccount?.id == account.id || plexHomeForConnection == null)) { + return fallbackHome; + } + return plexHomeForConnection?.call(account.id); + } + + void addContext(RemoteAuthContext? context) { + if (context == null || seen.contains(context.id)) return; + contexts.add(context); + seen.add(context.id); + } + + Future addConnection(Connection connection, {String? userUuid}) async { + switch (connection) { + case PlexAccountConnection(): + addContext( + await _createPlexAuthContext( + account: connection, + home: await resolvePlexHome(connection), + activeProfile: profile, + userUuid: userUuid, + ), + ); + case JellyfinConnection(): + addContext(await _createJellyfinAuthContext(connection: connection)); + } + } + + if (profile.parentConnectionId case final parentId?) { + final parent = preferredAccount?.id == parentId + ? preferredAccount + : (identity?.account.id == parentId ? identity?.account : byId[parentId]); + if (parent is PlexAccountConnection) { + await addConnection(parent, userUuid: profile.plexHomeUserUuid); + } + } + + final pcs = await profileConnections.listForProfile(profile.id); + for (final pc in pcs) { + final connection = byId[pc.connectionId]; + if (connection == null) continue; + await addConnection(connection, userUuid: pc.userIdentifier.isEmpty ? null : pc.userIdentifier); + } + + return contexts; + } + + Future _createPlexAuthContext({ + required PlexAccountConnection account, + required PlexHome? home, + required Profile activeProfile, + required String? userUuid, + }) async { + if (home == null || home.adminUser == null) { + appLogger.w('CompanionRemote: Skipping Plex remote identity — no home data for ${account.id}'); + return null; + } + + final auth = RemoteAuthService.instance; + final homeSecret = await auth.deriveHomeSecretFromHome(home); + final resolvedUserUuid = userUuid != null && userUuid.isNotEmpty + ? userUuid + : (activeProfile.plexHomeUserUuid != null && activeProfile.plexHomeUserUuid!.isNotEmpty + ? activeProfile.plexHomeUserUuid! + : home.adminUser!.uuid); + final allowedUserUuids = { + for (final user in home.users) + if (user.uuid.isNotEmpty) user.uuid, + if (resolvedUserUuid.isNotEmpty) resolvedUserUuid, + }.toList(); + + return RemoteAuthContext( + id: auth.computeAuthContextId(homeSecret), + backend: 'plex', + connectionId: account.id, + homeSecret: homeSecret, + discoveryKey: await auth.deriveDiscoveryKey(homeSecret), + clientIdentifier: account.clientIdentifier.isNotEmpty ? account.clientIdentifier : account.id, + userUuid: resolvedUserUuid, + allowedUserUuids: allowedUserUuids, + ); + } + + Future _createJellyfinAuthContext({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}'); + return null; + } + + final auth = RemoteAuthService.instance; + final homeSecret = await auth.deriveJellyfinSecret( + serverMachineId: connection.serverMachineId, + userId: connection.userId, + ); + return RemoteAuthContext( + id: auth.computeAuthContextId(homeSecret), + backend: 'jellyfin', + connectionId: connection.id, + homeSecret: homeSecret, + discoveryKey: await auth.deriveDiscoveryKey(homeSecret), + clientIdentifier: connection.deviceId.isNotEmpty ? connection.deviceId : connection.id, + userUuid: connection.userId, + allowedUserUuids: [connection.userId], + ); + } + + bool _sameAuthContexts(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + final left = a[i]; + final right = b[i]; + if (left.id != right.id || + left.backend != right.backend || + left.connectionId != right.connectionId || + left.clientIdentifier != right.clientIdentifier || + left.userUuid != right.userUuid || + !_sameStrings(left.allowedUserUuids, right.allowedUserUuids)) { + return false; + } + } + return true; + } + + bool _sameStrings(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + RemoteAuthContext? _authContextForId(String? id) { + if (id == null || id.isEmpty) return null; + for (final context in _authContexts) { + if (context.id == id) return context; + } + return null; + } + + Future _prepareForCryptoRebuild() async { + if (isInSession || isHostServerRunning) { + await stopHostServer(); + } else { + stopDiscovery(); + _cleanupSubscriptions(); + } + _clearCryptoContext(); + } + + void _clearCryptoContext() { + _authContexts = const []; + _cryptoProfileId = null; + } + + /// Fully tear down network/session state and forget derived crypto material. + /// Used by logout so an app-level provider surviving route replacement does + /// not keep broadcasting with the previous Plex Home identity. + Future resetForLogout() async { + _reconnectTimer?.cancel(); + _reconnectAttempts = 0; + _lastHostAddresses = null; + _lastHostClientId = null; + _lastAuthContextId = null; + await stopHostServer(); + stopDiscovery(); + _clearCryptoContext(); + RemoteAuthService.instance.clearCache(); + safeNotifyListeners(); + } + + @visibleForTesting + String? get debugCryptoConnectionId => _primaryAuthContext?.connectionId; + + @visibleForTesting + String? get debugCryptoProfileId => _cryptoProfileId; + + @visibleForTesting + String? get debugCryptoUserUuid => _primaryAuthContext?.userUuid; + + @visibleForTesting + List get debugCryptoConnectionIds => _authContexts.map((context) => context.connectionId).toList(); + // ── Host Server ── /// Start the host server and begin LAN broadcasting. Idempotent. @@ -151,13 +459,8 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin _setupPeerServiceListeners(); try { - final result = await _peerService!.createSession( - _deviceName, - _platform, - _homeSecret!, - _clientIdentifier!, - _homeUserUUIDs!, - ); + final contexts = List.unmodifiable(_authContexts); + final result = await _peerService!.createSessionForContexts(_deviceName, _platform, contexts); _session = RemoteSession(role: RemoteSessionRole.host, status: RemoteSessionStatus.connected); safeNotifyListeners(); @@ -165,11 +468,10 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin // Start LAN discovery broadcasting _discoveryService ??= LanDiscoveryService(); final localIps = result.addresses.map((a) => a.split(':').first).toList(); - await _discoveryService!.startBroadcasting( - discoveryKey: _discoveryKey!, + await _discoveryService!.startBroadcastingForContexts( + contexts: contexts, deviceName: _deviceName, platform: _platform, - clientId: _clientIdentifier!, wsPort: result.port, ips: localIps, ); @@ -213,7 +515,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin } _discoveryService ??= LanDiscoveryService(); - return _discoveryService!.startListening(discoveryKey: _discoveryKey!); + return _discoveryService!.startListeningForContexts(_authContexts); } /// Stop listening for host beacons. @@ -226,11 +528,16 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin if (!isCryptoReady) { throw StateError('Crypto not initialized'); } + final authContext = _authContextForId(host.authContextId); + if (authContext == null) { + throw StateError('Matching auth context is no longer available'); + } await leaveSession(); _lastHostAddresses = host.addresses; _lastHostClientId = host.clientId; + _lastAuthContextId = authContext.id; appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}'); @@ -241,16 +548,17 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin safeNotifyListeners(); try { - final winner = await _peerService!.joinSessionRacing( + final winner = await _peerService!.joinSessionRacingWithContexts( _deviceName, _platform, host.addresses, - _homeSecret!, - host.clientId, - _userUUID!, - _clientIdentifier!, + _authContexts, + authContextId: authContext.id, + expectedHostClientId: host.clientId, ); _lastHostAddresses = [winner]; + _lastAuthContextId = _peerService!.selectedAuthContextId ?? authContext.id; + _lastHostClientId = _peerService!.selectedHostClientId ?? host.clientId; _session = _session?.copyWith(status: RemoteSessionStatus.connected); safeNotifyListeners(); @@ -273,6 +581,7 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin _lastHostAddresses = [hostAddress]; _lastHostClientId = ''; + _lastAuthContextId = null; appLogger.d('CompanionRemote: Connecting to manual host $hostAddress'); @@ -283,15 +592,9 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin safeNotifyListeners(); try { - await _peerService!.joinSession( - _deviceName, - _platform, - hostAddress, - _homeSecret!, - '', // Empty hostClientId — accept any host in same home - _userUUID!, - _clientIdentifier!, - ); + await _peerService!.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts); + _lastAuthContextId = _peerService!.selectedAuthContextId; + _lastHostClientId = _peerService!.selectedHostClientId ?? ''; _session = _session?.copyWith(status: RemoteSessionStatus.connected); safeNotifyListeners(); @@ -453,15 +756,17 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin _setupPeerServiceListeners(); } - await _peerService!.joinSession( + final authContextId = _authContextForId(_lastAuthContextId)?.id; + await _peerService!.joinSessionWithContexts( _deviceName, _platform, _lastHostAddresses!.first, - _homeSecret!, - _lastHostClientId ?? '', - _userUUID!, - _clientIdentifier!, + _authContexts, + authContextId: authContextId, + expectedHostClientId: _lastHostClientId ?? '', ); + _lastAuthContextId = _peerService!.selectedAuthContextId ?? authContextId; + _lastHostClientId = _peerService!.selectedHostClientId ?? _lastHostClientId; _session = _session?.copyWith(status: RemoteSessionStatus.connected, clearErrorMessage: true); _reconnectAttempts = 0; diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 829e39fd..12ee513e 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -1,23 +1,28 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:plezy/utils/content_utils.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_kind.dart'; +import '../media/media_version.dart'; import '../models/download_models.dart'; -import '../models/plex_media_version.dart'; -import '../models/plex_metadata.dart'; import '../utils/download_version_utils.dart'; import '../database/app_database.dart'; +import '../database/download_operations.dart'; import '../services/download_manager_service.dart'; +import '../services/api_cache.dart'; import '../services/download_storage_service.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; import '../services/storage_service.dart'; -import '../services/plex_api_cache.dart'; -import '../services/plex_client.dart'; +import '../media/media_server_client.dart'; import '../services/sync_rule_executor.dart'; import '../utils/app_logger.dart'; +import '../utils/deletion_notifier.dart'; import '../utils/episode_collection.dart'; import '../utils/global_key_utils.dart'; +import '../utils/watch_state_notifier.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; /// Filter mode for batch downloads (shows/seasons). @@ -46,13 +51,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final SyncRuleExecutor _syncRuleExecutor; StreamSubscription? _progressSubscription; StreamSubscription? _deletionProgressSubscription; + StreamSubscription? _watchStateSubscription; late final Future _initFuture; - // Track download progress by globalKey (serverId:ratingKey) + // Track download progress by public globalKey (serverId:ratingKey). + // Downloads are shared across profiles/users; scoped Jellyfin state lives in + // watch actions, cache namespaces, and sync-rule ownership. final Map _downloads = {}; // Store metadata for display - final Map _metadata = {}; + final Map _metadata = {}; // Store Plex thumb paths for offline display (actual file path computed from hash) final Map _artworkPaths = {}; @@ -60,6 +68,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Track items currently being queued (building download queue) final Set _queueing = {}; + // Public download keys owned by the active profile. Physical download rows + // stay app-wide; this set controls profile-visible state. + final Set _ownedDownloadKeys = {}; + // Track items currently being deleted with progress final Map _deletionProgress = {}; @@ -67,9 +79,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Key: globalKey (serverId:ratingKey), Value: total episode count final Map _totalEpisodeCounts = {}; - // Persistent sync rules: globalKey -> SyncRuleItem + // Persistent sync rules keyed by profile-scoped globalKey + // (profileId|serverId:ratingKey). Downloads remain public/shared. final Map _syncRules = {}; + String? _activeProfileId; + OfflineModeSource? _offlineSource; DownloadProvider({required DownloadManagerService downloadManager, required AppDatabase database}) @@ -82,6 +97,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Listen to deletion progress updates _deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate); + // Keep cached metadata fresh when items get marked watched/unwatched anywhere + // in the app, so re-entering a screen reflects the latest state. + _watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged); + // Load persisted downloads from database _initFuture = _loadPersistedDownloads(); } @@ -89,16 +108,21 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Test-only constructor that skips the heavy initial load (artwork dir, /// pinned-metadata bulk fetch, episode counts). Only sync rules are loaded /// from the database. Use this in tests that exercise the provider's public - /// database-backed API without mocking [PlexApiCache], [DownloadStorageService], + /// database-backed API without mocking [DownloadStorageService], /// or path_provider. @visibleForTesting - DownloadProvider.forTesting({required DownloadManagerService downloadManager, required AppDatabase database}) - : _downloadManager = downloadManager, - _database = database, - _syncRuleExecutor = SyncRuleExecutor(database: database) { + DownloadProvider.forTesting({ + required DownloadManagerService downloadManager, + required AppDatabase database, + String? activeProfileId = 'test-profile', + }) : _downloadManager = downloadManager, + _database = database, + _syncRuleExecutor = SyncRuleExecutor(database: database), + _activeProfileId = activeProfileId { _progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate); _deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate); - _initFuture = _loadSyncRules(); + _watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged); + _initFuture = _loadProfileScopedState(); } /// Inject the offline-mode source so queueing paths can short-circuit when @@ -113,6 +137,137 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Ensures persisted downloads have been loaded from disk. Future ensureInitialized() => _initFuture; + /// Switch the visible sync-rule scope to [profileId]. Physical downloads are + /// intentionally not reloaded because they are shared across profiles. + void setActiveProfileId(String? profileId) { + if (_activeProfileId == profileId) return; + _activeProfileId = profileId; + unawaited(_reloadProfileScopedStateForActiveProfile()); + } + + Future _reloadProfileScopedStateForActiveProfile() async { + final targetProfileId = _activeProfileId; + await _initFuture; + if (_activeProfileId != targetProfileId) return; + await _loadProfileScopedState(); + if (_activeProfileId == targetProfileId) { + safeNotifyListeners(); + } + } + + String _requireActiveProfileId() { + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) { + throw StateError('Cannot create, update, or claim downloads without an active profile'); + } + return profileId; + } + + bool _ownsDownloadKey(String globalKey) => _ownedDownloadKeys.contains(globalKey); + + bool _ownsProgressEntry(MapEntry entry) => _ownsDownloadKey(entry.key); + + Future _claimDownloadForActiveProfile(String globalKey) async { + final profileId = _requireActiveProfileId(); + if (_ownedDownloadKeys.contains(globalKey)) return false; + await _database.addDownloadOwner(profileId: profileId, globalKey: globalKey); + if (_activeProfileId != profileId) return false; + _ownedDownloadKeys.add(globalKey); + return true; + } + + Future _releaseDownloadForActiveProfile(String globalKey) async { + final profileId = _requireActiveProfileId(); + if (!_ownedDownloadKeys.contains(globalKey)) return false; + await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey); + if (_activeProfileId == profileId) { + _ownedDownloadKeys.remove(globalKey); + } + return true; + } + + /// Remove all ownership rows for a deleted profile and delete physical files + /// that no remaining valid profile owns. + Future deleteDownloadsForProfile(String profileId) async { + await _releaseDownloadsForProfileWhere(profileId, (_) => true); + } + + /// Remove ownership rows for [profileId] that belong to the removed + /// connection's public server ids. Physical files stay when any other valid + /// owner remains. + Future releaseDownloadsForProfileServers(String profileId, Set serverIds) async { + if (serverIds.isEmpty) return; + await _releaseDownloadsForProfileWhere(profileId, (globalKey) { + final parsed = parseGlobalKey(globalKey); + return parsed != null && serverIds.contains(parsed.serverId); + }); + } + + Future _releaseDownloadsForProfileWhere(String profileId, bool Function(String globalKey) shouldRelease) async { + if (profileId.isEmpty) return; + final ownedKeys = await _database.getDownloadOwnerKeysForProfile(profileId); + var changed = false; + for (final globalKey in ownedKeys) { + if (!shouldRelease(globalKey)) continue; + final meta = _metadata[globalKey]; + await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey); + if (_activeProfileId == profileId) { + _ownedDownloadKeys.remove(globalKey); + } + if (await _database.hasDownloadOwner(globalKey)) { + changed = true; + continue; + } + + await _downloadManager.deleteDownload(globalKey); + _downloads.remove(globalKey); + _metadata.remove(globalKey); + _artworkPaths.remove(globalKey); + _totalEpisodeCounts.remove(globalKey); + if (meta != null) { + DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true); + } + changed = true; + } + if (changed) safeNotifyListeners(); + } + + Future _loadProfileScopedState() async { + await _loadDownloadOwners(); + await _loadSyncRules(); + } + + /// Test-only seam to populate internal state maps without driving the full + /// queue/progress pipeline. Intended for tests that exercise functions whose + /// behavior depends on pre-existing state (e.g. cancelDownload artwork + /// cleanup, _loadPersistedDownloads transient-state clearing). + @visibleForTesting + void debugSeedState({ + Map? downloads, + Map? metadata, + Map? artwork, + Map? episodeCounts, + Set? queueing, + Map? deletionProgress, + Set? ownedDownloadKeys, + }) { + if (downloads != null) _downloads.addAll(downloads); + if (metadata != null) _metadata.addAll(metadata); + if (artwork != null) _artworkPaths.addAll(artwork); + if (episodeCounts != null) _totalEpisodeCounts.addAll(episodeCounts); + if (queueing != null) _queueing.addAll(queueing); + if (deletionProgress != null) _deletionProgress.addAll(deletionProgress); + if (ownedDownloadKeys != null) { + _ownedDownloadKeys.addAll(ownedDownloadKeys); + } else if (downloads != null) { + _ownedDownloadKeys.addAll(downloads.keys); + } + } + + /// Test-only inspector for `_totalEpisodeCounts` (no public getter today). + @visibleForTesting + int? totalEpisodeCountFor(String globalKey) => _totalEpisodeCounts[globalKey]; + /// Load all persisted downloads and metadata from the database/cache Future _loadPersistedDownloads() async { try { @@ -125,9 +280,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _artworkPaths.clear(); _metadata.clear(); _totalEpisodeCounts.clear(); + _queueing.clear(); + _deletionProgress.clear(); + _ownedDownloadKeys.clear(); final storageService = DownloadStorageService.instance; - final apiCache = PlexApiCache.instance; // Initialize artwork directory path for synchronous access await storageService.getArtworkDirectory(); @@ -135,8 +292,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Load all downloads from database final downloads = await _downloadManager.getAllDownloads(); - // Bulk-load all pinned metadata in a single query instead of per-item DB calls - final allMetadata = await apiCache.getAllPinnedMetadata(); + // Bulk-load all pinned metadata across both backends in a single pass + // instead of per-item DB calls. + final allMetadata = await _downloadManager.getAllPinnedMetadata(preferActiveScope: true); for (final item in downloads) { _downloads[item.globalKey] = DownloadProgress( @@ -148,18 +306,24 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin errorMessage: item.errorMessage, ); - // Store Plex thumb path reference (file path computed from hash when needed) _artworkPaths[item.globalKey] = DownloadedArtwork(thumbPath: item.thumbPath); // Look up metadata from the bulk-loaded map (O(1) instead of DB query per item) - // Falls back to individual query for any unpinned entries (e.g., legacy data) - final metadata = allMetadata[item.globalKey] ?? await apiCache.getMetadata(item.serverId, item.ratingKey); - if (metadata != null) { - _metadata[item.globalKey] = metadata; + // Falls back to individual query for any unpinned entries (e.g., legacy data). + // The fallback dispatches by backend. + final cached = + allMetadata[item.globalKey] ?? + await _downloadManager.lookupMetadata(item.serverId, item.ratingKey, preferActiveScope: true); + if (cached != null) { + _metadata[item.globalKey] = cached; // For episodes, also load parent (show and season) metadata from the same map - if (metadata.isEpisode) { - _loadParentMetadataFromMap(metadata, allMetadata); + if (cached.isEpisode) { + _loadParentMetadataFromMap( + cached, + allMetadata, + clientScopeId: _downloadManager.activeClientScopeIdForServer(item.serverId) ?? item.clientScopeId, + ); } } } @@ -168,7 +332,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin await _loadTotalEpisodeCounts(); // Load sync rules from database - await _loadSyncRules(); + await _loadProfileScopedState(); + + // Apply queued offline watch actions on top of the server-time metadata + // we just loaded, so re-entries reflect locally-marked watched/unwatched + // state from previous sessions until those actions sync to the server. + await _applyOfflineWatchOverlay(); appLogger.i( 'Loaded ${_downloads.length} downloads, ${_metadata.length} metadata entries, ' @@ -180,6 +349,58 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } + /// Patch `_metadata` viewCount/viewOffsetMs from queued OfflineWatchProgress + /// actions. Idempotent and cheap (one batched DB read). + Future _applyOfflineWatchOverlay() async { + if (_metadata.isEmpty) return; + try { + final keys = _metadata.keys.toSet(); + final scopes = {}; + for (final key in keys) { + scopes[key] = await _offlineWatchScopeForGlobalKey(key); + } + final profileId = _activeProfileId; + final actions = await _database.getLatestWatchActionsForKeys( + keys, + profileId: profileId, + filterProfile: profileId != null, + clientScopeIdsByGlobalKey: scopes, + ); + if (actions.isEmpty) return; + for (final entry in actions.entries) { + final base = _metadata[entry.key]; + if (base == null) continue; + final action = entry.value; + bool? isWatched; + switch (action.actionType) { + case 'watched': + isWatched = true; + case 'unwatched': + isWatched = false; + case 'progress': + isWatched = action.shouldMarkWatched; + } + if (isWatched == null) continue; + _metadata[entry.key] = base.copyWith( + viewCount: isWatched ? 1 : 0, + viewOffsetMs: isWatched ? base.viewOffsetMs : 0, + ); + } + } catch (e) { + appLogger.w('Failed to apply offline watch overlay', error: e); + } + } + + Future _offlineWatchScopeForGlobalKey(String globalKey) async { + final parsed = parseGlobalKey(globalKey); + if (parsed == null) return null; + final activeScope = _downloadManager.activeClientScopeIdForServer(parsed.serverId); + if (activeScope != null && activeScope.isNotEmpty) return activeScope; + final downloaded = await _database.getDownloadedMedia(globalKey); + final downloadedScope = downloaded?.clientScopeId; + return downloadedScope == null || downloadedScope.isEmpty ? null : downloadedScope; + } + /// Load total episode counts from StorageService Future _loadTotalEpisodeCounts() async { try { @@ -206,35 +427,43 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Load parent (show and season) metadata from a pre-loaded map (no DB I/O). /// Used during bulk initialization to avoid per-item DB queries. - void _loadParentMetadataFromMap(PlexMetadata episode, Map allMetadata) { + void _loadParentMetadataFromMap(MediaItem episode, Map allMetadata, {String? clientScopeId}) { final serverId = episode.serverId; if (serverId == null) return; + MediaItem? lookupParent(String ratingKey) { + if (clientScopeId != null && clientScopeId.isNotEmpty) { + final scoped = allMetadata[buildGlobalKey(clientScopeId, ratingKey)]; + if (scoped != null) return scoped; + } + return allMetadata[buildGlobalKey(serverId, ratingKey)]; + } + // Load show metadata - final showRatingKey = episode.grandparentRatingKey; + final showRatingKey = episode.grandparentId; if (showRatingKey != null) { final showGlobalKey = buildGlobalKey(serverId, showRatingKey); if (!_metadata.containsKey(showGlobalKey)) { - final showMetadata = allMetadata[showGlobalKey]; + final showMetadata = lookupParent(showRatingKey); if (showMetadata != null) { _metadata[showGlobalKey] = showMetadata; - if (showMetadata.thumb != null) { - _artworkPaths[showGlobalKey] = DownloadedArtwork(thumbPath: showMetadata.thumb); + if (showMetadata.thumbPath != null) { + _artworkPaths[showGlobalKey] = DownloadedArtwork(thumbPath: showMetadata.thumbPath); } } } } // Load season metadata - final seasonRatingKey = episode.parentRatingKey; + final seasonRatingKey = episode.parentId; if (seasonRatingKey != null) { final seasonGlobalKey = buildGlobalKey(serverId, seasonRatingKey); if (!_metadata.containsKey(seasonGlobalKey)) { - final seasonMetadata = allMetadata[seasonGlobalKey]; + final seasonMetadata = lookupParent(seasonRatingKey); if (seasonMetadata != null) { _metadata[seasonGlobalKey] = seasonMetadata; - if (seasonMetadata.thumb != null) { - _artworkPaths[seasonGlobalKey] = DownloadedArtwork(thumbPath: seasonMetadata.thumb); + if (seasonMetadata.thumbPath != null) { + _artworkPaths[seasonGlobalKey] = DownloadedArtwork(thumbPath: seasonMetadata.thumbPath); } } } @@ -259,49 +488,84 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin void dispose() { _progressSubscription?.cancel(); _deletionProgressSubscription?.cancel(); + _watchStateSubscription?.cancel(); super.dispose(); } + void _onWatchStateChanged(WatchStateEvent event) { + // Progress ticks fire continuously during playback; only react to discrete + // watched/unwatched flips so we don't churn listeners on every frame. + if (event.changeType == WatchStateChangeType.progressUpdate) return; + if (event.isNowWatched == null) return; + + final globalKey = buildGlobalKey(event.serverId, event.itemId); + final base = _metadata[globalKey]; + if (base == null) return; + + final isWatched = event.isNowWatched!; + _metadata[globalKey] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: isWatched ? base.viewOffsetMs : 0); + // Persist into the per-backend pinned cache so the patch survives reloads + // (`_loadPersistedDownloads` rehydrates `_metadata` from the cache). + unawaited( + ApiCache.forBackend(base.backend) + .applyWatchState(serverId: event.cacheServerId ?? event.serverId, itemId: event.itemId, isWatched: isWatched) + .catchError((Object e) { + appLogger.w('Failed to apply watch state to cache for $globalKey', error: e); + }), + ); + safeNotifyListeners(); + } + /// Ensure metadata has a serverId, falling back to a parent's serverId. - PlexMetadata _ensureServerId(PlexMetadata metadata, String? fallbackServerId) => + MediaItem _ensureServerId(MediaItem metadata, String? fallbackServerId) => metadata.serverId != null ? metadata : metadata.copyWith(serverId: fallbackServerId); /// All current download progress entries - Map get downloads => Map.unmodifiable(_downloads); + Map get downloads => + Map.unmodifiable(Map.fromEntries(_downloads.entries.where(_ownsProgressEntry))); /// All metadata for downloads - Map get metadata => Map.unmodifiable(_metadata); + Map get metadata => Map.unmodifiable(_metadata); /// Get unique TV shows that have downloaded episodes /// Returns stored show metadata, or synthesizes from episode metadata as fallback - List get downloadedShows { - final Map shows = {}; + List get downloadedShows { + final Map shows = {}; for (final entry in _metadata.entries) { final globalKey = entry.key; + if (!_ownsDownloadKey(globalKey)) continue; final meta = entry.value; final progress = _downloads[globalKey]; - if (progress?.status == DownloadStatus.completed && meta.type == 'episode') { - final showRatingKey = meta.grandparentRatingKey; + if (progress?.status == DownloadStatus.completed && meta.isEpisode) { + final showRatingKey = meta.grandparentId; if (showRatingKey != null && !shows.containsKey(showRatingKey)) { // Try to get stored show metadata first final showGlobalKey = buildGlobalKey(meta.serverId!, showRatingKey); final storedShow = _metadata[showGlobalKey]; - if (storedShow != null && storedShow.type == 'show') { + if (storedShow != null && storedShow.isShow) { // Use stored show metadata (has year, summary, clearLogo) shows[showRatingKey] = storedShow; } else { // Fallback: synthesize from episode metadata (missing year, summary) - shows[showRatingKey] = PlexMetadata( - ratingKey: showRatingKey, - key: '/library/metadata/$showRatingKey', - type: 'show', + // 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). + final synthesizedRaw = switch (meta.backend) { + MediaBackend.plex => {'key': '/library/metadata/$showRatingKey'}, + MediaBackend.jellyfin => {'Id': showRatingKey, 'Type': 'Series'}, + }; + shows[showRatingKey] = MediaItem( + id: showRatingKey, + backend: meta.backend, + kind: MediaKind.show, title: meta.grandparentTitle ?? 'Unknown Show', - thumb: meta.grandparentThumb, - art: meta.grandparentArt, + thumbPath: meta.grandparentThumbPath, + artPath: meta.grandparentArtPath, serverId: meta.serverId, + raw: synthesizedRaw, ); } } @@ -312,18 +576,19 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Get completed movie downloads - List get downloadedMovies { + List get downloadedMovies { return _metadata.entries .where((entry) { + if (!_ownsDownloadKey(entry.key)) return false; final progress = _downloads[entry.key]; - return progress?.status == DownloadStatus.completed && entry.value.type == 'movie'; + return progress?.status == DownloadStatus.completed && entry.value.isMovie; }) .map((entry) => entry.value) .toList(); } /// Get metadata for a specific download - PlexMetadata? getMetadata(String globalKey) => _metadata[globalKey]; + MediaItem? getMetadata(String globalKey) => _metadata[globalKey]; /// Get artwork paths for a specific download (for offline display) DownloadedArtwork? getArtworkPaths(String globalKey) => _artworkPaths[globalKey]; @@ -336,14 +601,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Get downloaded episodes for a specific show (by grandparentRatingKey) - List getDownloadedEpisodesForShow(String showRatingKey) { + List getDownloadedEpisodesForShow(String showRatingKey) { return _metadata.entries .where((entry) { + if (!_ownsDownloadKey(entry.key)) return false; final progress = _downloads[entry.key]; final meta = entry.value; - return progress?.status == DownloadStatus.completed && - meta.type == 'episode' && - meta.grandparentRatingKey == showRatingKey; + return progress?.status == DownloadStatus.completed && meta.isEpisode && meta.grandparentId == showRatingKey; }) .map((entry) => entry.value) .toList(); @@ -353,10 +617,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin List _getEpisodeDownloads({String? showRatingKey, String? seasonRatingKey}) { return _downloads.entries .where((entry) { + if (!_ownsDownloadKey(entry.key)) return false; final meta = _metadata[entry.key]; - if (meta?.type != 'episode') return false; - if (showRatingKey != null && meta?.grandparentRatingKey != showRatingKey) return false; - if (seasonRatingKey != null && meta?.parentRatingKey != seasonRatingKey) return false; + if (meta == null || !meta.isEpisode) return false; + if (showRatingKey != null && meta.grandparentId != showRatingKey) return false; + if (seasonRatingKey != null && meta.parentId != seasonRatingKey) return false; return true; }) .map((entry) => entry.value) @@ -406,7 +671,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin ' - Stored count: $storedCount\n' ' - Downloaded episodes: $downloadedCount\n' ' - Metadata exists: ${meta != null}\n' - ' - Type: ${meta?.type}\n' + ' - Type: ${meta?.kind.id}\n' ' - Title: ${meta?.title}', ); @@ -496,6 +761,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // First check if we have direct progress (for episodes/movies) final directProgress = _downloads[globalKey]; if (directProgress != null) { + if (!_ownsDownloadKey(globalKey)) return null; return directProgress; } @@ -525,11 +791,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin return null; } - // We have metadata, check type - final mt = meta.mediaType; - if (mt == PlexMediaType.show) { + // We have metadata, check kind + if (meta.kind == MediaKind.show) { return getAggregateProgressForShow(serverId, ratingKey); - } else if (mt == PlexMediaType.season) { + } else if (meta.kind == MediaKind.season) { return getAggregateProgressForSeason(serverId, ratingKey); } @@ -564,6 +829,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Returns null if not downloaded or file doesn't exist Future getVideoFilePath(String globalKey) async { appLogger.d('getVideoFilePath called with globalKey: $globalKey'); + if (!_ownsDownloadKey(globalKey)) { + appLogger.w('Profile does not own downloaded item: $globalKey'); + return null; + } final downloadedItem = await _downloadManager.getDownloadedMedia(globalKey); if (downloadedItem == null) { @@ -605,8 +874,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// For shows and seasons, fetches all child episodes and queues them. /// Returns the number of items queued. Future queueDownload( - PlexMetadata metadata, - PlexClient client, { + MediaItem metadata, + MediaServerClient client, { DownloadVersionConfig? versionConfig, DownloadFilter filter = DownloadFilter.all, int? maxCount, @@ -624,19 +893,37 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _queueing.add(globalKey); safeNotifyListeners(); - final mt = metadata.mediaType; - - if (mt == PlexMediaType.movie || mt == PlexMediaType.episode) { + if (metadata.isMovie || metadata.isEpisode) { final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex); return queued ? 1 : 0; - } else if (mt == PlexMediaType.show) { + } else if (metadata.isShow) { + // Stash metadata pre-queue so the UI can render the queueing state; + // roll back if expansion throws so the orphan doesn't linger. + final hadMetadata = _metadata.containsKey(globalKey); _metadata[globalKey] = metadata; - return await _queueShowDownload(metadata, client, versionConfig: config, filter: filter, maxCount: maxCount); - } else if (mt == PlexMediaType.season) { + try { + return await _queueShowDownload(metadata, client, versionConfig: config, filter: filter, maxCount: maxCount); + } catch (_) { + if (!hadMetadata) _metadata.remove(globalKey); + rethrow; + } + } else if (metadata.isSeason) { + final hadMetadata = _metadata.containsKey(globalKey); _metadata[globalKey] = metadata; - return await _queueSeasonDownload(metadata, client, versionConfig: config, filter: filter, maxCount: maxCount); + try { + return await _queueSeasonDownload( + metadata, + client, + versionConfig: config, + filter: filter, + maxCount: maxCount, + ); + } catch (_) { + if (!hadMetadata) _metadata.remove(globalKey); + rethrow; + } } else { - throw Exception('Cannot download ${metadata.type}'); + throw Exception('Cannot download ${metadata.kind.id}'); } } finally { _queueing.remove(globalKey); @@ -650,8 +937,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// into their episodes (when [expandShows] is true). Music items, nested /// collections/playlists, and unknown types are skipped. Future queueListDownload( - List items, - PlexClient client, { + List items, + MediaServerClient client, { DownloadFilter filter = DownloadFilter.all, bool expandShows = true, }) async { @@ -662,40 +949,32 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final unwatchedOnly = filter == DownloadFilter.unwatched; int count = 0; - Future queueItem(PlexMetadata item) async { + Future queueItem(MediaItem item) async { if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) return; final queued = await _queueSingleDownload(item, client); if (queued) count++; } - Future expandSeason(String seasonRatingKey) async { - final episodes = await client.getChildren(seasonRatingKey); - for (final ep in episodes) { - if (ep.type != ContentTypes.episode) continue; - await queueItem(ep); - } - } - for (final item in items) { - final mt = item.mediaType; - switch (mt) { - case PlexMediaType.movie: - case PlexMediaType.episode: - await queueItem(item); - case PlexMediaType.show: - if (!expandShows) break; - final seasons = await client.getChildren(item.ratingKey); - for (final season in seasons) { - if (season.type == ContentTypes.season) { - await expandSeason(season.ratingKey); - } - } - case PlexMediaType.season: - if (!expandShows) break; - await expandSeason(item.ratingKey); - default: - // Skip music, clips, nested collections/playlists, unknown types. - break; + if (item.isMovie || item.isEpisode) { + await queueItem(item); + } else if (item.isShow || item.isSeason) { + if (!expandShows) continue; + // One-shot recursive expansion (Plex /grandchildren, Jellyfin + // Recursive=true) — the per-season walk that used to live here + // was the same pattern as collectEpisodes*, just inlined. + final episodes = []; + if (item.isShow) { + await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes); + } else { + await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes); + } + for (final ep in episodes) { + await queueItem(ep); + } + } else { + // Skip music, clips, nested collections/playlists, unknown types. + continue; } } return count; @@ -704,40 +983,46 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Queue a single movie or episode for download. /// Returns true if the item was actually queued, false if skipped. Future _queueSingleDownload( - PlexMetadata metadata, - PlexClient client, { + MediaItem metadata, + MediaServerClient client, { int mediaIndex = 0, DownloadVersionConfig? versionConfig, }) async { + _requireActiveProfileId(); final globalKey = metadata.globalKey; - // Don't re-queue if already downloading or completed + // Don't duplicate the physical download. If another profile already owns + // the shared row, claiming it makes it visible for the active profile. if (_downloads.containsKey(globalKey)) { final existing = _downloads[globalKey]!; - if (existing.status == DownloadStatus.downloading || existing.status == DownloadStatus.completed) { - return false; + if (existing.status == DownloadStatus.downloading || + existing.status == DownloadStatus.completed || + existing.status == DownloadStatus.queued) { + final claimed = await _claimDownloadForActiveProfile(globalKey); + if (claimed) safeNotifyListeners(); + return claimed; } } // Always fetch full metadata before downloading. // Hub items may have summary but the cache at /library/metadata/$ratingKey // won't have the full API response (with Media/Part data needed for video URL) - // unless getMetadataWithImages has been called. + // unless fetchItem has been called. // // Skip the fetch when offline — it would just fail. The partial metadata // from whatever hub/grid invoked the queue is good enough to enqueue; the // actual video URL resolves later when we're back online. - PlexMetadata metadataToStore = metadata; + MediaItem metadataToStore = metadata; if (_offlineSource?.isOffline ?? false) { - appLogger.d('Offline — using partial metadata for ${metadata.ratingKey}'); + appLogger.d('Offline — using partial metadata for ${metadata.id}'); } else { try { - final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey); + final fullMetadata = await client.fetchItem(metadata.id); if (fullMetadata != null) { metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName); } } catch (e) { - appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e); + appLogger.w('Failed to fetch full metadata for ${metadata.id}, using partial', error: e); } } @@ -746,7 +1031,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (versionConfig != null && versionConfig.acceptedSignatures.isNotEmpty) { final versions = metadataToStore.mediaVersions; if (versions != null && versions.isNotEmpty) { - final matchedIndex = PlexMediaVersion.findMatchingIndex(versions, versionConfig.acceptedSignatures); + final matchedIndex = MediaVersion.findMatchingIndex(versions, versionConfig.acceptedSignatures); if (matchedIndex != null) { resolvedIndex = matchedIndex; } else if (versionConfig.onVersionMismatch != null) { @@ -759,13 +1044,15 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } // For episodes, also fetch and store show and season metadata for offline display - if (metadataToStore.type == 'episode') { + if (metadataToStore.isEpisode) { await _fetchAndStoreParentMetadata(metadataToStore, client); } // Store full metadata for display _metadata[globalKey] = metadataToStore; + await _claimDownloadForActiveProfile(globalKey); + // Update local state immediately for UI feedback _downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued); safeNotifyListeners(); @@ -777,28 +1064,28 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Fetch and store show and season metadata for an episode /// Also downloads artwork for show and season - Future _fetchAndStoreParentMetadata(PlexMetadata episode, PlexClient client) async { + Future _fetchAndStoreParentMetadata(MediaItem episode, MediaServerClient client) async { final serverId = episode.serverId; if (serverId == null) return; - await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.grandparentRatingKey, client: client); - await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.parentRatingKey, client: client); + await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.grandparentId, client: client); + await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.parentId, client: client); } /// Fetch, persist, and download artwork for a related metadata item (show or season). Future _fetchAndStoreRelatedMetadata({ required String serverId, required String? ratingKey, - required PlexClient client, + required MediaServerClient client, }) async { if (ratingKey == null) return; final globalKey = buildGlobalKey(serverId, ratingKey); final storageService = DownloadStorageService.instance; - PlexMetadata? metadata = _metadata[globalKey]; + MediaItem? metadata = _metadata[globalKey]; if (metadata == null) { try { - metadata = await client.getMetadataWithImages(ratingKey); + metadata = await client.fetchItem(ratingKey); } catch (e) { appLogger.w('Failed to fetch metadata for $ratingKey', error: e); } @@ -807,9 +1094,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin final withServer = metadata.copyWith(serverId: serverId); _metadata[globalKey] = withServer; - await _downloadManager.saveMetadata(withServer); + await _downloadManager.saveMetadata(withServer, client); - final thumbPath = withServer.thumb; + final thumbPath = withServer.thumbPath; final hasPoster = thumbPath != null && await storageService.artworkExists(serverId, thumbPath); if (!hasPoster) { await _downloadManager.downloadArtworkForMetadata(withServer, client); @@ -818,7 +1105,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Store leafCount for a show or season so aggregate progress works. - Future _storeLeafCount(String globalKey, PlexMetadata metadata) async { + Future _storeLeafCount(String globalKey, MediaItem metadata) async { if (metadata.leafCount != null && metadata.leafCount! > 0) { _totalEpisodeCounts[globalKey] = metadata.leafCount!; await _persistTotalEpisodeCount(globalKey, metadata.leafCount!); @@ -827,8 +1114,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Queue all episodes from a TV show for download Future _queueShowDownload( - PlexMetadata show, - PlexClient client, { + MediaItem show, + MediaServerClient client, { DownloadVersionConfig? versionConfig, DownloadFilter filter = DownloadFilter.all, int? maxCount, @@ -846,8 +1133,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Queue all episodes from a season for download Future _queueSeasonDownload( - PlexMetadata season, - PlexClient client, { + MediaItem season, + MediaServerClient client, { DownloadVersionConfig? versionConfig, DownloadFilter filter = DownloadFilter.all, int? maxCount, @@ -866,12 +1153,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Queue only the missing (not downloaded) episodes for a show/season. /// Used for resuming partial downloads. Returns the number of episodes queued. Future queueMissingEpisodes( - PlexMetadata metadata, - PlexClient client, { + MediaItem metadata, + MediaServerClient client, { DownloadVersionConfig? versionConfig, }) async { - final mt = metadata.mediaType; - if (mt != PlexMediaType.show && mt != PlexMediaType.season) { + if (!metadata.isShow && !metadata.isSeason) { throw Exception('queueMissingEpisodes only supports shows/seasons'); } final queued = await _expandAndQueue( @@ -882,7 +1168,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin maxCount: null, skipExisting: true, ); - if (mt == PlexMediaType.show) { + if (metadata.isShow) { appLogger.i('Queued $queued missing episodes for show ${metadata.title}'); } return queued; @@ -892,19 +1178,19 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// apply [filter] and optional [maxCount], optionally skip items already /// queued/downloading/completed ([skipExisting]), and queue each one. Future _expandAndQueue({ - required PlexMetadata container, - required PlexClient client, + required MediaItem container, + required MediaServerClient client, required DownloadVersionConfig? versionConfig, required DownloadFilter filter, required int? maxCount, required bool skipExisting, }) async { final unwatchedOnly = filter == DownloadFilter.unwatched; - final episodes = []; - if (container.mediaType == PlexMediaType.show) { - await collectEpisodesForShow(client, container.ratingKey, unwatchedOnly: unwatchedOnly, out: episodes); + final episodes = []; + if (container.kind == MediaKind.show) { + await collectEpisodesForShow(client, container.id, unwatchedOnly: unwatchedOnly, out: episodes); } else { - await collectEpisodesForSeason(client, container.ratingKey, unwatchedOnly: unwatchedOnly, out: episodes); + await collectEpisodesForSeason(client, container.id, unwatchedOnly: unwatchedOnly, out: episodes); } int count = 0; @@ -916,6 +1202,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (skipExisting) { final progress = _downloads[episodeWithServer.globalKey]; if (progress != null && + _ownsDownloadKey(episodeWithServer.globalKey) && (progress.status == DownloadStatus.completed || progress.status == DownloadStatus.downloading || progress.status == DownloadStatus.queued)) { @@ -931,6 +1218,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Pause a download (works for both downloading and queued items) Future pauseDownload(String globalKey) async { + if (!_ownsDownloadKey(globalKey)) return; final progress = _downloads[globalKey]; if (progress != null && (progress.status == DownloadStatus.downloading || progress.status == DownloadStatus.queued)) { @@ -939,7 +1227,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Resume a paused download - Future resumeDownload(String globalKey, PlexClient client) async { + Future resumeDownload(String globalKey, MediaServerClient client) async { + if (!_ownsDownloadKey(globalKey)) return; final progress = _downloads[globalKey]; if (progress != null && progress.status == DownloadStatus.paused) { await _downloadManager.resumeDownload(globalKey, client); @@ -947,7 +1236,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Retry a failed download - Future retryDownload(String globalKey, PlexClient client) async { + Future retryDownload(String globalKey, MediaServerClient client) async { + if (!_ownsDownloadKey(globalKey)) return; final progress = _downloads[globalKey]; if (progress != null && progress.status == DownloadStatus.failed) { await _downloadManager.retryDownload(globalKey, client); @@ -956,11 +1246,25 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Cancel a download Future cancelDownload(String globalKey) async { + if (!_ownsDownloadKey(globalKey)) return; final progress = _downloads[globalKey]; if (progress != null) { - await _downloadManager.cancelDownload(globalKey); - _downloads.remove(globalKey); - _metadata.remove(globalKey); + final released = await _releaseDownloadForActiveProfile(globalKey); + final hasOtherOwners = await _database.hasDownloadOwner(globalKey); + final removedMeta = _metadata[globalKey]; + if (!hasOtherOwners) { + await _downloadManager.cancelDownload(globalKey); + await _database.deleteDownload(globalKey); + _downloads.remove(globalKey); + _metadata.remove(globalKey); + _artworkPaths.remove(globalKey); + _totalEpisodeCounts.remove(globalKey); + } + if (removedMeta != null) { + DeletionNotifier().notifyDeletedItem(item: removedMeta, isDownloadOnly: true); + } else if (!released) { + return; + } safeNotifyListeners(); } } @@ -968,19 +1272,21 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Delete a downloaded item Future deleteDownload(String globalKey) async { try { - // Check if this is a show/season and clean up episode count final meta = _metadata[globalKey]; - if (meta?.type == 'show' || meta?.type == 'season') { - final removedCount = _totalEpisodeCounts.remove(globalKey); - final storage = await StorageService.getInstance(); - await storage.removeEpisodeCount(globalKey); - appLogger.i( - 'Removed episode count for $globalKey\n' - ' - Removed count value: $removedCount\n' - ' - Metadata type: ${meta?.type}\n' - ' - Metadata title: ${meta?.title}\n' - ' - Remaining stored counts: ${_totalEpisodeCounts.length}', - ); + if (meta != null && (meta.isShow || meta.isSeason)) { + await _deleteOwnedContainerDownloads(globalKey, meta); + return; + } + if (!_ownsDownloadKey(globalKey)) return; + + final released = await _releaseDownloadForActiveProfile(globalKey); + final hasOtherOwners = await _database.hasDownloadOwner(globalKey); + if (hasOtherOwners) { + if (meta != null) { + DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true); + } + if (released) safeNotifyListeners(); + return; } // Start deletion (progress will be tracked via stream) @@ -991,6 +1297,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin _metadata.remove(globalKey); _artworkPaths.remove(globalKey); + // Notify any open screens so they can drop the item from their lists + // immediately instead of waiting for an exit/re-enter. + if (meta != null) { + DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true); + } + safeNotifyListeners(); } catch (e) { // Remove from deletion tracking on error @@ -1000,6 +1312,38 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } + Future _deleteOwnedContainerDownloads(String globalKey, MediaItem container) async { + final removedCount = _totalEpisodeCounts.remove(globalKey); + final storage = await StorageService.getInstance(); + await storage.removeEpisodeCount(globalKey); + appLogger.i( + 'Removed episode count for $globalKey\n' + ' - Removed count value: $removedCount\n' + ' - Metadata type: ${container.kind.id}\n' + ' - Metadata title: ${container.title}\n' + ' - Remaining stored counts: ${_totalEpisodeCounts.length}', + ); + + final descendants = _ownedDescendantEntries(container).toList(); + for (final entry in descendants) { + await deleteDownload(entry.key); + } + + DeletionNotifier().notifyDeletedItem(item: container, isDownloadOnly: true); + safeNotifyListeners(); + } + + Iterable> _ownedDescendantEntries(MediaItem container) { + return _metadata.entries.where((entry) { + if (!_ownsDownloadKey(entry.key)) return false; + final meta = entry.value; + if (meta.serverId != container.serverId) return false; + return container.isShow + ? (meta.grandparentId == container.id || meta.parentId == container.id) + : meta.parentId == container.id; + }); + } + /// Handle deletion progress updates void _onDeletionProgressUpdate(DeletionProgress progress) { if (progress.isComplete) { @@ -1021,39 +1365,92 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } /// Resume queued downloads that were interrupted by app kill. - /// Call after a PlexClient becomes available (e.g. after server connect on launch). - void resumeQueuedDownloads(PlexClient client) { + /// Call after a [MediaServerClient] becomes available (e.g. after server connect on launch). + void resumeQueuedDownloads(MediaServerClient client) { _downloadManager.resumeQueuedDownloads(client); } + /// Backend-aware metadata lookup for offline UI. Routes through + /// [DownloadManagerService] which dispatches to [PlexApiCache] or + /// [JellyfinApiCache] based on the connection's `kind`. + Future lookupOfflineMetadata(String serverId, String itemId) => + _downloadManager.lookupMetadata(serverId, itemId); + /// Refresh only metadata from API cache (after watch state sync). /// /// This is more lightweight than full refresh() - only updates metadata /// without reloading download progress from database. Future refreshMetadataFromCache() async { - final apiCache = PlexApiCache.instance; - int updatedCount = 0; + // The initial load runs in the constructor and may still be in flight + // when callers (e.g. `onServersConnected`) trigger this. Wait for it so + // `_downloads` is populated before we walk it — otherwise an early call + // sees an empty map and does nothing useful. + await ensureInitialized(); - for (final globalKey in _metadata.keys.toList()) { + // Walk every download — not just keys we already have metadata for. The + // initial `_loadPersistedDownloads` may have raced with connection setup + // (Jellyfin's cache reads need a [Connections] row) and skipped entries; + // this lets a later refresh actually populate them. + final keys = {..._metadata.keys, ..._downloads.keys}; + if (keys.isEmpty) return; + + final allMetadata = await _downloadManager.getAllPinnedMetadata(preferActiveScope: true); + int cacheHits = 0; + int networkFills = 0; + int misses = 0; + + for (final globalKey in keys) { final parsed = parseGlobalKey(globalKey); if (parsed == null) continue; - final serverId = parsed.serverId; - final ratingKey = parsed.ratingKey; - try { - final metadata = await apiCache.getMetadata(serverId, ratingKey); - if (metadata != null) { - _metadata[globalKey] = metadata; - updatedCount++; + final downloadRecord = await _downloadManager.getDownloadedMedia(globalKey); + var cached = + allMetadata[globalKey] ?? + await _downloadManager.lookupMetadata(parsed.serverId, parsed.ratingKey, preferActiveScope: true); + if (cached != null) { + cacheHits++; + } else if (_downloads.containsKey(globalKey)) { + // Cache miss for an item we know is downloaded — pull from the + // live server. Repairs profiles where the per-backend cache row + // was never written or got cleared, the case that produces + // empty-title sync rules and a missing-downloads list. + cached = await _downloadManager.fetchAndPinMetadata( + parsed.serverId, + parsed.ratingKey, + preferActiveScope: true, + ); + if (cached != null) networkFills++; + } + + if (cached != null) { + _metadata[globalKey] = cached; + if (cached.isEpisode) { + _loadParentMetadataFromMap( + cached, + allMetadata, + clientScopeId: + _downloadManager.activeClientScopeIdForServer(parsed.serverId) ?? downloadRecord?.clientScopeId, + ); + } + } else { + misses++; } } catch (e) { appLogger.d('Failed to refresh metadata for $globalKey: $e'); } } + // Re-apply offline overlay so locally-queued watch actions aren't clobbered + // by stale per-backend caches that haven't yet seen the server roundtrip. + await _applyOfflineWatchOverlay(); + + final updatedCount = cacheHits + networkFills; + appLogger.i( + 'refreshMetadataFromCache: walked ${keys.length} keys → ' + '$cacheHits cache hits, $networkFills network fills, $misses unresolved', + ); if (updatedCount > 0) { - appLogger.i('Refreshed metadata from cache for $updatedCount items'); safeNotifyListeners(); } } @@ -1061,12 +1458,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Auto-delete downloaded episodes/movies that are now marked as watched. /// /// Only deletes individual episodes and movies, never show/season containers. - /// [activeRatingKey] is excluded from deletion to protect the currently playing item. - Future> autoDeleteWatchedDownloads({String? activeRatingKey}) async { + /// [activeId] is excluded from deletion to protect the currently playing item. + Future> autoDeleteWatchedDownloads({String? activeId}) async { final deletedTitles = []; final completedKeys = _downloads.entries - .where((e) => e.value.status == DownloadStatus.completed) + .where((e) => _ownsDownloadKey(e.key) && e.value.status == DownloadStatus.completed) .map((e) => e.key) .toList(); @@ -1077,7 +1474,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (!meta.isWatched) continue; // Don't delete the episode that's currently playing - if (activeRatingKey != null && meta.ratingKey == activeRatingKey) continue; + if (activeId != null && meta.id == activeId) continue; try { appLogger.i('Auto-deleting watched download: ${meta.title} ($globalKey)'); @@ -1095,9 +1492,45 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Sync Rules // ============================================================ - /// All sync rules (globalKey -> SyncRuleItem) + /// All sync rules for the active profile (profile-scoped globalKey -> SyncRuleItem). Map get syncRules => Map.unmodifiable(_syncRules); + String syncRuleKeyFor(String serverId, String ratingKey, {String? profileId}) { + final owner = profileId ?? _activeProfileId; + if (owner == null || owner.isEmpty) return buildGlobalKey(serverId, ratingKey); + return buildProfileScopedGlobalKey(owner, serverId, ratingKey); + } + + String syncRuleKeyForGlobalKey(String globalKey) { + final scoped = parseProfileScopedGlobalKey(globalKey); + if (scoped != null) { + return syncRuleKeyFor(scoped.serverId, scoped.ratingKey, profileId: scoped.profileId); + } + final parsed = parseGlobalKey(globalKey); + if (parsed == null) return globalKey; + return syncRuleKeyFor(parsed.serverId, parsed.ratingKey); + } + + String syncRuleKeyForClient(MediaServerClient client, String ratingKey, {String? serverId}) { + return syncRuleKeyFor(serverId ?? client.serverId, ratingKey); + } + + /// Candidate active-profile sync-rule keys touched by a watched item event. + Set syncRuleKeysForWatchEvent(WatchStateEvent event) { + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) return const {}; + final keys = {}; + void add(String ratingKey) { + keys.add(syncRuleKeyFor(event.serverId, ratingKey, profileId: profileId)); + } + + add(event.itemId); + for (final parentKey in event.parentChain) { + add(parentKey); + } + return keys; + } + /// Check if a sync rule exists for the given item bool hasSyncRule(String globalKey) => _syncRules.containsKey(globalKey); @@ -1117,13 +1550,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin required int episodeCount, int mediaIndex = 0, String downloadFilter = SyncRuleFilter.unwatched, - PlexMetadata? targetMetadata, + MediaItem? targetMetadata, }) async { - final globalKey = buildGlobalKey(serverId, ratingKey); + final profileId = _requireActiveProfileId(); + final publicGlobalKey = buildGlobalKey(serverId, ratingKey); + final scopedGlobalKey = syncRuleKeyFor(serverId, ratingKey, profileId: profileId); await _database.insertSyncRule( + profileId: profileId, serverId: serverId, ratingKey: ratingKey, - globalKey: globalKey, + globalKey: scopedGlobalKey, targetType: targetType, episodeCount: episodeCount, mediaIndex: mediaIndex, @@ -1132,20 +1568,21 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (targetMetadata != null) { final withServer = targetMetadata.serverId != null ? targetMetadata : targetMetadata.copyWith(serverId: serverId); - _metadata[globalKey] = withServer; + _metadata[publicGlobalKey] = withServer; } // Reload to get the full row with id/timestamps - final rule = await _database.getSyncRule(globalKey); + final rule = await _database.getSyncRule(scopedGlobalKey); if (rule != null) { - _syncRules[globalKey] = rule; + _syncRules[rule.globalKey] = rule; safeNotifyListeners(); } - appLogger.i('Created sync rule: $globalKey ($targetType, filter=$downloadFilter, keep $episodeCount)'); + appLogger.i('Created sync rule: $scopedGlobalKey ($targetType, filter=$downloadFilter, keep $episodeCount)'); } /// Update the episode count for an existing show/season sync rule. Future updateSyncRuleCount(String globalKey, int episodeCount) async { + _requireActiveProfileId(); await _database.updateSyncRuleCount(globalKey, episodeCount); final existing = _syncRules[globalKey]; if (existing != null) { @@ -1157,6 +1594,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Update the download filter for an existing collection/playlist sync rule. Future updateSyncRuleFilter(String globalKey, String downloadFilter) async { + _requireActiveProfileId(); await _database.updateSyncRuleFilter(globalKey, downloadFilter); final existing = _syncRules[globalKey]; if (existing != null) { @@ -1168,6 +1606,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Toggle a sync rule's enabled state. Future setSyncRuleEnabled(String globalKey, bool enabled) async { + _requireActiveProfileId(); await _database.updateSyncRuleEnabled(globalKey, enabled); final existing = _syncRules[globalKey]; if (existing != null) { @@ -1179,8 +1618,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Delete a sync rule. Downloaded episodes are kept. Future deleteSyncRule(String globalKey) async { + _requireActiveProfileId(); + final existing = _syncRules[globalKey] ?? await _database.getSyncRule(globalKey); + final publicGlobalKey = existing == null ? globalKey : buildGlobalKey(existing.serverId, existing.ratingKey); await _database.deleteSyncRule(globalKey); _syncRules.remove(globalKey); + // createSyncRule may have stashed targetMetadata for collection/playlist + // rules with no underlying download; release it if nothing else holds it. + if (!_downloads.containsKey(publicGlobalKey)) { + _metadata.remove(publicGlobalKey); + } safeNotifyListeners(); appLogger.i('Deleted sync rule: $globalKey'); } @@ -1193,11 +1640,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// /// Returns titles of newly queued items (for snackbar display). Future> executeSyncRules(MultiServerManager serverManager, {bool force = false}) async { + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) return []; if (_syncRules.isEmpty) return []; final results = await _syncRuleExecutor.executeSyncRules( + profileId: profileId, serverManager: serverManager, - downloads: Map.unmodifiable(_downloads), + downloads: downloads, metadata: Map.unmodifiable(_metadata), queueSingleDownload: (episode, client, {int mediaIndex = 0}) => _queueSingleDownload(episode, client, mediaIndex: mediaIndex), @@ -1213,12 +1663,15 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Execute a single sync rule immediately (eager path for `addToPlaylist` / /// `addToCollection`). Bypasses the cooldown. Future executeSyncRuleFor(String globalKey, MultiServerManager serverManager) async { + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) return null; if (!_syncRules.containsKey(globalKey)) return null; return _syncRuleExecutor.executeSingleRule( + profileId: profileId, globalKey: globalKey, serverManager: serverManager, - downloads: Map.unmodifiable(_downloads), + downloads: downloads, metadata: Map.unmodifiable(_metadata), queueSingleDownload: (episode, client, {int mediaIndex = 0}) => _queueSingleDownload(episode, client, mediaIndex: mediaIndex), @@ -1228,7 +1681,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future _loadSyncRules() async { try { _syncRules.clear(); - final rules = await _database.getSyncRules(); + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) return; + await _database.adoptLegacySyncRulesForProfile(profileId); + if (_activeProfileId != profileId) return; + final rules = await _database.getSyncRules(profileId: profileId); for (final rule in rules) { _syncRules[rule.globalKey] = rule; } @@ -1236,6 +1693,19 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin appLogger.w('Failed to load sync rules', error: e); } } + + Future _loadDownloadOwners() async { + try { + _ownedDownloadKeys.clear(); + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) return; + await _database.adoptLegacyDownloadsForProfile(profileId); + if (_activeProfileId != profileId) return; + _ownedDownloadKeys.addAll(await _database.getDownloadOwnerKeysForProfile(profileId)); + } catch (e) { + appLogger.w('Failed to load download ownership', error: e); + } + } } /// Exception thrown when download is blocked due to cellular-only setting diff --git a/lib/providers/libraries_provider.dart b/lib/providers/libraries_provider.dart index 8af56c41..a6850f9d 100644 --- a/lib/providers/libraries_provider.dart +++ b/lib/providers/libraries_provider.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; +import '../media/media_library.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; -import '../models/plex_library.dart'; import '../services/data_aggregation_service.dart'; import '../services/storage_service.dart'; import '../utils/app_logger.dart'; @@ -15,7 +15,7 @@ enum LibrariesLoadState { initial, loading, loaded, error } /// instead of independently fetching library data. class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixin { DataAggregationService? _aggregationService; - List _libraries = []; + List _libraries = []; LibrariesLoadState _loadState = LibrariesLoadState.initial; String? _errorMessage; @@ -24,7 +24,7 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi Future? _inFlightLoad; /// Unmodifiable list of all libraries (filtered for supported types, ordered) - List get libraries => List.unmodifiable(_libraries); + List get libraries => List.unmodifiable(_libraries); /// Whether libraries are currently being loaded bool get isLoading => _loadState == LibrariesLoadState.loading; @@ -64,8 +64,10 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi safeNotifyListeners(); try { - // Fetch libraries from all servers - final allLibraries = await _aggregationService!.getLibrariesFromAllServers(); + // Fetch libraries from every connected backend (Plex + Jellyfin). + // The aggregation service converts Plex-typed responses to MediaLibrary + // internally; Jellyfin clients return MediaLibrary natively. + final allLibraries = await _aggregationService!.getMediaLibrariesFromAllServers(); // Filter out music libraries (not supported) final filteredLibraries = allLibraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).toList(); @@ -99,7 +101,7 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi } /// Update the library order and persist it. - Future updateLibraryOrder(List orderedLibraries) async { + Future updateLibraryOrder(List orderedLibraries) async { _libraries = List.from(orderedLibraries); safeNotifyListeners(); @@ -121,7 +123,7 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi } /// Apply saved library order to a list of libraries. - List _applyLibraryOrder(List libraries, List? savedOrder) { + List _applyLibraryOrder(List libraries, List? savedOrder) { if (savedOrder == null || savedOrder.isEmpty) { return libraries; } @@ -130,7 +132,7 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi final libraryMap = {for (final lib in libraries) lib.globalKey: lib}; // Build ordered list based on saved order - final orderedLibraries = []; + final orderedLibraries = []; for (final key in savedOrder) { final lib = libraryMap.remove(key); if (lib != null) { diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 9af1bf18..03e2bdfa 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../media/media_server_client.dart'; import '../models/livetv_dvr.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; import '../services/plex_client.dart'; import '../services/data_aggregation_service.dart'; import '../services/multi_server_manager.dart'; -import '../services/plex_auth_service.dart'; import '../utils/app_logger.dart'; /// Cached info about a DVR-enabled server @@ -40,6 +40,67 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Previously-seen set of online server IDs, used to detect new servers Set _previousOnlineServerIds = {}; + /// Visibility filter applied by the active app profile. `null` means + /// "all servers visible" (no profile restriction); otherwise only server + /// ids in the set surface through [serverIds] / [onlineServerIds]. + Set? _visibleServerIds; + + /// Replace the active visibility filter and notify listeners. Pass `null` + /// to clear the filter (all servers visible). Idempotent — does nothing + /// when [ids] equals the current filter. + void setVisibleServerIds(Set? ids) { + if (_visibleServerIds == null && ids == null) return; + if (_visibleServerIds != null && + ids != null && + _visibleServerIds!.length == ids.length && + _visibleServerIds!.containsAll(ids)) { + return; + } + _visibleServerIds = ids; + _pruneLiveTvServersForVisibility(); + safeNotifyListeners(); + _refreshLiveTvAvailabilitySoon(); + } + + /// Add [serverId] to the active visibility filter. Used after adding a + /// connection inline (without a profile switch), so the new server + /// becomes visible without the binder having to re-run. Initializes the + /// filter to a one-element set when no filter is currently set. + void addToVisibleServerIds(String serverId) { + final current = _visibleServerIds; + if (current == null) { + _visibleServerIds = {serverId}; + safeNotifyListeners(); + _refreshLiveTvAvailabilitySoon(); + return; + } + if (current.contains(serverId)) return; + _visibleServerIds = {...current, serverId}; + safeNotifyListeners(); + _refreshLiveTvAvailabilitySoon(); + } + + void _pruneLiveTvServersForVisibility() { + final filter = _visibleServerIds; + if (filter == null) return; + _liveTvServers.removeWhere((s) => !filter.contains(s.serverId)); + _hasLiveTv = _liveTvServers.isNotEmpty; + } + + void _refreshLiveTvAvailabilitySoon() { + scheduleMicrotask(() { + if (!isDisposed) unawaited(checkLiveTvAvailability()); + }); + } + + @visibleForTesting + void debugSetLiveTvServersForTesting(List servers) { + _liveTvServers + ..clear() + ..addAll(servers); + _hasLiveTv = servers.isNotEmpty; + } + MultiServerProvider(this._serverManager, this._aggregationService) { // Listen to server status changes _statusSubscription = _serverManager.statusStream.listen((_) { @@ -62,31 +123,76 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Get the data aggregation service DataAggregationService get aggregationService => _aggregationService; - /// Get client for specific server - PlexClient? getClientForServer(String serverId) { + /// Get client for specific server. + MediaServerClient? getClientForServer(String serverId) { return _serverManager.getClient(serverId); } - /// Get all online server IDs - List get onlineServerIds => _serverManager.onlineServerIds; + /// Get the [PlexClient] for a server, or `null` if the server is Jellyfin + /// (or not registered). Use for Plex-only flows that don't yet have a + /// backend-neutral equivalent. + PlexClient? getPlexClientForServer(String serverId) { + return _serverManager.getPlexClient(serverId); + } - /// Get all server IDs - List get serverIds => _serverManager.serverIds; + /// Get all online server IDs (visibility-filtered). + List get onlineServerIds { + final all = _serverManager.onlineServerIds; + final filter = _visibleServerIds; + if (filter == null) return all; + return all.where(filter.contains).toList(); + } - /// Check if a server is online + /// Get all server IDs (visibility-filtered). + List get serverIds { + final all = _serverManager.serverIds; + final filter = _visibleServerIds; + if (filter == null) return all; + return all.where(filter.contains).toList(); + } + + /// Check if a server is online (and visible under the active profile). bool isServerOnline(String serverId) { + final filter = _visibleServerIds; + if (filter != null && !filter.contains(serverId)) return false; return _serverManager.isServerOnline(serverId); } /// Get number of online servers - int get onlineServerCount => _serverManager.onlineServerIds.length; + int get onlineServerCount => onlineServerIds.length; /// Get number of total servers - int get totalServerCount => _serverManager.serverIds.length; + int get totalServerCount => serverIds.length; /// Check if any servers are connected bool get hasConnectedServers => onlineServerCount > 0; + /// 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. + bool get hasOnlinePlexServers => onlineServerIds.any((id) => _serverManager.getPlexClient(id) != null); + + /// Visibility-filtered server ids whose latest health probe was rejected + /// with HTTP 401/403 (token expired or revoked). UI uses this to show a + /// "Sign in again" banner distinct from generic "Server offline". + List get authErrorServerIds { + final all = _serverManager.authErrorServerIds; + final filter = _visibleServerIds; + if (filter == null) return all.toList(); + return all.where(filter.contains).toList(); + } + + /// Whether any visible server currently has an auth error. + bool get hasAuthErrorServers => authErrorServerIds.isNotEmpty; + + /// Display names for the visible auth-errored servers, in stable order. + /// Falls back to the server id when the client doesn't expose a name. + List<({String serverId, String displayName})> get authErrorServers { + return authErrorServerIds + .map((id) => (serverId: id, displayName: _serverManager.getClient(id)?.serverName ?? id)) + .toList(); + } + /// Clear all server connections void clearAllConnections() { _serverManager.disconnectAll(); @@ -94,52 +200,57 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi safeNotifyListeners(); } - /// Reconnect all servers after a profile switch - /// Clears existing connections and connects to all provided servers - Future reconnectWithServers(List servers, {String? clientIdentifier}) async { - // Clear existing connections first - _serverManager.disconnectAll(); - appLogger.d('MultiServerProvider: Cleared connections, reconnecting to ${servers.length} servers'); - - // Connect with new server tokens - final connectedCount = await _serverManager.connectToAllServers(servers, clientIdentifier: clientIdentifier); - - appLogger.i('MultiServerProvider: Reconnected to $connectedCount/${servers.length} servers after profile switch'); - safeNotifyListeners(); - return connectedCount; - } - /// Check server health for all connected servers Future checkServerHealth() async { await _serverManager.checkServerHealth(); // notifyListeners() will be called automatically via status stream } - /// Check all online servers for DVR/Live TV availability + /// 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 + /// flat channel list per server (synthesized into one [LiveTvServerInfo] + /// with `dvrKey: 'jellyfin'` so the rest of the UI's per-DVR loop works + /// uniformly). Future checkLiveTvAvailability() async { + if (isDisposed) return; final newLiveTvServers = []; for (final serverId in onlineServerIds) { - final client = getClientForServer(serverId); - if (client == null) continue; + final genericClient = _serverManager.getClient(serverId); + if (genericClient == null) continue; try { - final dvrs = await client.getDvrs(); - for (final dvr in dvrs) { - newLiveTvServers.add(LiveTvServerInfo(serverId: serverId, dvrKey: dvr.key, lineup: dvr.lineup, dvrs: dvrs)); + final liveTv = genericClient.liveTv; + final dvrs = await liveTv.fetchDvrs(); + if (dvrs.isNotEmpty) { + // Plex: one entry per DVR with its own lineup. + for (final dvr in dvrs) { + 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 [])); } } catch (e) { appLogger.d('LiveTV check failed for server $serverId', error: e); } } + final filter = _visibleServerIds; + final visibleLiveTvServers = filter == null + ? newLiveTvServers + : newLiveTvServers.where((s) => filter.contains(s.serverId)).toList(); + final hadLiveTv = _hasLiveTv; - final oldServerIds = _liveTvServers.map((s) => s.serverId).toSet(); - final newServerIds = newLiveTvServers.map((s) => s.serverId).toSet(); + final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet(); + final newServerIds = visibleLiveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet(); + if (isDisposed) return; _liveTvServers ..clear() - ..addAll(newLiveTvServers); - _hasLiveTv = newLiveTvServers.isNotEmpty; + ..addAll(visibleLiveTvServers); + _hasLiveTv = visibleLiveTvServers.isNotEmpty; // Notify when availability changes OR when the server set changes if (hadLiveTv != _hasLiveTv || !oldServerIds.containsAll(newServerIds) || !newServerIds.containsAll(oldServerIds)) { diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index 2affff20..9edb0ecb 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -16,17 +16,34 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi late bool _hasServerConnection; bool _isInitialized = false; - OfflineModeProvider(this._serverManager) : _hasServerConnection = _serverManager.onlineServerIds.isNotEmpty; + /// True once [MultiServerManager] has emitted its first server-status + /// snapshot. Until then we don't actually know whether any server is + /// online — the binder hasn't finished its first connect yet — so we + /// treat the app as online to avoid flashing the "offline" UI for the + /// few hundred ms it takes to come up. After the first emission we + /// trust the real flag. + bool _hasReceivedServerStatus = false; + + OfflineModeProvider(this._serverManager) : _hasServerConnection = _serverManager.onlineServerIds.isNotEmpty { + // Pre-seed the "received status" flag if there are already online + // servers (e.g. provider rebuilt mid-session) — otherwise we'd + // incorrectly say "online" after the manager already emitted. + if (_hasServerConnection) _hasReceivedServerStatus = true; + } /// Whether the app is currently in offline mode - /// Offline = no network OR no servers reachable + /// Offline = no network OR (we know servers are unreachable) @override - bool get isOffline => !_hasNetworkConnection || !_hasServerConnection; + bool get isOffline { + if (!_hasNetworkConnection) return true; + if (!_hasReceivedServerStatus) return false; + return !_hasServerConnection; + } /// Whether there is network connectivity (WiFi, mobile data, etc.) bool get hasNetworkConnection => _hasNetworkConnection; - /// Whether at least one Plex server is reachable + /// Whether at least one media server (Plex or Jellyfin) is reachable bool get hasServerConnection => _hasServerConnection; /// Updates network and server connection flags @@ -80,6 +97,7 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi _serverStatusSubscription = _serverManager.statusStream.listen((statusMap) { final wasOffline = isOffline; _hasServerConnection = statusMap.values.any((isOnline) => isOnline); + _hasReceivedServerStatus = true; if (wasOffline != isOffline) { safeNotifyListeners(); diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index 0f1c56b7..add7beb2 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -1,13 +1,13 @@ import 'package:flutter/foundation.dart'; import '../i18n/strings.g.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; import '../models/download_models.dart'; -import '../models/plex_metadata.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/settings_service.dart'; import '../utils/app_logger.dart'; -import '../utils/content_utils.dart'; import '../utils/snackbar_helper.dart'; import '../utils/watch_state_notifier.dart'; import 'download_provider.dart'; @@ -80,12 +80,12 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM // Fall back to cached metadata final metadata = _downloadProvider.getMetadata(globalKey); - return metadata?.viewOffset; + return metadata?.viewOffsetMs; } /// Get sorted episodes for a show (by season, then episode number). - List _getSortedEpisodes(String showRatingKey) { - final episodes = _downloadProvider.getDownloadedEpisodesForShow(showRatingKey); + List _getSortedEpisodes(String showId) { + final episodes = _downloadProvider.getDownloadedEpisodesForShow(showId); if (episodes.isEmpty) return episodes; // Sort Season 0 (Specials) to the end so regular seasons play first @@ -104,7 +104,7 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// Batch resolve watch statuses for a list of episodes. /// /// Returns a map of globalKey -> isWatched for each episode. - Future> _resolveEpisodeWatchStatuses(List episodes) async { + Future> _resolveEpisodeWatchStatuses(List episodes) async { if (episodes.isEmpty) return {}; final globalKeys = episodes.map((e) => e.globalKey).toSet(); @@ -125,8 +125,8 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// Episodes are sorted by season number, then episode number. /// /// Returns the next unwatched episode, or the first episode if all watched. - Future getNextUnwatchedEpisode(String showRatingKey) async { - final episodes = _getSortedEpisodes(showRatingKey); + Future getNextUnwatchedEpisode(String showId) async { + final episodes = _getSortedEpisodes(showId); if (episodes.isEmpty) return null; final watchStatuses = await _resolveEpisodeWatchStatuses(episodes); @@ -145,20 +145,22 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// Emit a watch state change event for immediate UI update. void _emitWatchStateChange({ required String serverId, - required String ratingKey, + required String itemId, required bool isNowWatched, required WatchStateChangeType changeType, + String? cacheServerId, }) { - final globalKey = buildGlobalKey(serverId, ratingKey); + final globalKey = buildGlobalKey(serverId, itemId); final metadata = _downloadProvider.getMetadata(globalKey); if (metadata != null) { - WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: isNowWatched); + WatchStateNotifier().notifyWatched(item: metadata, isNowWatched: isNowWatched, cacheServerId: cacheServerId); } else { - // Fallback: emit minimal event without parent chain + // Fallback: emit minimal event without parent chain. WatchStateNotifier().notify( WatchStateEvent( - ratingKey: ratingKey, + itemId: itemId, serverId: serverId, + cacheServerId: cacheServerId, changeType: changeType, parentChain: [], mediaType: 'unknown', @@ -171,24 +173,25 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// Mark an item as watched while offline. /// /// This queues the action for sync when online and emits a [WatchStateEvent]. - Future markAsWatched({required String serverId, required String ratingKey}) async { - await _syncService.queueMarkWatched(serverId: serverId, ratingKey: ratingKey); + Future markAsWatched({required String serverId, required String itemId}) async { + final cacheServerId = await _syncService.queueMarkWatched(serverId: serverId, itemId: itemId); _emitWatchStateChange( serverId: serverId, - ratingKey: ratingKey, + itemId: itemId, isNowWatched: true, changeType: WatchStateChangeType.watched, + cacheServerId: cacheServerId, ); safeNotifyListeners(); - _autoDeleteIfWatched(serverId, ratingKey); + _autoDeleteIfWatched(serverId, itemId); } /// Auto-delete a download if the auto-remove setting is enabled. - void _autoDeleteIfWatched(String serverId, String ratingKey) { + void _autoDeleteIfWatched(String serverId, String itemId) { final settings = SettingsService.instanceOrNull; if (settings == null || !settings.read(SettingsService.autoRemoveWatchedDownloads)) return; - final globalKey = buildGlobalKey(serverId, ratingKey); + final globalKey = buildGlobalKey(serverId, itemId); final meta = _downloadProvider.getMetadata(globalKey); if (meta == null) return; if (!meta.isEpisode && !meta.isMovie) return; @@ -212,13 +215,14 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// Mark an item as unwatched while offline. /// /// This queues the action for sync when online and emits a [WatchStateEvent]. - Future markAsUnwatched({required String serverId, required String ratingKey}) async { - await _syncService.queueMarkUnwatched(serverId: serverId, ratingKey: ratingKey); + Future markAsUnwatched({required String serverId, required String itemId}) async { + final cacheServerId = await _syncService.queueMarkUnwatched(serverId: serverId, itemId: itemId); _emitWatchStateChange( serverId: serverId, - ratingKey: ratingKey, + itemId: itemId, isNowWatched: false, changeType: WatchStateChangeType.unwatched, + cacheServerId: cacheServerId, ); safeNotifyListeners(); } @@ -227,8 +231,8 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// /// Returns a list of (episode, isWatched) pairs. /// Uses batched database query for efficiency. - Future> getEpisodesWithWatchStatus(String showRatingKey) async { - final episodes = _downloadProvider.getDownloadedEpisodesForShow(showRatingKey); + Future> getEpisodesWithWatchStatus(String showId) async { + final episodes = _downloadProvider.getDownloadedEpisodesForShow(showId); if (episodes.isEmpty) return []; final watchStatuses = await _resolveEpisodeWatchStatuses(episodes); diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 17245bb1..70fb297a 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -1,9 +1,16 @@ import 'package:flutter/foundation.dart'; -import '../models/plex_metadata.dart'; -import '../models/play_queue_response.dart'; -import '../services/plex_client.dart'; +import '../media/media_item.dart'; +import '../media/play_queue.dart'; +import '../models/plex/play_queue_response.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; +/// Fetches a window of items from a server-side play queue. Provider calls +/// this when the currently loaded window doesn't contain the next item it +/// needs to surface. Wired to a backend that maintains queues server-side +/// (Plex's `/playQueues`); left null for client-side queues (Jellyfin's +/// [LocalPlayQueue]) where the full list is already resident. +typedef PlayQueueWindowFetcher = Future Function(int playQueueId, {String? center, int window}); + /// Result of trying to locate the current queue index. class _IndexLookupResult { final int? index; @@ -23,14 +30,33 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { int? _currentPlayQueueItemID; // Windowed items (loaded around current position) - List _loadedItems = []; + List _loadedItems = []; final int _windowSize = 50; // Number of items to keep in memory + /// Synthetic per-item queue IDs for client-side queues (Jellyfin, etc.). + /// Parallel to [_loadedItems] — `_syntheticIds[i]` is the queue ID for + /// `_loadedItems[i]`. Empty when the queue is server-side (Plex), where + /// the real id lives on [PlexMediaItem.playQueueItemId]. + List _syntheticIds = const []; + String? _contextKey; // The show/season/playlist ratingKey for this session bool _isQueueMode = false; // Client reference for loading more items - PlexClient? _client; + PlayQueueWindowFetcher? _windowFetcher; + + /// Returns the queue id for [item] within the current queue. For Plex + /// items this is the server's `playQueueItemID`; for client-side queues + /// (Jellyfin) it's a synthetic index assigned in [setPlaybackFromLocalQueue]. + /// Returns null when [item] isn't in the current loaded window. + int? playQueueItemIdFor(MediaItem item) { + if (item is PlexMediaItem && item.playQueueItemId != null) { + return item.playQueueItemId; + } + final idx = _loadedItems.indexOf(item); + if (idx < 0 || idx >= _syntheticIds.length) return null; + return _syntheticIds[idx]; + } /// Whether shuffle mode is currently active bool get isShuffleActive => _playQueueShuffled; @@ -48,20 +74,22 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { int? get playQueueId => _playQueueId; /// The currently loaded queue items (windowed subset of full queue) - List get loadedItems => List.unmodifiable(_loadedItems); + List get loadedItems => List.unmodifiable(_loadedItems); /// The current play queue item ID int? get currentPlayQueueItemID => _currentPlayQueueItemID; /// Set the client reference for loading more items - void setClient(PlexClient client) { - _client = client; + void setPlayQueueWindowFetcher(PlayQueueWindowFetcher? fetcher) { + _windowFetcher = fetcher; } /// Update the current play queue item when playing a new item - void setCurrentItem(PlexMetadata metadata) { - if (_isQueueMode && metadata.playQueueItemID != null) { - _currentPlayQueueItemID = metadata.playQueueItemID; + void setCurrentItem(MediaItem metadata) { + if (!_isQueueMode) return; + final id = playQueueItemIdFor(metadata); + if (id != null) { + _currentPlayQueueItemID = id; safeNotifyListeners(); } } @@ -75,34 +103,62 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { _playQueueShuffled = playQueue.playQueueShuffled; _currentPlayQueueItemID = playQueue.playQueueSelectedItemID; - // Items are already tagged with server info by PlexClient + // Items arrive pre-tagged with server info by the producing mapper. _loadedItems = playQueue.items ?? []; + // Plex items carry their own playQueueItemId — no synthetic IDs needed. + _syntheticIds = const []; _contextKey = contextKey; _isQueueMode = true; safeNotifyListeners(); } + /// Initialize playback from a [LocalPlayQueue] (Jellyfin / any backend + /// without a server-side queue). Synthetic per-item queue IDs are + /// recorded in [_syntheticIds] (parallel to [_loadedItems]) so the + /// existing Plex-shaped UI — Queue sheet, content strip, current item + /// highlight — keeps working without a parallel rendering path. Items + /// themselves are stored unmutated. + /// + /// `playQueueId` is set to a sentinel so [isQueueActive] returns true. + /// Window-extension paths (`_ensureItemsLoaded`, `getNextEpisode`) consult + /// `_windowFetcher`, which stays null for client-side queues — JF callers + /// resolve adjacent items through [EpisodeNavigationService] instead. + void setPlaybackFromLocalQueue(LocalPlayQueue queue, {String? contextKey}) { + _playQueueId = -1; // sentinel for "client-side queue" + _playQueueTotalCount = queue.items.length; + _playQueueShuffled = queue.shuffled; + _loadedItems = List.of(queue.items); + _syntheticIds = [for (var i = 0; i < queue.items.length; i++) i]; + _currentPlayQueueItemID = queue.currentIndex; + _contextKey = contextKey; + _isQueueMode = true; + _windowFetcher = null; // disable server-side window extension + safeNotifyListeners(); + } + /// Load more items from the play queue if needed /// Returns true if more items were loaded Future _ensureItemsLoaded(int targetPlayQueueItemID) async { - if (_client == null || _playQueueId == null) return false; + if (_windowFetcher == null || _playQueueId == null) return false; - // Check if the target item is already loaded - final hasItem = _loadedItems.any((item) => item.playQueueItemID == targetPlayQueueItemID); + // Plex queues only — items are PlexMediaItem with a real playQueueItemId. + final hasItem = _loadedItems.whereType().any( + (item) => item.playQueueItemId == targetPlayQueueItemID, + ); if (hasItem) return true; // Load a window around the target item try { - final response = await _client!.getPlayQueue( + final response = await _windowFetcher!( _playQueueId!, center: targetPlayQueueItemID.toString(), window: _windowSize, ); if (response != null && response.items != null) { - // Items are already tagged with server info by PlexClient + // Items arrive pre-tagged with server info by the producing mapper. _loadedItems = response.items!; // Use size or items length as fallback if totalCount is null _playQueueTotalCount = response.playQueueTotalCount ?? response.size ?? response.items!.length; @@ -123,13 +179,13 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return const _IndexLookupResult(); } - var currentIndex = _loadedItems.indexWhere((item) => item.playQueueItemID == _currentPlayQueueItemID); + var currentIndex = _findLoadedIndex(_currentPlayQueueItemID!); if (currentIndex != -1) { return _IndexLookupResult(index: currentIndex); } - if (!loadIfMissing || _client == null || _playQueueId == null) { + if (!loadIfMissing || _windowFetcher == null || _playQueueId == null) { return const _IndexLookupResult(); } @@ -138,7 +194,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); } - currentIndex = _loadedItems.indexWhere((item) => item.playQueueItemID == _currentPlayQueueItemID); + currentIndex = _findLoadedIndex(_currentPlayQueueItemID!); if (currentIndex == -1) { return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); @@ -147,10 +203,26 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return _IndexLookupResult(index: currentIndex, attemptedLoad: true); } + /// Returns the index of the item with [playQueueItemId] in [_loadedItems], + /// or -1 if absent. Bridges Plex (real id on [PlexMediaItem]) and + /// client-side (synthetic id in [_syntheticIds]) queues. + int _findLoadedIndex(int playQueueItemId) { + for (var i = 0; i < _loadedItems.length; i++) { + final item = _loadedItems[i]; + if (item is PlexMediaItem && item.playQueueItemId == playQueueItemId) { + return i; + } + if (i < _syntheticIds.length && _syntheticIds[i] == playQueueItemId) { + return i; + } + } + return -1; + } + /// Gets the next item in the playback queue. /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted - Future getNextEpisode(String currentItemKey, {bool loopQueue = false}) async { + Future getNextEpisode(String currentItemKey, {bool loopQueue = false}) async { if (!_isQueueMode) { // For sequential mode, let the video player handle next episode return null; @@ -175,10 +247,10 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { if (currentIndex + 1 >= _playQueueTotalCount) { if (loopQueue && _playQueueTotalCount > 0) { // Loop back to beginning - load first item - if (_client != null && _playQueueId != null) { - final response = await _client!.getPlayQueue(_playQueueId!); + if (_windowFetcher != null && _playQueueId != null) { + final response = await _windowFetcher!(_playQueueId!); if (response != null && response.items != null && response.items!.isNotEmpty) { - // Items are already tagged with server info by PlexClient + // Items arrive pre-tagged with server info by the producing mapper. _loadedItems = response.items!; // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts return _loadedItems.first; @@ -190,9 +262,11 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { } // Need to load next window - if (_client != null && _playQueueId != null && _loadedItems.isNotEmpty) { - // Load next window centered on the item after current - final nextItemID = _loadedItems.last.playQueueItemID; + if (_windowFetcher != null && _playQueueId != null && _loadedItems.isNotEmpty) { + // Load next window centered on the item after current. Plex-only path + // — _windowFetcher != null implies queue items are PlexMediaItem. + final last = _loadedItems.last; + final nextItemID = last is PlexMediaItem ? last.playQueueItemId : null; if (nextItemID != null) { final loaded = await _ensureItemsLoaded(nextItemID + 1); if (loaded) { @@ -207,7 +281,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { /// Gets the previous item in the playback queue. /// Returns null if at the beginning of the queue or current item is not in queue. - Future getPreviousEpisode(String currentItemKey) async { + Future getPreviousEpisode(String currentItemKey) async { if (!_isQueueMode) { // For sequential mode, let the video player handle previous episode return null; @@ -228,8 +302,10 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { } // Need to load previous window - if (_client != null && _playQueueId != null && _loadedItems.isNotEmpty) { - final prevItemID = _loadedItems.first.playQueueItemID; + if (_windowFetcher != null && _playQueueId != null && _loadedItems.isNotEmpty) { + // Plex-only path — _windowFetcher != null implies items are PlexMediaItem. + final first = _loadedItems.first; + final prevItemID = first is PlexMediaItem ? first.playQueueItemId : null; if (prevItemID != null && prevItemID > 0) { final loaded = await _ensureItemsLoaded(prevItemID - 1); if (loaded) { @@ -248,6 +324,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { _playQueueShuffled = false; _currentPlayQueueItemID = null; _loadedItems = []; + _syntheticIds = const []; _contextKey = null; _isQueueMode = false; safeNotifyListeners(); diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index 88279e9c..89a1c047 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -1,380 +1,306 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; +import '../media/media_server_user_profile.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; -import '../utils/plex_http_exception.dart'; -import '../models/plex_home.dart'; -import '../models/plex_home_user.dart'; -import '../models/plex_user_profile.dart'; +import '../profiles/active_profile_provider.dart'; +import '../profiles/profile.dart'; +import '../profiles/profile_connection.dart'; +import '../profiles/profile_connection_registry.dart'; +import '../services/jellyfin_client.dart'; +import '../services/multi_server_manager.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; import '../utils/app_logger.dart'; -import '../screens/profile/pin_entry_dialog.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. +/// +/// Profile *identity* and *switching* are owned by [ActiveProfileProvider] +/// and [ActiveProfileBinder]. This provider is just the settings cache so +/// the video player can apply the active user's defaults. +/// +/// Plex settings are fetched with the *active Home user's token* (minted via +/// `/home/users/{uuid}/switch` and cached in +/// the parent [ProfileConnection.userToken], or stored on the +/// [ProfileConnection] row for local profiles). Falling back to the +/// account-owner's token would silently return the *owner's* settings — +/// wrong defaults for kid profiles, parental restrictions, etc. class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMixin { - PlexHome? _home; - PlexHomeUser? _currentUser; - PlexUserProfile? _profileSettings; + MediaServerUserProfile? _profileSettings; bool _isLoading = false; String? _error; bool _isInitialized = false; - PlexHome? get home => _home; - PlexHomeUser? get currentUser => _currentUser; - PlexUserProfile? get profileSettings => _profileSettings; + MediaServerUserProfile? get profileSettings => _profileSettings; bool get isLoading => _isLoading; String? get error => _error; - bool get hasMultipleUsers => _home?.hasMultipleUsers ?? false; - - bool get needsInitialProfileSelection => _home != null && _home!.users.isNotEmpty && _currentUser == null; PlexAuthService? _authService; StorageService? _storageService; + ConnectionRegistry? _connectionRegistry; + ProfileConnectionRegistry? _profileConnectionRegistry; + ActiveProfileProvider? _activeProfile; + MultiServerManager? _serverManager; + String? _lastSeenActiveId; + StreamSubscription>? _profileConnectionSubscription; + String? _watchedProfileConnectionProfileId; + ProfileConnectionRegistry? _watchedProfileConnectionRegistry; - // Callback for data invalidation when switching profiles - // Receives the list of servers with new profile tokens for reconnection - Future Function(List)? _onDataInvalidationRequested; - - /// Set a callback to be called when profile switching requires data invalidation - /// The callback receives the list of servers with the new profile's access tokens - void setDataInvalidationCallback(Future Function(List)? callback) { - _onDataInvalidationRequested = callback; + /// Wire the dependencies needed to resolve the active user's token / client. + /// May be called multiple times (proxy provider re-builds) — only the + /// most recent values are kept; we re-attach the listener on the new + /// [activeProfile] each time so settings refresh whenever the active + /// profile changes (or the binder finishes wiring up its token). + void attach({ + required ConnectionRegistry connections, + required ActiveProfileProvider activeProfile, + required ProfileConnectionRegistry profileConnections, + MultiServerManager? serverManager, + }) { + _connectionRegistry = connections; + final profileConnectionsChanged = !identical(_profileConnectionRegistry, profileConnections); + _profileConnectionRegistry = profileConnections; + _serverManager = serverManager; + if (!identical(_activeProfile, activeProfile)) { + _activeProfile?.removeListener(_onActiveProfileChanged); + _activeProfile = activeProfile; + _lastSeenActiveId = activeProfile.activeId; + activeProfile.addListener(_onActiveProfileChanged); + } + if (profileConnectionsChanged) { + _profileConnectionSubscription?.cancel(); + _profileConnectionSubscription = null; + _watchedProfileConnectionProfileId = null; + _watchedProfileConnectionRegistry = null; + } + _watchActiveProfileConnections(activeProfile.active); } - /// Trigger data invalidation for all screens with the new profile's servers - Future _invalidateAllData(List servers) async { - if (_onDataInvalidationRequested != null) { - await _onDataInvalidationRequested!(servers); - appLogger.d('Data invalidation triggered for profile switch with ${servers.length} servers'); + void _onActiveProfileChanged() { + final ap = _activeProfile; + if (ap == null) return; + // Only refresh on actual profile change, not on every binding-state + // tick — refreshProfileSettings awaits awaitBindingSettle internally + // so it'll always read the fresh post-bind token. + final id = ap.activeId; + if (id == _lastSeenActiveId) return; + _lastSeenActiveId = id; + _watchActiveProfileConnections(ap.active); + if (_isInitialized) unawaited(refreshProfileSettings()); + } + + void _watchActiveProfileConnections(Profile? profile) { + final registry = _profileConnectionRegistry; + final profileId = profile?.id; + if (identical(_watchedProfileConnectionRegistry, registry) && _watchedProfileConnectionProfileId == profileId) { + return; } + + _profileConnectionSubscription?.cancel(); + _profileConnectionSubscription = null; + _watchedProfileConnectionRegistry = registry; + _watchedProfileConnectionProfileId = profileId; + + if (registry == null || profileId == null) return; + _profileConnectionSubscription = registry.watchForProfile(profileId).listen((_) { + if (_isInitialized) unawaited(refreshProfileSettings()); + }); } Future initialize() async { - // Prevent duplicate initialization once we have usable data. - // If initialized state exists but home data is missing, retry bootstrap. - if (_isInitialized && _home != null) { - appLogger.d('UserProfileProvider: Already initialized, skipping'); + if (_isInitialized && _profileSettings != null) { return; } - if (_isInitialized && _home == null) { - appLogger.w('UserProfileProvider: Initialized but home data missing, retrying initialization'); - } - - appLogger.d('UserProfileProvider: Initializing...'); + appLogger.d('UserProfileProvider: initializing'); try { _authService = await PlexAuthService.create(); _storageService = await StorageService.getInstance(); - await _loadCachedData(); - // If no cached home data or it's expired, try to load from API - if (_home == null) { - appLogger.d('UserProfileProvider: No cached home data, attempting to load from API'); - try { - await loadHomeUsers(); - } catch (e) { - appLogger.w('UserProfileProvider: Failed to load home users during initialization', error: e); - // Don't set error here as it's not critical for app startup - } - } - - // Fetch fresh profile settings from API - appLogger.d('UserProfileProvider: Fetching profile settings from API'); try { await refreshProfileSettings(); } catch (e) { - appLogger.w('UserProfileProvider: Failed to fetch profile settings during initialization', error: e); - // Don't set error here, cached profile (if any) was already loaded + appLogger.w('UserProfileProvider: failed to fetch profile settings during initialization', error: e); } _isInitialized = true; - appLogger.d('UserProfileProvider: Initialization complete'); } catch (e) { - appLogger.e('UserProfileProvider: Critical initialization failure', error: e); + appLogger.e('UserProfileProvider: critical initialization failure', error: e); _setError('Failed to initialize profile services'); - // Ensure services are null on failure - _authService = null; - _storageService = null; - _isInitialized = false; // Allow retry on failure - } - } - - Future _loadCachedData() async { - if (_storageService == null) return; - - // Load cached home users - final cachedHomeData = _storageService!.getHomeUsersCache(); - if (cachedHomeData != null) { - try { - _home = PlexHome.fromJson(cachedHomeData); - } catch (e) { - appLogger.w('Failed to load cached home data', error: e); - } - } - - // Load current user UUID - final currentUserUUID = _storageService!.getCurrentUserUUID(); - if (currentUserUUID != null && _home != null) { - _currentUser = _home!.getUserByUUID(currentUserUUID); - } - - // Profile settings are NOT cached - they will be fetched fresh from API - // in refreshProfileSettings() - - safeNotifyListeners(); - } - - /// Fetch the user's profile settings from the API - Future refreshProfileSettings() async { - if (_authService == null || _storageService == null) { - appLogger.w('refreshProfileSettings: Services not initialized, skipping'); - return; - } - - appLogger.d('Fetching user profile settings from Plex API'); - try { - final currentToken = _storageService!.getPlexToken(); - if (currentToken == null) { - appLogger.w('refreshProfileSettings: No Plex token available, cannot fetch profile'); - return; - } - - final profile = await _authService!.getUserProfile(currentToken); - _profileSettings = profile; - - appLogger.i('Successfully fetched user profile settings from API'); - - safeNotifyListeners(); - } catch (e) { - appLogger.w('Failed to fetch user profile settings from API', error: e); - // Don't set error state, profile will remain null or keep existing value - } - } - - Future loadHomeUsers({bool forceRefresh = false}) async { - appLogger.d('loadHomeUsers called - forceRefresh: $forceRefresh'); - - // Auto-initialize services if not ready - if (_authService == null || _storageService == null) { - appLogger.d('loadHomeUsers: Services not initialized, initializing services...'); - _authService = await PlexAuthService.create(); - _storageService = await StorageService.getInstance(); - await _loadCachedData(); - - // Double-check after initialization - if (_authService == null || _storageService == null) { - appLogger.e('loadHomeUsers: Failed to initialize services'); - _setError('Failed to initialize services'); - return; - } - } - - // Use cached data if available and not forcing refresh - if (!forceRefresh && _home != null) { - appLogger.d('loadHomeUsers: Using cached data, users count: ${_home!.users.length}'); - return; - } - - _setLoading(true); - _clearError(); - - try { - final currentToken = _storageService!.getPlexToken(); - if (currentToken == null) { - throw Exception('No Plex.tv authentication token available'); - } - appLogger.d('loadHomeUsers: Using Plex.tv token'); - - appLogger.d('loadHomeUsers: Fetching home users from API'); - final home = await _authService!.getHomeUsers(currentToken); - _home = home; - - appLogger.i('loadHomeUsers: Success! Home users count: ${home.users.length}'); - appLogger.d('loadHomeUsers: Users: ${home.users.map((u) => u.displayName).join(', ')}'); - - // Cache the home data - await _storageService!.saveHomeUsersCache(home.toJson()); - - // Set current user if not already set - if (_currentUser == null) { - final currentUserUUID = _storageService!.getCurrentUserUUID(); - if (currentUserUUID != null) { - _currentUser = home.getUserByUUID(currentUserUUID); - appLogger.d('loadHomeUsers: Set current user from UUID: ${_currentUser?.displayName}'); - } else { - // Avoid auto-selecting protected profiles on first login. - // If there's exactly one unprotected profile, select it automatically. - if (home.users.length == 1 && !home.users.first.requiresPassword) { - _currentUser = home.users.first; - await _storageService!.saveCurrentUserUUID(_currentUser!.uuid); - appLogger.d('loadHomeUsers: Auto-selected only unprotected user: ${_currentUser?.displayName}'); - } else { - appLogger.d('loadHomeUsers: No current user selected yet, waiting for explicit profile selection'); - } - } - } - - safeNotifyListeners(); - } catch (e) { - _setError('Failed to load home users: $e'); - appLogger.e('Failed to load home users', error: e); - } finally { - _setLoading(false); - } - } - - Future switchToUser(PlexHomeUser user, BuildContext? context, {bool verifyPin = false}) async { - if (_authService == null || _storageService == null) { - _setError('Services not initialized'); - return false; - } - - if (user.uuid == _currentUser?.uuid && !(verifyPin && user.requiresPassword)) { - return true; - } - - _setLoading(true); - _clearError(); - - return await _attemptUserSwitch(user, context, null); - } - - Future _attemptUserSwitch(PlexHomeUser user, BuildContext? context, String? errorMessage) async { - try { - final currentToken = _storageService!.getPlexToken(); - if (currentToken == null) { - throw Exception('No Plex.tv authentication token available'); - } - - // Check if user requires PIN - String? pin; - if (user.requiresPassword && context != null && context.mounted) { - pin = await showPinEntryDialog(context, user.displayName, errorMessage: errorMessage); - - // User cancelled the PIN dialog - if (pin == null) { - _setLoading(false); - return false; - } - } - - final switchResponse = await _authService!.switchToUser(user.uuid, currentToken, pin: pin); - - // switchResponse.authToken is the new user's Plex.tv token - // Fetch servers with this token to get the proper server access tokens - appLogger.d('Got new user Plex.tv token, fetching servers...'); - - final servers = await _authService!.fetchServers(switchResponse.authToken); - if (servers.isEmpty) { - throw Exception('No servers available for this user'); - } - - appLogger.d('Fetched ${servers.length} servers for new profile'); - - // Save the new Plex.tv token for future profile operations - await _storageService!.savePlexToken(switchResponse.authToken); - - // Update current user UUID in storage - await _storageService!.saveCurrentUserUUID(user.uuid); - - // Update current user - _currentUser = user; - - // Update user profile settings (fresh from API) - _profileSettings = switchResponse.profile; - appLogger.d( - 'Updated profile settings for user: ${user.displayName}', - error: { - 'defaultAudioLanguage': _profileSettings?.defaultAudioLanguage ?? 'not set', - 'defaultSubtitleLanguage': _profileSettings?.defaultSubtitleLanguage ?? 'not set', - }, - ); - - safeNotifyListeners(); - - // Invalidate all cached data and reconnect to all servers with new tokens - // The callback will handle server reconnection using the servers list - await _invalidateAllData(servers); - - appLogger.d('Profile switch complete, all servers reconnected with new tokens'); - - appLogger.i('Successfully switched to user: ${user.displayName}'); - return true; - } catch (e) { - // Check if it's a PIN validation error - if (e is PlexHttpException && e.statusCode == 403) { - final errors = (e.responseData is Map) ? (e.responseData as Map)['errors'] as List? : null; - if (errors != null && errors.isNotEmpty) { - final errorCode = errors.first['code'] as int?; - final errorMessage = errors.first['message'] as String?; - - // Error code 1041 means invalid PIN - if (errorCode == 1041) { - appLogger.w('Invalid PIN for user: ${user.displayName}'); - _clearError(); // Clear any previous error state - - // Retry with error message if context is still available - if (context != null && context.mounted) { - return await _attemptUserSwitch(user, context, errorMessage ?? 'Incorrect PIN. Please try again.'); - } - - // If context not available, return false without showing error - appLogger.d('Cannot retry PIN entry - context not available'); - return false; - } - } - } - - // Only show error for non-PIN validation errors - _setError('Failed to switch user: $e'); - appLogger.e('Failed to switch to user: ${user.displayName}', error: e); - return false; - } finally { - _setLoading(false); - } - } - - Future refreshCurrentUser() async { - if (_currentUser != null) { - await loadHomeUsers(forceRefresh: true); - - // Update current user from refreshed data - if (_home != null) { - _currentUser = _home!.getUserByUUID(_currentUser!.uuid); - safeNotifyListeners(); - } - } - } - - Future logout() async { - if (_storageService == null) return; - - _setLoading(true); - - try { - await _storageService!.clearUserData(); - - // Clear user-specific provider state and reset initialization so - // the next sign-in performs a full bootstrap. - _home = null; - _currentUser = null; - _profileSettings = null; - _onDataInvalidationRequested = null; _authService = null; _storageService = null; _isInitialized = false; - - _clearError(); - safeNotifyListeners(); - - appLogger.i('User logged out successfully'); - } catch (e) { - appLogger.e('Error during logout', error: e); - } finally { - _setLoading(false); } } - void _setLoading(bool loading) { - _isLoading = loading; + /// Fetch the user's profile settings from the API. Best-effort: failures + /// leave [profileSettings] unchanged (cached or null). + Future refreshProfileSettings() async { + if (_authService == null || _storageService == null) { + _authService = await PlexAuthService.create(); + _storageService = await StorageService.getInstance(); + } + + // Wait for the binder to finish wiring up the active profile so we + // read the freshly-minted user-token rather than racing the cache. + await _activeProfile?.awaitBindingSettle(); + + 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'); + return; + } + final profile = await jellyfinClient.fetchUserProfile(); + if (profile != null) { + _profileSettings = profile; + safeNotifyListeners(); + } + return; + } + + final userToken = await _resolveActivePlexUserToken(preferred: settingsConnection); + if (userToken == null || userToken.isEmpty) { + appLogger.d('UserProfileProvider: no token for active profile, skipping settings refresh'); + return; + } + + try { + final profile = await _authService!.getUserProfile(userToken); + _profileSettings = profile; + safeNotifyListeners(); + } catch (e) { + appLogger.w('UserProfileProvider: failed to fetch user profile settings', error: e); + } + } + + JellyfinClient? _resolveJellyfinClient(JellyfinConnection conn) { + final manager = _serverManager; + if (manager == null) return null; + final client = manager.getClient(conn.serverMachineId); + return client is JellyfinClient ? client : null; + } + + /// Resolve the *active Home user's* plex.tv token, in priority order: + /// 1. The [ProfileConnection]'s `userToken`. For Plex Home profiles + /// this is the parent connection's row (written by + /// `_bindPlexHome`); for local profiles bound to a Plex account + /// it's the default join row (`listForProfile` orders default + /// first). + /// 2. The parent / first plex account's token as a last resort — + /// wrong user identity, but at least keeps the call from + /// no-op'ing for fresh installs that haven't completed a bind yet. + /// Returns `null` only when the device has no Plex account at all + /// (Jellyfin-only setup) or no profile is active. + Future _resolveActivePlexUserToken({ + ({ProfileConnection profileConnection, Connection connection})? preferred, + }) async { + final connections = _connectionRegistry; + final activeProfile = _activeProfile; + if (connections == null || activeProfile == null) return null; + + final profile = activeProfile.active; + if (profile == null) return null; + + final plexAccounts = (await connections.list()).whereType().toList(); + if (plexAccounts.isEmpty) return null; + + final pcRegistry = _profileConnectionRegistry; + + if (profile.kind == ProfileKind.plexHome) { + final parentId = profile.parentConnectionId; + final uuid = profile.plexHomeUserUuid; + if (parentId == null || uuid == null) return null; + if (pcRegistry != null) { + final pc = await pcRegistry.get(profile.id, parentId); + if (pc?.hasToken == true) return pc!.userToken; + } + // Pre-bind fallback: the binder hasn't run yet (or it failed), so + // there's no user-scoped token. Return the parent account token — + // it'll fetch the *owner's* settings, but that's still better than + // no settings at all on first launch. + for (final acc in plexAccounts) { + if (acc.id == parentId) return acc.accountToken; + } + return null; + } + + // Local profile — read the user-token off the default ProfileConnection + // (listForProfile orders default first). Each connection persists its + // own minted token, so this is already user-scoped. + final resolved = preferred ?? await _resolveActiveSettingsConnection(); + if (resolved?.connection is PlexAccountConnection && resolved!.profileConnection.hasToken) { + return resolved.profileConnection.userToken; + } + final resolvedConnection = resolved?.connection; + if (resolvedConnection is PlexAccountConnection) { + return resolvedConnection.accountToken; + } + return plexAccounts.first.accountToken; + } + + Future<({ProfileConnection profileConnection, Connection connection})?> _resolveActiveSettingsConnection() async { + final pcRegistry = _profileConnectionRegistry; + final activeProfile = _activeProfile; + final connections = _connectionRegistry; + if (pcRegistry == null || activeProfile == null || connections == null) return null; + + final profile = activeProfile.active; + if (profile == null || profile.kind == ProfileKind.plexHome) return null; + + final pcs = await pcRegistry.listForProfile(profile.id); + if (pcs.isEmpty) return null; + + final connectionsList = await connections.list(); + final byId = {for (final c in connectionsList) c.id: c}; + for (final pc in pcs) { + final conn = byId[pc.connectionId]; + if (conn != null) return (profileConnection: pc, connection: conn); + } + return null; + } + + @visibleForTesting + Future debugResolveActiveSettingsConnectionForTesting() async { + return (await _resolveActiveSettingsConnection())?.connection; + } + + @visibleForTesting + Future debugResolveActivePlexUserTokenForTesting() { + return _resolveActivePlexUserToken(); + } + + @visibleForTesting + String? get debugWatchedProfileConnectionProfileId => _watchedProfileConnectionProfileId; + + /// Logout — clear settings and credentials. Called from the discover + /// screen "sign out" action; the rest of the teardown (clearing + /// connections, profiles, etc.) happens in the screen's logout flow. + Future logout() async { + _isLoading = true; safeNotifyListeners(); + try { + _storageService ??= await StorageService.getInstance(); + await _storageService!.clearUserData(); + _profileSettings = null; + _authService = null; + _storageService = null; + _isInitialized = false; + _clearError(); + appLogger.i('UserProfileProvider: logged out'); + } catch (e) { + appLogger.e('UserProfileProvider: logout error', error: e); + } finally { + _isLoading = false; + safeNotifyListeners(); + } } void _setError(String error) { @@ -385,4 +311,11 @@ class UserProfileProvider extends ChangeNotifier with DisposableChangeNotifierMi void _clearError() { _error = null; } + + @override + void dispose() { + _activeProfile?.removeListener(_onActiveProfileChanged); + _profileConnectionSubscription?.cancel(); + super.dispose(); + } } diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index 84f01f67..7d9f3142 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -1,16 +1,24 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../models/plex_metadata.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../services/plex_client.dart'; +import '../utils/provider_extensions.dart'; import '../widgets/desktop_app_bar.dart'; -import '../widgets/plex_optimized_image.dart'; -import '../utils/plex_image_helper.dart'; +import '../widgets/optimized_media_image.dart'; +import '../utils/media_image_helper.dart'; import '../i18n/strings.g.dart'; import 'base_media_list_detail_screen.dart'; import 'focusable_detail_screen_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart'; import '../focus/focusable_action_bar.dart'; -/// Screen to browse all media featuring a specific actor +/// Screen to browse all media featuring a specific actor. +/// +/// Plex-only today: uses `fetchAllPersonMediaAsMediaItems` which has no +/// Jellyfin counterpart yet. Callers must guard the navigation by backend +/// (see `_navigateToActorMedia` in media_detail_screen.dart). class ActorMediaScreen extends StatefulWidget { final String actorName; final String personId; @@ -18,6 +26,7 @@ class ActorMediaScreen extends StatefulWidget { final String? characterName; final String serverId; final String? serverName; + final MediaBackend backend; const ActorMediaScreen({ super.key, @@ -27,6 +36,7 @@ class ActorMediaScreen extends StatefulWidget { this.characterName, required this.serverId, this.serverName, + required this.backend, }); @override @@ -39,7 +49,13 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen GridFocusNodeMixin, FocusableDetailScreenMixin { @override - PlexMetadata get mediaItem => PlexMetadata(ratingKey: '', serverId: widget.serverId, serverName: widget.serverName); + MediaItem get mediaItem => MediaItem( + id: '', + backend: widget.backend, + kind: MediaKind.unknown, + serverId: widget.serverId, + serverName: widget.serverName, + ); @override String get title => widget.actorName; @@ -56,9 +72,12 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen super.dispose(); } + PlexClient get _plexClient => context.getPlexClientForServer(widget.serverId); + @override - Future> fetchItems() async { - return await client.fetchAllPersonMedia(widget.personId); + Future> fetchItems() async { + // Plex-only — guarded at the call site in media_detail_screen.dart. + return _plexClient.fetchAllPersonMediaAsMediaItems(widget.personId); } @override @@ -81,8 +100,8 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen children: [ ClipRRect( borderRadius: BorderRadius.circular(40), - child: PlexOptimizedImage( - client: client, + child: OptimizedMediaImage( + client: _plexClient, imagePath: widget.actorThumb, width: 80, height: 80, diff --git a/lib/screens/auth/plex_pin_auth_flow.dart b/lib/screens/auth/plex_pin_auth_flow.dart new file mode 100644 index 00000000..8c046a5a --- /dev/null +++ b/lib/screens/auth/plex_pin_auth_flow.dart @@ -0,0 +1,299 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../i18n/strings.g.dart'; +import '../../services/plex_auth_service.dart'; +import '../../theme/mono_tokens.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/platform_detector.dart'; + +/// Self-contained Plex PIN/QR auth flow. +/// +/// Renders the polling UI (QR code or browser-waiting spinner) once an +/// auth attempt is started via [PlexPinAuthFlowController.startBrowser] / +/// [PlexPinAuthFlowController.startQr]. When polling resolves successfully +/// it invokes [onTokenReceived(token)]; the parent decides what to do next +/// (the legacy [AuthScreen] connects to all servers + navigates to +/// MainScreen, while [AddPlexAccountScreen] pops with success or routes +/// into the borrow flow). +/// +/// Both screens previously implemented this flow inline — same `PlexAuthService` +/// orchestration, same QR widget, same browser-waiting state. Extracting +/// here removes ~300 lines of duplicate UI code and centralises the +/// poll-cancel-retry plumbing. +class PlexPinAuthFlow extends StatefulWidget { + /// Fires when the user successfully claims the PIN. The token is the raw + /// `X-Plex-Token` value — parent code is responsible for exchanging it + /// for a [PlexAccountConnection] (account label + servers list). + final Future Function(String token) onTokenReceived; + + /// QR size on mobile / narrow layouts. + final double mobileQrSize; + + /// QR size on desktop / wide layouts (where the auth screen has more + /// horizontal room). The two-column login screen uses 300; the bottom-sheet + /// add-account screen uses 200. + final double desktopQrSize; + + /// When `true` and running on TV, auto-start the QR flow on first build so + /// the user doesn't have to navigate to the QR button with the remote. + final bool autoStartQrOnTV; + + /// Override the QR-vs-browser default before any user interaction. Useful + /// for callers that want to force one mode (the add-account screen + /// auto-starts QR on TV; the legacy login screen offers both). + final bool? initialUseQr; + + /// Optional builder for the initial action buttons. The default (`null`) + /// shows two buttons — "Sign in with Plex" (browser) and "Show QR Code". + /// Pass a custom builder when the parent wants to integrate the buttons + /// into a richer layout (extra Jellyfin button, debug button, branding). + final Widget Function(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy)? + initialButtonsBuilder; + + const PlexPinAuthFlow({ + super.key, + required this.onTokenReceived, + this.mobileQrSize = 200, + this.desktopQrSize = 300, + this.autoStartQrOnTV = true, + this.initialUseQr, + this.initialButtonsBuilder, + }); + + @override + State createState() => _PlexPinAuthFlowState(); +} + +class _PlexPinAuthFlowState extends State { + PlexAuthService? _authService; + bool _isPolling = false; + bool _useQr = false; + String? _qrAuthUrl; + int _attemptId = 0; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _useQr = widget.initialUseQr ?? PlatformDetector.isTV(); + unawaited(_initService()); + } + + Future _initService() async { + final svc = await PlexAuthService.create(); + if (!mounted) { + svc.dispose(); + return; + } + setState(() { + _authService = svc; + }); + if (widget.autoStartQrOnTV && PlatformDetector.isTV()) { + unawaited(_start(useQr: true)); + } + } + + @override + void dispose() { + _attemptId++; + _authService?.dispose(); + super.dispose(); + } + + Future _start({required bool useQr}) async { + final svc = _authService; + if (svc == null) return; + final attemptId = ++_attemptId; + setState(() { + _useQr = useQr; + _isPolling = true; + _errorMessage = null; + _qrAuthUrl = null; + }); + + try { + final pinData = await svc.createPin(); + if (!_isCurrentAttempt(attemptId)) return; + final pinId = pinData['id'] as int; + final pinCode = pinData['code'] as String; + final url = svc.getAuthUrl(pinCode); + + if (!_isCurrentAttempt(attemptId)) return; + if (useQr) { + setState(() => _qrAuthUrl = url); + } else { + final uri = Uri.parse(url); + try { + final mode = PlatformDetector.isTV() ? LaunchMode.inAppWebView : LaunchMode.inAppBrowserView; + await launchUrl(uri, mode: mode); + } catch (_) { + // Chrome Custom Tabs may not be available — fall back to default + // external browser. + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + final token = await svc.pollPinUntilClaimed(pinId, shouldCancel: () => attemptId != _attemptId); + if (!_isCurrentAttempt(attemptId)) return; + + if (token == null) { + setState(() { + _isPolling = false; + _qrAuthUrl = null; + _errorMessage = t.auth.authenticationTimeout; + }); + return; + } + + // Auto-close the in-app browser on mobile (no-op on desktop / when + // already closed). + if (!useQr) { + try { + await closeInAppWebView(); + } catch (_) {} + } + + if (!_isCurrentAttempt(attemptId)) return; + setState(() { + _qrAuthUrl = null; + }); + await widget.onTokenReceived(token); + if (!_isCurrentAttempt(attemptId)) return; + setState(() { + _isPolling = false; + }); + } catch (e) { + appLogger.w('Plex PIN auth failed', error: e); + if (!_isCurrentAttempt(attemptId)) return; + setState(() { + _isPolling = false; + _qrAuthUrl = null; + _errorMessage = e.toString(); + }); + } + } + + bool _isCurrentAttempt(int attemptId) => mounted && attemptId == _attemptId; + + void _retry() { + final useQr = _useQr; + _attemptId++; + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) unawaited(_start(useQr: useQr)); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (_isPolling) { + final isDesktop = MediaQuery.sizeOf(context).width > 700; + if (_useQr && _qrAuthUrl != null) { + return _buildQr(theme, isDesktop ? widget.desktopQrSize : widget.mobileQrSize); + } + return _buildBrowserWaiting(theme); + } + + final builder = widget.initialButtonsBuilder ?? _defaultInitialButtons; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + builder(context, () => _start(useQr: false), () => _start(useQr: true), _authService == null), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + ], + ], + ); + } + + Widget _defaultInitialButtons(BuildContext context, VoidCallback browser, VoidCallback qr, bool busy) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton(onPressed: busy ? null : browser, child: Text(t.auth.signInWithPlex)), + const SizedBox(height: 12), + OutlinedButton(onPressed: busy ? null : qr, child: Text(t.auth.showQRCode)), + ], + ); + } + + Widget _buildQr(ThemeData theme, double qrSize) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.auth.scanQRToSignIn, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey), + ), + const SizedBox(height: 24), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusMd), + child: QrImageView( + data: _qrAuthUrl!, + size: qrSize, + version: QrVersions.auto, + backgroundColor: Colors.white, + ), + ), + ), + const SizedBox(height: 24), + OutlinedButton( + onPressed: _retry, + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)), + child: Text(t.common.retry), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + ], + ], + ); + } + + Widget _buildBrowserWaiting(ThemeData theme) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Center(child: CircularProgressIndicator()), + const SizedBox(height: 16), + Text( + t.auth.waitingForAuth, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey), + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: _retry, + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)), + child: Text(t.common.retry), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + ], + ], + ); + } +} diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index fb30d83f..5ab058f3 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -2,24 +2,25 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:qr_flutter/qr_flutter.dart'; +import '../connection/connection.dart'; +import '../connection/connection_registry.dart'; +import '../profiles/active_profile_provider.dart'; +import '../profiles/plex_home_service.dart'; +import '../profiles/profile.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; -import '../services/server_registry.dart'; -import '../services/server_connection_orchestrator.dart'; -import '../providers/multi_server_provider.dart'; -import '../providers/libraries_provider.dart'; import '../providers/user_profile_provider.dart'; -import '../services/offline_watch_sync_service.dart'; import '../i18n/strings.g.dart'; -import '../theme/mono_tokens.dart'; import '../utils/app_logger.dart'; import '../utils/platform_detector.dart'; import '../focus/focusable_button.dart'; +import '../media/media_backend.dart'; import '../utils/navigation_transitions.dart'; +import '../widgets/backend_badge.dart'; import '../widgets/dialog_action_button.dart'; +import 'auth/plex_pin_auth_flow.dart'; import 'main_screen.dart'; +import 'settings/add_jellyfin_screen.dart'; class AuthScreen extends StatefulWidget { const AuthScreen({super.key}); @@ -31,31 +32,56 @@ class AuthScreen extends StatefulWidget { class _AuthScreenState extends State { bool _isAuthenticating = false; String? _errorMessage; - late PlexAuthService _authService; - bool _shouldCancelPolling = false; - bool _useQrFlow = false; // whether current auth attempt is QR based - String? _qrAuthUrl; // auth URL rendered as QR + // Reuse a one-shot service for the debug-token verify path; the Plex + // PIN/QR flow inside [PlexPinAuthFlow] owns its own service instance. + PlexAuthService? _verifyOnlyService; @override void initState() { super.initState(); - _initializeAuthService(); + unawaited(_initVerifyService()); } - Future _initializeAuthService() async { - _authService = await PlexAuthService.create(); - - // On Android TV, auto-start QR code flow - if (PlatformDetector.isTV()) { - if (!mounted) return; - setState(() { - _useQrFlow = true; - }); - unawaited(_startAuthentication()); + Future _initVerifyService() async { + final svc = await PlexAuthService.create(); + if (!mounted) { + svc.dispose(); + return; } + setState(() => _verifyOnlyService = svc); } - /// Connect to all available servers and navigate to main screen + @override + void dispose() { + _verifyOnlyService?.dispose(); + super.dispose(); + } + + /// Auto-select the active profile after sign-in *only* when there's a + /// single Plex Home user — there's no choice for the user to make. With + /// multiple Home users (the "real" Home case) we leave the active id + /// unset so [MainScreen] forces the picker before the binder runs, + /// avoiding a surprise PIN prompt on whichever user we'd otherwise + /// pre-select. + Future _selectInitialProfile( + PlexHomeService plexHome, + ActiveProfileProvider activeProfiles, + PlexAccountConnection accountConn, + ) async { + await activeProfiles.initialize(); + final profile = initialPlexHomeProfileFromCache(plexHome, accountConn); + if (profile == null) { + await activeProfiles.clearActiveProfile(); + return; + } + await activeProfiles.activate(profile); + } + + /// Persist the new Plex account into the connection pipeline and + /// navigate to the main screen. The [ActiveProfileBinder] (mounted by + /// [MainScreen]) takes over from there: it picks up the active profile + /// id we set below and connects servers via + /// [MultiServerManager.refreshTokensForProfile]. Future _connectToAllServersAndNavigate(String plexToken) async { if (!mounted) return; @@ -64,13 +90,17 @@ class _AuthScreenState extends State { _errorMessage = null; }); + final connectionRegistry = context.read(); + final plexHome = context.read(); + final svc = await PlexAuthService.create(); + try { - // Fetch user info and servers for this user - final userInfo = await _authService.getUserInfo(plexToken); + final userInfo = await svc.getUserInfo(plexToken); final username = userInfo['username'] as String? ?? ''; final email = userInfo['email'] as String? ?? ''; + final accountUuid = (userInfo['uuid'] as String?)?.trim() ?? ''; - final servers = await _authService.fetchServers(plexToken); + final servers = await svc.fetchServers(plexToken); final storage = await StorageService.getInstance(); if (servers.isEmpty) { @@ -83,39 +113,31 @@ class _AuthScreenState extends State { return; } - // Save all servers to registry (all servers are considered enabled) - final registry = ServerRegistry(storage); - await registry.saveServers(servers); - - if (!mounted) return; - - // Start profile initialization in parallel with server connection. - // The home users API (clients.plex.tv) is independent of server connections. - final profileFuture = context.read().initialize(); - - final result = await ServerConnectionOrchestrator.connectAndInitialize( + final clientId = await storage.getOrCreateClientIdentifier(); + final accountConnection = PlexAccountConnection( + // Key the row by the plex.tv account UUID so signing into a second + // Plex account on the same device produces a distinct row. The + // clientIdentifier is per-device and would collide. Falls back to + // clientId only if plex.tv didn't return a uuid (rare). + id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}', + accountToken: plexToken, + clientIdentifier: clientId, + accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'), servers: servers, - multiServerProvider: context.read(), - librariesProvider: context.read(), - syncService: context.read(), - clientIdentifier: _authService.clientIdentifier, + createdAt: DateTime.now(), + lastAuthenticatedAt: DateTime.now(), ); - - if (!result.hasConnections || result.firstClient == null) { - if (!mounted) return; - setState(() { - _isAuthenticating = false; - _errorMessage = t.serverSelection.allServerConnectionsFailed; - }); - return; - } - - // Wait for profile init to finish before navigating so MainScreen - // has home user data available immediately. - await profileFuture; + await connectionRegistry.upsert(accountConnection); + await plexHome.refresh(accountConnection); + if (!mounted) return; + await _selectInitialProfile(plexHome, context.read(), accountConnection); if (!mounted) return; - unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!)))); + + await context.read().initialize(); + + if (!mounted) return; + unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen()))); } catch (e) { appLogger.e('Failed to connect to servers', error: e); if (!mounted) return; @@ -123,190 +145,30 @@ class _AuthScreenState extends State { _isAuthenticating = false; _errorMessage = t.serverSelection.failedToLoadServers(error: e); }); + } finally { + svc.dispose(); } } - Future _startAuthentication() async { - setState(() { - _isAuthenticating = true; - _errorMessage = null; - _shouldCancelPolling = false; - // preserve _useQrFlow as chosen prior to calling - if (!_useQrFlow) { - _qrAuthUrl = null; // ensure stale QR cleared for browser flow - } - }); - - try { - // Create a PIN - final pinData = await _authService.createPin(); - final pinId = pinData['id'] as int; - final pinCode = pinData['code'] as String; - - // Construct auth URL - final authUrl = _authService.getAuthUrl(pinCode); - - if (!mounted) return; - if (_useQrFlow) { - // Display QR instead of launching browser - setState(() { - _qrAuthUrl = authUrl; - }); - } else { - // Open browser (in-app for mobile, external for desktop) - final uri = Uri.parse(authUrl); - if (await canLaunchUrl(uri)) { - // On TV, use inAppWebView (simpler WebView) instead of Chrome Custom Tabs - final mode = PlatformDetector.isTV() ? LaunchMode.inAppWebView : LaunchMode.inAppBrowserView; - try { - await launchUrl(uri, mode: mode); - } catch (_) { - // Chrome Custom Tabs may not be available (e.g. no Chrome installed). - // Fall back to opening in the default external browser. - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } else { - throw Exception(t.errors.couldNotLaunchUrl); - } - } - - // Poll for authentication with cancellation support - final token = await _authService.pollPinUntilClaimed(pinId, shouldCancel: () => _shouldCancelPolling); - - // If polling was cancelled, don't show error - if (_shouldCancelPolling) { - return; - } - - if (!mounted) return; - if (token == null) { - setState(() { - _isAuthenticating = false; - _errorMessage = t.auth.authenticationTimeout; - }); - return; - } - - // Auto-close the in-app browser on mobile (no-op on desktop) - if (!_useQrFlow) { - try { - await closeInAppWebView(); - } catch (e) { - // Ignore errors - browser might already be closed or on desktop - } - } - - // Store the token - final storage = await StorageService.getInstance(); - await storage.savePlexToken(token); - - // Clear QR URL after successful auth - if (!mounted) return; - setState(() { - _qrAuthUrl = null; - _useQrFlow = false; - }); - - // Connect to all servers and navigate to main screen - if (mounted) { - await _connectToAllServersAndNavigate(token); - } - } catch (e) { - if (!mounted) return; - setState(() { - _isAuthenticating = false; - _errorMessage = t.errors.authenticationFailed(error: e); - }); - } - } - - void _retryAuthentication() { - setState(() { - _shouldCancelPolling = true; - _isAuthenticating = false; - _qrAuthUrl = null; - }); - // Start new authentication after a brief delay to ensure cleanup - Future.delayed(const Duration(milliseconds: 100), _startAuthentication); - } - void _handleDebugTap() { if (!kDebugMode) return; _showDebugTokenDialog(); } - void _showDebugTokenDialog() { - final tokenController = TextEditingController(); - String? errorMessage; + Future _connectToJellyfin() async { + final added = await Navigator.push(context, MaterialPageRoute(builder: (_) => const AddJellyfinScreen())); + 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 + // from the server provider, so no client argument is needed here. + unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen()))); + } + void _showDebugTokenDialog() { showDialog( context: context, builder: (BuildContext context) { - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: const Text('Debug: Enter Plex Token'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: tokenController, - decoration: InputDecoration( - labelText: 'Plex Auth Token', - hintText: 'Enter your Plex.tv token', - errorText: errorMessage, - border: const OutlineInputBorder(), - ), - obscureText: true, - maxLines: 1, - ), - ], - ), - actions: [ - DialogActionButton(onPressed: () => Navigator.of(context).pop(), label: t.common.cancel), - DialogActionButton( - onPressed: () async { - final token = tokenController.text.trim(); - if (token.isEmpty) { - setDialogState(() { - errorMessage = t.errors.pleaseEnterToken; - }); - return; - } - - final navigator = Navigator.of(context); - - try { - final isValid = await _authService.verifyToken(token); - if (!isValid) { - setDialogState(() { - errorMessage = t.errors.invalidToken; - }); - return; - } - - // Store the token - final storage = await StorageService.getInstance(); - await storage.savePlexToken(token); - - // Close dialog and connect to all servers - if (mounted) { - navigator.pop(); - await _connectToAllServersAndNavigate(token); - } - } catch (e) { - setDialogState(() { - errorMessage = t.errors.failedToVerifyToken(error: e); - }); - } - }, - label: t.auth.authenticate, - isPrimary: true, - ), - ], - ); - }, - ); + return _DebugTokenDialog(verifyService: _verifyOnlyService, onTokenAccepted: _connectToAllServersAndNavigate); }, ); } @@ -325,7 +187,6 @@ class _AuthScreenState extends State { ? Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // First column - Logo and title (always visible) Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -342,22 +203,13 @@ class _AuthScreenState extends State { ), ), const SizedBox(width: 48), - // Second column - All authentication content Expanded( child: Center( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_isAuthenticating) ...[ - if (_useQrFlow && _qrAuthUrl != null) - _buildQrAuthWidget(qrSize: 300) - else - _buildBrowserAuthWidget(), - ] else - _buildInitialButtons(), - ], + children: [_buildAuthBody()], ), ), ), @@ -377,13 +229,7 @@ class _AuthScreenState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 48), - if (_isAuthenticating) ...[ - if (_useQrFlow && _qrAuthUrl != null) - _buildQrAuthWidget(qrSize: 200) - else - _buildBrowserAuthWidget(), - ] else - _buildInitialButtons(), + _buildAuthBody(), ], ), ), @@ -392,62 +238,100 @@ class _AuthScreenState extends State { ); } - /// Builds the initial authentication buttons (before auth starts) - Widget _buildInitialButtons() { + Widget _buildAuthBody() { + if (_isAuthenticating) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Center(child: CircularProgressIndicator()), + const SizedBox(height: 16), + Text( + t.auth.waitingForAuth, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey), + ), + ], + ); + } + return PlexPinAuthFlow( + onTokenReceived: _connectToAllServersAndNavigate, + initialButtonsBuilder: _buildInitialButtons, + ); + } + + Widget _buildInitialButtons(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy) { final isTV = PlatformDetector.isTV(); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (isTV) ...[ - // On TV: QR is primary, browser is secondary FocusableButton( autofocus: true, - onPressed: () { - setState(() { - _useQrFlow = true; - }); - _startAuthentication(); - }, + onPressed: busy ? null : startQr, child: ElevatedButton( - onPressed: () { - setState(() { - _useQrFlow = true; - }); - _startAuthentication(); - }, + onPressed: busy ? null : startQr, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(t.auth.showQRCode), ), ), const SizedBox(height: 12), FocusableButton( - onPressed: _startAuthentication, + onPressed: busy ? null : startBrowser, child: OutlinedButton( - onPressed: _startAuthentication, + onPressed: busy ? null : startBrowser, style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(t.auth.useBrowser), ), ), ] else ...[ - // On other platforms: Browser is primary, QR is secondary - ElevatedButton( - onPressed: _startAuthentication, + ElevatedButton.icon( + onPressed: busy ? null : startBrowser, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), - child: Text(t.auth.signInWithPlex), + icon: const BackendBadge(backend: MediaBackend.plex, size: 18), + label: Text(t.auth.signInWithPlex), ), const SizedBox(height: 12), OutlinedButton( - onPressed: () { - setState(() { - _useQrFlow = true; - }); - _startAuthentication(); - }, + onPressed: busy ? null : startQr, style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(t.auth.showQRCode), ), ], + const SizedBox(height: 24), + Row( + children: [ + Expanded(child: Divider(color: Theme.of(context).colorScheme.outlineVariant)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + t.auth.or, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)), + ), + ), + Expanded(child: Divider(color: Theme.of(context).colorScheme.outlineVariant)), + ], + ), + const SizedBox(height: 12), + if (isTV) + FocusableButton( + onPressed: _connectToJellyfin, + child: OutlinedButton.icon( + onPressed: _connectToJellyfin, + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18), + label: Text(t.auth.connectToJellyfin), + ), + ) + else + OutlinedButton.icon( + onPressed: _connectToJellyfin, + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18), + label: Text(t.auth.connectToJellyfin), + ), if (kDebugMode) ...[ const SizedBox(height: 12), OutlinedButton( @@ -470,99 +354,101 @@ class _AuthScreenState extends State { ], ); } +} - Widget _buildRetryButton() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 24), - OutlinedButton( - onPressed: _retryAuthentication, - style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)), - child: Text(t.common.retry), - ), - ], - ); +@visibleForTesting +Profile? initialPlexHomeProfileFromCache(PlexHomeService plexHome, PlexAccountConnection accountConn) { + final users = plexHome.current[accountConn.id]; + if (users == null || users.length != 1) return null; + return Profile.virtualPlexHome(connectionId: accountConn.id, homeUser: users.single); +} + +/// Stateful so the [TextEditingController] is disposed when the dialog +/// closes — the previous inline `showDialog` builder created the +/// controller in a closure and leaked it on every dismissal. +class _DebugTokenDialog extends StatefulWidget { + final PlexAuthService? verifyService; + final Future Function(String token) onTokenAccepted; + + const _DebugTokenDialog({required this.verifyService, required this.onTokenAccepted}); + + @override + State<_DebugTokenDialog> createState() => _DebugTokenDialogState(); +} + +class _DebugTokenDialogState extends State<_DebugTokenDialog> { + final TextEditingController _tokenController = TextEditingController(); + String? _errorMessage; + bool _busy = false; + + @override + void dispose() { + _tokenController.dispose(); + super.dispose(); } - /// Builds the QR code authentication widget - Widget _buildQrAuthWidget({required double qrSize}) { - final isTV = PlatformDetector.isTV(); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - t.auth.scanQRToSignIn, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey), - ), - const SizedBox(height: 24), - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(tokens(context).radiusMd), - child: QrImageView( - data: _qrAuthUrl!, - size: qrSize, - version: QrVersions.auto, - backgroundColor: Colors.white, + Future _submit() async { + final token = _tokenController.text.trim(); + if (token.isEmpty) { + setState(() => _errorMessage = t.errors.pleaseEnterToken); + return; + } + final svc = widget.verifyService; + if (svc == null) { + setState(() => _errorMessage = 'Auth service not ready'); + return; + } + final navigator = Navigator.of(context); + setState(() { + _errorMessage = null; + _busy = true; + }); + try { + final isValid = await svc.verifyToken(token); + if (!mounted) return; + if (!isValid) { + setState(() { + _errorMessage = t.errors.invalidToken; + _busy = false; + }); + return; + } + navigator.pop(); + await widget.onTokenAccepted(token); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = t.errors.failedToVerifyToken(error: e); + _busy = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Debug: Enter Plex Token'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _tokenController, + decoration: InputDecoration( + labelText: 'Plex Auth Token', + hintText: 'Enter your Plex.tv token', + errorText: _errorMessage, + border: const OutlineInputBorder(), ), + obscureText: true, + maxLines: 1, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _busy ? null : _submit(), ), - ), - // On TV, show retry and browser buttons in a row - if (isTV) ...[ - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FocusableButton( - autofocus: true, - onPressed: _retryAuthentication, - child: OutlinedButton( - onPressed: _retryAuthentication, - style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)), - child: Text(t.common.retry), - ), - ), - const SizedBox(width: 16), - FocusableButton( - onPressed: () { - setState(() { - _useQrFlow = false; - }); - _startAuthentication(); - }, - child: OutlinedButton( - onPressed: () { - setState(() { - _useQrFlow = false; - }); - _startAuthentication(); - }, - style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)), - child: Text(t.auth.useBrowser), - ), - ), - ], - ), - ] else - _buildRetryButton(), - ], - ); - } - - /// Builds the browser authentication waiting widget - Widget _buildBrowserAuthWidget() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Center(child: CircularProgressIndicator()), - const SizedBox(height: 16), - Text( - t.auth.waitingForAuth, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey), - ), - _buildRetryButton(), + ], + ), + actions: [ + DialogActionButton(onPressed: _busy ? () {} : () => Navigator.of(context).pop(), label: t.common.cancel), + DialogActionButton(onPressed: _busy ? () {} : _submit, label: t.auth.authenticate, isPrimary: true), ], ); } diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index f584cbc0..592fb8ab 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../services/plex_client.dart'; -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; +import '../media/media_playlist.dart'; +import '../media/media_server_client.dart'; import '../providers/multi_server_provider.dart'; import '../utils/provider_extensions.dart'; -import '../services/play_queue_launcher.dart'; -import '../models/plex_playlist.dart'; +import '../services/media_list_playback_launcher.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import '../mixins/refreshable.dart'; @@ -19,15 +19,16 @@ import 'libraries/state_messages.dart'; /// Provides common state management and playback functionality abstract class BaseMediaListDetailScreen extends State with Refreshable, ItemUpdatable { // State properties - concrete implementations to avoid duplication - List items = []; + List items = []; bool isLoading = false; String? errorMessage; - @override - PlexClient get client => _getClientForMediaItem(); + /// Backend-neutral client for the media item's server. + MediaServerClient get mediaClient => _getMediaClientForMediaItem(); - /// The media item being displayed (collection or playlist) - dynamic get mediaItem; + /// The media item being displayed (collection or playlist) — either a + /// [MediaItem] or a [MediaPlaylist]. + Object get mediaItem; /// Title to display in app bar String get title; @@ -38,34 +39,27 @@ abstract class BaseMediaListDetailScreen extends State /// Optional icon to show when list is empty IconData? get emptyIcon => null; - /// Get the correct PlexClient for this media item's server - PlexClient _getClientForMediaItem() { - // Try to get serverId from the media item + String? _resolveMediaItemServerId() { + final item = mediaItem; String? serverId; - - // Check if mediaItem has serverId property - if (mediaItem is PlexMetadata) { - serverId = (mediaItem as PlexMetadata).serverId; - } else if (mediaItem != null) { - // For playlists or other types, use dynamic access - try { - final dynamic item = mediaItem; - serverId = item.serverId as String?; - } catch (_) { - // Ignore if serverId is not available - } + if (item is MediaItem) { + serverId = item.serverId; + } else if (item is MediaPlaylist) { + serverId = item.serverId; } - - // If serverId is null, fall back to first available server if (serverId == null) { final multiServerProvider = Provider.of(context, listen: false); serverId = multiServerProvider.onlineServerIds.firstOrNull; - if (serverId == null) { - throw Exception(t.errors.noClientAvailable); - } } + return serverId; + } - return context.getClientForServer(serverId); + MediaServerClient _getMediaClientForMediaItem() { + final serverId = _resolveMediaItemServerId(); + if (serverId == null) { + throw Exception(t.errors.noClientAvailable); + } + return context.getMediaClientWithFallback(serverId); } @override @@ -83,7 +77,11 @@ abstract class BaseMediaListDetailScreen extends State /// Shuffle play all items in the list Future shufflePlayItems() => _playWithShuffle(true); - /// Internal helper to play items with optional shuffle + /// Internal helper to play items with optional shuffle. + /// + /// Dispatches to the right launcher implementation based on the item's + /// backend — Plex uses server-side `/playQueues`, Jellyfin builds an + /// in-memory queue via [JellyfinSequentialLauncher]. Future _playWithShuffle(bool shuffle) async { if (items.isEmpty) { if (mounted) { @@ -92,26 +90,18 @@ abstract class BaseMediaListDetailScreen extends State return; } - final client = _getClientForMediaItem(); final item = mediaItem; - - final launcher = PlayQueueLauncher( - context: context, - client: client, - serverId: item is PlexMetadata ? item.serverId : (item as PlexPlaylist).serverId, - serverName: item is PlexMetadata ? item.serverName : (item as PlexPlaylist).serverName, - ); - + final launcher = MediaListPlaybackLauncher.forItem(context, item); await launcher.launchFromCollectionOrPlaylist(item: item, shuffle: shuffle, showLoadingIndicator: false); } @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + void updateItemInLists(String itemId, MediaItem updatedItem) { if (mounted) { setState(() { - final index = items.indexWhere((item) => item.ratingKey == ratingKey); + final index = items.indexWhere((it) => it.id == itemId); if (index != -1) { - items[index] = updatedMetadata; + items[index] = updatedItem; } }); } @@ -187,7 +177,7 @@ abstract class BaseMediaListDetailScreen extends State /// Handles the common pattern of fetching, tagging, and setting items mixin StandardItemLoader on BaseMediaListDetailScreen { /// Fetch items from the API (must be implemented by subclass) - Future> fetchItems(); + Future> fetchItems(); /// Get error message for failed load (can be overridden) String getLoadErrorMessage(Object error) { diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index ca3cb41a..79b09898 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; +import '../media/library_query.dart'; +import '../media/media_item.dart'; import '../mixins/paginated_item_loader.dart'; -import '../models/plex_metadata.dart'; import '../providers/download_provider.dart'; -import '../services/plex_client.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../utils/download_utils.dart'; import '../utils/platform_detector.dart'; -import '../utils/plex_http_client.dart'; +import '../utils/media_server_http_client.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; import '../i18n/strings.g.dart'; @@ -20,7 +20,7 @@ import '../mixins/grid_focus_node_mixin.dart'; /// Screen to display the contents of a collection class CollectionDetailScreen extends StatefulWidget { - final PlexMetadata collection; + final MediaItem collection; const CollectionDetailScreen({super.key, required this.collection}); @@ -36,7 +36,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen widget.collection; + MediaItem get mediaItem => widget.collection; @override String get title => widget.collection.title!; @@ -55,17 +55,18 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen fetchPage(int start, int size, AbortController? abort) => - client.getCollectionItems(widget.collection.ratingKey, start: start, size: size, abort: abort); + Future> fetchPage(int start, int size, AbortController? abort) { + return mediaClient.fetchCollectionPage(widget.collection.id, start: start, size: size, abort: abort); + } @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + void updateItemInLists(String itemId, MediaItem updatedItem) { // Search [loadedItems] (not the flat [items] snapshot, which only has // the first page) so refreshing an item at a scrolled-in position updates // the grid in place. for (final entry in loadedItems.entries) { - if (entry.value.ratingKey == ratingKey) { - loadedItems[entry.key] = updatedMetadata; + if (entry.value.id == itemId) { + loadedItems[entry.key] = updatedItem; return; } } @@ -103,9 +104,10 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen getAppBarActions() { + final ruleKey = _collectionSyncRuleKey(); // Select the specific bool we care about so unrelated DownloadProvider // ticks (e.g. active download progress) don't rebuild the app bar. - final hasRule = context.select((p) => p.hasSyncRule(widget.collection.globalKey)); + final hasRule = context.select((p) => p.hasSyncRule(ruleKey)); return [ if (hasItems) ...[ @@ -142,13 +144,16 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen(); try { - final allItems = await client.fetchAllCollectionItems(widget.collection.ratingKey); + // [fetchChildren] is the neutral equivalent of the Plex-only + // `fetchAllCollectionItemsAsMediaItems` — both backends return the + // collection's full contents. + final allItems = await mediaClient.fetchChildren(widget.collection.id); if (!mounted) return; final result = await showCollectionDownloadOptionsAndQueue( context, collectionMetadata: widget.collection, items: allItems, - client: client, + client: mediaClient, downloadProvider: downloadProvider, ); if (result == null || !mounted) return; @@ -161,32 +166,22 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen _manageCollectionSyncRule() => manageSyncRule( - context, - downloadProvider: context.read(), - globalKey: widget.collection.globalKey, - ); + Future _manageCollectionSyncRule() => + manageSyncRule(context, downloadProvider: context.read(), globalKey: _collectionSyncRuleKey()); Future _removeCollectionSyncRule() => removeSyncRuleAndSnack( context, downloadProvider: context.read(), - globalKey: widget.collection.globalKey, + globalKey: _collectionSyncRuleKey(), displayTitle: widget.collection.displayTitle, ); + String _collectionSyncRuleKey() { + final serverId = widget.collection.serverId ?? mediaClient.serverId; + return context.read().syncRuleKeyForClient(mediaClient, widget.collection.id, serverId: serverId); + } + Future _deleteCollection() async { - int? sectionId = widget.collection.librarySectionID; - if (sectionId == null && loadedItems.isNotEmpty) { - sectionId = loadedItems.values.first.librarySectionID; - } - - if (sectionId == null) { - if (mounted) { - showErrorSnackBar(context, t.collections.unknownLibrarySection); - } - return; - } - final confirmed = await showDeleteConfirmation( context, title: t.collections.deleteCollection, @@ -197,7 +192,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen loadedItems[index], onRefresh: updateItem, onSkeletonVisible: (index) => ensureIndexLoaded(index, pageSize: _pageSize), - collectionId: widget.collection.ratingKey, + collectionId: widget.collection.id, onListRefresh: loadItems, ), ], diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 3516140d..fb698f4e 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -13,20 +13,29 @@ import '../utils/global_key_utils.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../services/image_cache_service.dart'; -import '../../services/plex_client.dart'; -import '../utils/plex_image_helper.dart'; -import '../widgets/plex_optimized_image.dart' show blurArtwork; -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_server_client.dart'; +import '../media/media_hub.dart'; +import '../utils/media_image_helper.dart'; import '../utils/content_utils.dart'; -import '../models/plex_hub.dart'; +import '../widgets/optimized_media_image.dart' show blurArtwork; import '../providers/multi_server_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; -import 'profile/user_avatar_widget.dart'; import '../widgets/hub_section.dart'; import 'profile/profile_switch_screen.dart'; +import '../connection/connection_registry.dart'; +import '../profiles/active_profile_provider.dart'; +import '../profiles/plex_home_service.dart'; +import '../profiles/profile.dart'; +import '../profiles/profile_activation.dart'; +import '../profiles/profile_avatar.dart'; +import '../profiles/profile_connection_registry.dart'; +import '../profiles/profile_registry.dart'; import '../providers/user_profile_provider.dart'; +import '../services/storage_service.dart'; import '../providers/settings_provider.dart'; import '../mixins/refreshable.dart'; import '../mixins/tab_visibility_aware.dart'; @@ -69,18 +78,39 @@ class _DiscoverScreenState extends State static const Duration _heroAutoScrollDuration = Duration(seconds: 8); static const Duration _indicatorUpdateInterval = Duration(milliseconds: 200); + /// Items in [_onDeck] and [_hubs] can come from any registered server + /// (Plex or Jellyfin), so resolve the server per-item rather than via the + /// mixin's single-server [itemServerId] hook. @override - PlexClient get client { - final multiServerProvider = Provider.of(context, listen: false); - final serverId = multiServerProvider.onlineServerIds.firstOrNull; - if (serverId == null) { - throw Exception('No servers available'); + Future updateItem(String itemId) async { + try { + final serverId = _serverIdForItem(itemId); + if (serverId == null) return; + final updated = await context.tryGetMediaClientForServer(serverId)?.fetchItem(itemId); + if (updated == null || !mounted) return; + setState(() { + updateItemInLists(itemId, updated); + }); + } catch (_) { + // Silently fail — the item will refresh on the next full reload. } - return context.getClientForServer(serverId); } - List _onDeck = []; - List _hubs = []; + /// Locate the server that owns [itemId] by scanning the visible lists. + String? _serverIdForItem(String itemId) { + for (final item in _onDeck) { + if (item.id == itemId) return item.serverId; + } + for (final hub in _hubs) { + for (final item in hub.items) { + if (item.id == itemId) return item.serverId; + } + } + return null; + } + + List _onDeck = []; + List _hubs = []; bool _isLoading = true; bool _areHubsLoading = true; String? _errorMessage; @@ -97,15 +127,15 @@ class _DiscoverScreenState extends State // WatchStateAware: watch on-deck items and their parent shows/seasons @override - Set? get watchedRatingKeys { + Set? get watchedIds { final keys = {}; for (final item in _onDeck) { - keys.add(item.ratingKey); - if (item.parentRatingKey != null) { - keys.add(item.parentRatingKey!); + keys.add(item.id); + if (item.parentId != null) { + keys.add(item.parentId!); } - if (item.grandparentRatingKey != null) { - keys.add(item.grandparentRatingKey!); + if (item.grandparentId != null) { + keys.add(item.grandparentId!); } } return keys; @@ -118,12 +148,12 @@ class _DiscoverScreenState extends State final serverId = item.serverId; if (serverId == null) return null; - keys.add(buildGlobalKey(serverId, item.ratingKey)); - if (item.parentRatingKey != null) { - keys.add(buildGlobalKey(serverId, item.parentRatingKey!)); + keys.add(buildGlobalKey(serverId, item.id)); + if (item.parentId != null) { + keys.add(buildGlobalKey(serverId, item.parentId!)); } - if (item.grandparentRatingKey != null) { - keys.add(buildGlobalKey(serverId, item.grandparentRatingKey!)); + if (item.grandparentId != null) { + keys.add(buildGlobalKey(serverId, item.grandparentId!)); } } return keys; @@ -146,10 +176,15 @@ class _DiscoverScreenState extends State late FocusNode _heroFocusNode; final _actionBarKey = GlobalKey(); - PlexClient? _getClientForItem(PlexMetadata? item) { + /// Backend-neutral hero client lookup. Returns the actual + /// [MediaServerClient] for the item's server (Plex or Jellyfin) so + /// [MediaImageHelper] uses the right transcoder for sized URLs. + MediaServerClient? _getMediaClientForItem(MediaItem? item) { final serverId = item?.serverId; - if (serverId == null) return context.tryGetFirstAvailableClient(); - return context.tryGetClientForServer(serverId); + if (serverId == null) { + return context.tryGetMediaClientForServer(null); + } + return context.tryGetMediaClientForServer(serverId); } /// Update hub keys when hubs list changes — reuse existing keys to avoid @@ -455,6 +490,12 @@ class _DiscoverScreenState extends State final multiServerProvider = Provider.of(context, listen: false); if (!multiServerProvider.hasConnectedServers) { + // Stay in the loading state set above (no error, no spinner replacement) + // when the binder hasn't finished wiring servers yet — main_screen + // calls fullRefresh() once binding settles. Surfacing the throw here + // would briefly flash an error during cold start. + final activeProfile = Provider.of(context, listen: false); + if (activeProfile.isBinding) return; throw Exception('No servers available'); } @@ -467,11 +508,8 @@ class _DiscoverScreenState extends State // Get settings for hub mode preference (ensure initialized before accessing) final settingsProvider = Provider.of(context, listen: false); - // Reuse already-loaded libraries to avoid a redundant API call inside hub fetching - final librariesProvider = context.read(); - final librariesByServer = librariesProvider.libraries.isNotEmpty - ? multiServerProvider.aggregationService.groupLibrariesByServer(librariesProvider.libraries) - : null; + // Let aggregation service fetch libraries internally; the LibrariesProvider + // stores neutral MediaLibrary objects. await settingsProvider.ensureInitialized(); @@ -483,7 +521,6 @@ class _DiscoverScreenState extends State final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers( hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, useGlobalHubs: settingsProvider.useGlobalHubs, - librariesByServer: librariesByServer, ); // Wait for OnDeck to complete and show it immediately @@ -535,7 +572,7 @@ class _DiscoverScreenState extends State // Filter out Continue Watching / On Deck hubs (handled separately in hero section) final filteredHubs = allHubs.where((hub) { - final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; + final hubId = hub.identifier?.toLowerCase() ?? ''; final title = hub.title.toLowerCase(); return !hubId.contains('ondeck') && !hubId.contains('continue') && @@ -583,12 +620,12 @@ class _DiscoverScreenState extends State } /// Resolve the library globalKey for a hub (for sorting by library order). - String? _hubLibraryGlobalKey(PlexHub hub) { + String? _hubLibraryGlobalKey(MediaHub hub) { final serverId = hub.serverId; if (serverId == null) return null; - final sectionId = hub.librarySectionID ?? hub.items.firstOrNull?.librarySectionID; + final sectionId = hub.libraryId ?? hub.items.firstOrNull?.libraryId; if (sectionId == null) return null; - return buildGlobalKey(serverId, sectionId.toString()); + return buildGlobalKey(serverId, sectionId); } /// Refresh only the Continue Watching section in the background @@ -634,12 +671,12 @@ class _DiscoverScreenState extends State } } - /// Sync On Deck items to Android TV Watch Next row - Future _syncWatchNext(List onDeck) async { + /// Sync On Deck items to Android TV Watch Next row. + Future _syncWatchNext(List onDeck) async { try { await WatchNextService().syncFromOnDeck( onDeck, - (serverId) => context.getClientForServer(serverId), + (serverId) => context.getMediaClientWithFallback(serverId), hideSpoilers: context.read().hideSpoilers, ); } catch (e) { @@ -768,18 +805,22 @@ class _DiscoverScreenState extends State } @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + void updateItemInLists(String itemId, MediaItem updatedItem) { // Check and update in _onDeck list - final onDeckIndex = _onDeck.indexWhere((item) => item.ratingKey == ratingKey); + final onDeckIndex = _onDeck.indexWhere((item) => item.id == itemId); if (onDeckIndex != -1) { - _onDeck[onDeckIndex] = updatedMetadata; + _onDeck[onDeckIndex] = updatedItem; } - // Check and update in hub items - for (final hub in _hubs) { - final itemIndex = hub.items.indexWhere((item) => item.ratingKey == ratingKey); + // Check and update in hub items. [MediaHub.items] is immutable list view; + // rebuild the hub when one of its items needs to change. + for (var i = 0; i < _hubs.length; i++) { + final hub = _hubs[i]; + final itemIndex = hub.items.indexWhere((item) => item.id == itemId); if (itemIndex != -1) { - hub.items[itemIndex] = updatedMetadata; + final newItems = List.from(hub.items); + newItems[itemIndex] = updatedItem; + _hubs[i] = hub.copyWith(items: newItems); } } } @@ -799,10 +840,25 @@ class _DiscoverScreenState extends State final multiServerProvider = context.read(); final hiddenLibrariesProvider = context.read(); final playbackStateProvider = context.read(); + final connectionRegistry = context.read(); + final profileRegistry = context.read(); + final profileConnReg = context.read(); + final plexHome = context.read(); + final companionRemote = context.read(); // Clear all user data and provider states + await companionRemote.resetForLogout(); await userProfileProvider.logout(); multiServerProvider.clearAllConnections(); + // Drop the profile/connection rows so the next sign-in starts clean + // and doesn't bind to stale tokens or orphaned profile rows. + await profileConnReg.clear(); + await profileRegistry.clear(); + await connectionRegistry.clear(); + await plexHome.clearAll(); + final storage = await StorageService.getInstance(); + await storage.clearActiveProfileId(); + await storage.clearAllProfileLastUsed(); await hiddenLibrariesProvider.refresh(); playbackStateProvider.clearShuffle(); @@ -820,8 +876,92 @@ class _DiscoverScreenState extends State Navigator.push(context, MaterialPageRoute(builder: (context) => const ProfileSwitchScreen())); } + /// Build the [FocusableAction] wrapping the user-menu PopupMenuButton. + /// Pulls live state from [ActiveProfileProvider]; the menu reuses + /// [_userMenuItems] for the menu contents so d-pad and tap paths + /// stay in sync. + FocusableAction _buildUserMenuAction(BuildContext context) { + final activeProvider = context.watch(); + final active = activeProvider.active; + final profiles = activeProvider.profiles; + + return FocusableAction( + onPressed: () => _showUserMenu(context), + child: PopupMenuButton( + icon: active != null + ? ProfileAvatar(profile: active, size: 32) + : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), + itemBuilder: (context) => _userMenuItems(context, activeProfile: active, profiles: profiles), + ), + ); + } + + List> _userMenuItems( + BuildContext context, { + required Profile? activeProfile, + required List profiles, + }) { + final theme = Theme.of(context); + final switchable = profiles.where((p) => p.id != activeProfile?.id).toList(); + void deferAction(String value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) _handleUserMenuAction(context, value); + }); + } + + return [ + for (final p in switchable) + PopupMenuItem( + value: 'profile:${p.id}', + onTap: () => deferAction('profile:${p.id}'), + child: Row( + children: [ + ProfileAvatar(profile: p, size: 24), + const SizedBox(width: 12), + Expanded(child: Text(p.displayName, overflow: TextOverflow.ellipsis)), + if (p.isPinProtected) ...[ + const SizedBox(width: 8), + AppIcon(Symbols.lock_rounded, fill: 1, size: 14, color: theme.colorScheme.onSurfaceVariant), + ], + ], + ), + ), + if (switchable.isNotEmpty) const PopupMenuDivider(), + PopupMenuItem( + value: 'manage_profiles', + onTap: () => deferAction('manage_profiles'), + child: const Row(children: [AppIcon(Symbols.group_rounded, fill: 1), SizedBox(width: 8), Text('Profiles')]), + ), + PopupMenuItem( + value: 'logout', + onTap: () => deferAction('logout'), + child: Row( + children: [const AppIcon(Symbols.logout_rounded, fill: 1), const SizedBox(width: 8), Text(t.common.logout)], + ), + ), + ]; + } + + Future _handleUserMenuAction(BuildContext context, String value) async { + if (value == 'logout') { + unawaited(_handleLogout()); + return; + } + if (value == 'manage_profiles') { + _handleSwitchProfile(context); + return; + } + if (value.startsWith('profile:')) { + final id = value.substring('profile:'.length); + final active = context.read(); + final target = active.profiles.where((p) => p.id == id).firstOrNull; + if (target == null) return; + await activateProfileWithPin(context, target); + } + } + /// Show user menu programmatically (for D-pad select) - void _showUserMenu(BuildContext context, UserProfileProvider userProvider) { + void _showUserMenu(BuildContext context) { final actionBar = _actionBarKey.currentState; if (actionBar == null) return; final lastNode = actionBar.getFocusNode(actionBar.widget.actions.length - 1); @@ -837,36 +977,14 @@ class _DiscoverScreenState extends State Offset.zero & overlay.size, ); - showMenu( - context: context, - position: position, - items: [ - if (userProvider.hasMultipleUsers) - PopupMenuItem( - value: 'switch_profile', - child: Row( - children: [ - AppIcon(Symbols.people_rounded, fill: 1), - const SizedBox(width: 8), - Text(t.discover.switchProfile), - ], - ), - ), - PopupMenuItem( - value: 'logout', - child: Row( - children: [AppIcon(Symbols.logout_rounded, fill: 1), const SizedBox(width: 8), Text(t.common.logout)], - ), - ), - ], - ).then((value) { - if (!context.mounted) return; - if (value == 'switch_profile') { - _handleSwitchProfile(context); - } else if (value == 'logout') { - _handleLogout(); - } - }); + final activeProvider = context.read(); + unawaited( + showMenu( + context: context, + position: position, + items: _userMenuItems(context, activeProfile: activeProvider.active, profiles: activeProvider.profiles), + ), + ); } Widget _buildOverlaidAppBar() { @@ -901,7 +1019,6 @@ class _DiscoverScreenState extends State Consumer2( builder: (context, watchTogether, companionRemote, _) { final isDesktop = PlatformDetector.shouldActAsRemoteHost(context); - final userProvider = context.watch(); final colorScheme = Theme.of(context).colorScheme; return FocusableActionBar( @@ -1000,47 +1117,15 @@ class _DiscoverScreenState extends State ], ), ), - // Server Tasks - if (PlatformDetector.isDesktop(context)) const FocusableAction(child: ServerActivitiesButton()), - // User menu - FocusableAction( - onPressed: () => _showUserMenu(context, userProvider), - child: PopupMenuButton( - icon: userProvider.currentUser?.thumb != null - ? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false) - : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), - onSelected: (value) { - if (value == 'switch_profile') { - _handleSwitchProfile(context); - } else if (value == 'logout') { - _handleLogout(); - } - }, - itemBuilder: (context) => [ - if (userProvider.hasMultipleUsers) - PopupMenuItem( - value: 'switch_profile', - child: Row( - children: [ - AppIcon(Symbols.people_rounded, fill: 1), - const SizedBox(width: 8), - Text(t.discover.switchProfile), - ], - ), - ), - PopupMenuItem( - value: 'logout', - child: Row( - children: [ - AppIcon(Symbols.logout_rounded, fill: 1), - const SizedBox(width: 8), - Text(t.common.logout), - ], - ), - ), - ], - ), - ), + // Server Tasks — Plex-only (`/activities` API has no + // Jellyfin equivalent), hide the button entirely on + // Jellyfin-only profiles so the chrome doesn't show + // a permanently empty popover. + if (PlatformDetector.isDesktop(context) && + context.select((p) => p.hasOnlinePlexServers)) + const FocusableAction(child: ServerActivitiesButton()), + // User menu — profiles + sign out + _buildUserMenuAction(context), ], ); }, @@ -1094,11 +1179,11 @@ class _DiscoverScreenState extends State SliverToBoxAdapter( child: HubSection( key: _continueWatchingHubKey, - hub: PlexHub( - hubKey: 'continue_watching', + hub: MediaHub( + id: 'continue_watching', title: t.discover.continueWatching, type: 'mixed', - hubIdentifier: '_continue_watching_', + identifier: '_continue_watching_', size: _onDeck.length, more: false, items: _onDeck, @@ -1319,8 +1404,8 @@ class _DiscoverScreenState extends State ); } - Widget _buildHeroItem(PlexMetadata heroItem, double heroHeight) { - final heroClient = _getClientForItem(heroItem); + Widget _buildHeroItem(MediaItem heroItem, double heroHeight) { + final heroClient = _getMediaClientForItem(heroItem); final isEpisode = heroItem.isEpisode; final showName = heroItem.grandparentTitle ?? heroItem.displayTitle; final screenWidth = MediaQuery.sizeOf(context).width; @@ -1352,7 +1437,9 @@ class _DiscoverScreenState extends State clipBehavior: Clip.none, children: [ // Background Image with fade/zoom animation and parallax - if (heroItem.art != null || heroItem.backgroundSquare != null || heroItem.grandparentArt != null) + if (heroItem.artPath != null || + heroItem.backgroundSquarePath != null || + heroItem.grandparentArtPath != null) ClipRect( child: AnimatedBuilder( animation: _scrollController, @@ -1372,22 +1459,23 @@ class _DiscoverScreenState extends State }, child: Builder( builder: (context) { - if (heroClient == null) { - return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest); - } + // heroClient resolves to the actual server's client + // (Plex or Jellyfin) so each backend's transcoder + // builds sized URLs. final size = MediaQuery.sizeOf(context); - final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); final containerAspect = screenWidth / heroHeight; - final imageUrl = PlexImageHelper.getOptimizedImageUrl( + final imageUrl = MediaImageHelper.getOptimizedImageUrl( client: heroClient, - thumbPath: heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArt, + thumbPath: + heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArtPath, maxWidth: size.width, maxHeight: size.height * 0.7, devicePixelRatio: dpr, imageType: ImageType.art, ); - final (_, memHeight) = PlexImageHelper.getMemCacheDimensions( + final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( displayWidth: (screenWidth * dpr).round(), displayHeight: (heroHeight * dpr).round(), imageType: ImageType.art, @@ -1450,17 +1538,16 @@ class _DiscoverScreenState extends State mainAxisSize: MainAxisSize.min, children: [ // Show logo or name/title - if (heroItem.clearLogo != null) + if (heroItem.clearLogoPath != null) SizedBox( height: 120, width: 400, child: Builder( builder: (context) { - if (heroClient == null) return const SizedBox.shrink(); - final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); - final logoUrl = PlexImageHelper.getOptimizedImageUrl( + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final logoUrl = MediaImageHelper.getOptimizedImageUrl( client: heroClient, - thumbPath: heroItem.clearLogo, + thumbPath: heroItem.clearLogoPath, maxWidth: 400, maxHeight: 120, devicePixelRatio: dpr, @@ -1618,13 +1705,12 @@ class _DiscoverScreenState extends State ); } - Widget _buildSmartPlayButton(PlexMetadata heroItem) { - final hasProgress = - heroItem.viewOffset != null && heroItem.duration != null && heroItem.viewOffset! > 0 && heroItem.duration! > 0; + Widget _buildSmartPlayButton(MediaItem heroItem) { + final hasProgress = heroItem.hasActiveProgress; - final minutesLeft = hasProgress ? ((heroItem.duration! - heroItem.viewOffset!) / 60000).round() : 0; + final minutesLeft = hasProgress ? ((heroItem.durationMs! - heroItem.viewOffsetMs!) / 60000).round() : 0; - final progress = hasProgress ? heroItem.viewOffset! / heroItem.duration! : 0.0; + final progress = hasProgress ? heroItem.viewOffsetMs! / heroItem.durationMs! : 0.0; return InkWell( onTap: () { diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 1d68f30e..4d2b51ae 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../models/plex_metadata.dart'; +import '../../media/media_item.dart'; import '../../providers/download_provider.dart'; import '../../providers/multi_server_provider.dart'; import '../../providers/settings_provider.dart'; @@ -193,7 +193,11 @@ class DownloadsScreenState extends State with TickerProviderSta children: [ Consumer2( builder: (context, downloadProvider, serverProvider, _) { - // Helper to get client from globalKey (serverId:ratingKey) + // Resolve the owning server's client from a download's + // globalKey (`serverId:ratingKey`). Backend-neutral — + // Jellyfin downloads also surface here, so the + // resume/retry buttons need a [MediaServerClient] + // (not a [PlexClient]) for both code paths. getClient(String globalKey) { final serverId = parseGlobalKey(globalKey)?.serverId ?? globalKey; return serverProvider.serverManager.getClient(serverId); @@ -290,7 +294,7 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> { Widget build(BuildContext context) { return Consumer2( builder: (context, downloadProvider, settingsProvider, _) { - final List items = widget.type == DownloadType.tvShows + final List items = widget.type == DownloadType.tvShows ? downloadProvider.downloadedShows : downloadProvider.downloadedMovies; diff --git a/lib/screens/downloads/sync_rules_screen.dart b/lib/screens/downloads/sync_rules_screen.dart index b8499f48..e8264dec 100644 --- a/lib/screens/downloads/sync_rules_screen.dart +++ b/lib/screens/downloads/sync_rules_screen.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../connection/connection.dart'; +import '../../connection/connection_registry.dart'; import '../../database/app_database.dart'; -import '../../models/plex_metadata.dart'; +import '../../media/media_item.dart'; import '../../providers/download_provider.dart'; +import '../../providers/multi_server_provider.dart'; import '../../services/sync_rule_executor.dart'; import '../../utils/content_utils.dart'; import '../../utils/download_utils.dart'; @@ -20,44 +23,66 @@ class SyncRulesScreen extends StatelessWidget { return Consumer( builder: (context, downloadProvider, _) { final syncRules = downloadProvider.syncRules; + final multiServerProvider = context.watch(); + final connectionRegistry = context.read(); - return FocusedScrollScaffold( - title: Text(t.downloads.activeSyncRules), - slivers: [ - if (syncRules.isEmpty) - SliverFillRemaining( - child: EmptyStateWidget(message: t.downloads.noSyncRules, icon: Symbols.sync_rounded, iconSize: 80), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final entry = syncRules.entries.elementAt(index); - final rule = entry.value; - return _SyncRuleTile( - rule: rule, - metadata: downloadProvider.metadata, - downloadProvider: downloadProvider, - autofocus: index == 0, - ); - }, childCount: syncRules.length), - ), - ], + return StreamBuilder>( + stream: connectionRegistry.watchConnections(), + initialData: const [], + builder: (context, snapshot) { + final connections = snapshot.data ?? const []; + return FocusedScrollScaffold( + title: Text(t.downloads.activeSyncRules), + slivers: [ + if (syncRules.isEmpty) + SliverFillRemaining( + child: EmptyStateWidget(message: t.downloads.noSyncRules, icon: Symbols.sync_rounded, iconSize: 80), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final entry = syncRules.entries.elementAt(index); + final rule = entry.value; + return _SyncRuleTile( + rule: rule, + metadata: downloadProvider.metadata, + downloadProvider: downloadProvider, + multiServerProvider: multiServerProvider, + connections: connections, + autofocus: index == 0, + ); + }, childCount: syncRules.length), + ), + ], + ); + }, ); }, ); } } +class _RuleServerInfo { + final String label; + final bool isKnown; + + const _RuleServerInfo({required this.label, required this.isKnown}); +} + class _SyncRuleTile extends StatelessWidget { final SyncRuleItem rule; - final Map metadata; + final Map metadata; final DownloadProvider downloadProvider; + final MultiServerProvider multiServerProvider; + final List connections; final bool autofocus; const _SyncRuleTile({ required this.rule, required this.metadata, required this.downloadProvider, + required this.multiServerProvider, + required this.connections, this.autofocus = false, }); @@ -85,6 +110,46 @@ class _SyncRuleTile extends StatelessWidget { } } + _RuleServerInfo _serverLabelForRule() { + final activeName = multiServerProvider.getClientForServer(rule.serverId)?.serverName; + if (activeName != null && activeName.isNotEmpty) { + return _RuleServerInfo(label: activeName, isKnown: true); + } + + final publicGlobalKey = '${rule.serverId}:${rule.ratingKey}'; + final meta = metadata[rule.globalKey] ?? metadata[publicGlobalKey]; + final metadataName = meta?.serverName; + if (metadataName != null && metadataName.isNotEmpty) { + return _RuleServerInfo(label: metadataName, isKnown: true); + } + + for (final connection in connections) { + switch (connection) { + case PlexAccountConnection(:final servers): + for (final server in servers) { + if (server.clientIdentifier == rule.serverId && server.name.isNotEmpty) { + return _RuleServerInfo(label: server.name, isKnown: true); + } + } + case JellyfinConnection(:final serverMachineId, :final serverName): + if (serverMachineId == rule.serverId && serverName.isNotEmpty) { + return _RuleServerInfo(label: serverName, isKnown: true); + } + } + } + + return _RuleServerInfo(label: rule.serverId, isKnown: false); + } + + String _serverStatusForRule(_RuleServerInfo serverInfo) { + if (!serverInfo.isKnown) return t.downloads.syncRuleUnknownServer; + if (multiServerProvider.authErrorServerIds.contains(rule.serverId)) return t.downloads.syncRuleSignInRequired; + if (!multiServerProvider.serverIds.contains(rule.serverId)) return t.downloads.syncRuleNotAvailableForProfile; + return multiServerProvider.isServerOnline(rule.serverId) + ? t.downloads.syncRuleAvailable + : t.downloads.syncRuleOffline; + } + Future _onTap(BuildContext context) async { if (rule.isListRule) { await editSyncRuleFilter( @@ -105,14 +170,27 @@ class _SyncRuleTile extends StatelessWidget { @override Widget build(BuildContext context) { - final meta = metadata[rule.globalKey]; + final publicGlobalKey = '${rule.serverId}:${rule.ratingKey}'; + final meta = metadata[rule.globalKey] ?? metadata[publicGlobalKey]; final title = meta?.title ?? rule.ratingKey; + final serverInfo = _serverLabelForRule(); + final serverLine = t.downloads.syncRuleServerContext( + server: serverInfo.label, + status: _serverStatusForRule(serverInfo), + ); return FocusableListTile( autofocus: autofocus, leading: Icon(_leadingIcon(), color: rule.enabled ? Colors.teal : null, size: 20), title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(_subtitle()), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(_subtitle(), maxLines: 1, overflow: TextOverflow.ellipsis), + Text(serverLine, maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ), trailing: Switch( value: rule.enabled, onChanged: (value) => downloadProvider.setSyncRuleEnabled(rule.globalKey, value), diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index e02b58fb..e0419227 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -3,8 +3,9 @@ import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; +import '../media/media_item.dart'; +import '../media/media_playlist.dart'; import '../mixins/grid_focus_node_mixin.dart'; -import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart' show ViewMode; import '../utils/grid_size_calculator.dart'; @@ -12,6 +13,14 @@ import '../widgets/focusable_media_card.dart'; import '../widgets/media_grid_delegate.dart'; import '../widgets/skeleton_media_card.dart'; +/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a +/// Flutter widget Key. +String _idForItem(Object item) { + if (item is MediaItem) return item.id; + if (item is MediaPlaylist) return item.id; + return identityHashCode(item).toString(); +} + /// Mixin that provides common focus navigation functionality for detail screens. /// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. /// @@ -150,7 +159,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu /// Used by collection and smart playlist detail screens. Widget buildFocusableGrid({ required List items, - required void Function(String ratingKey) onRefresh, + required void Function(String itemId) onRefresh, String? collectionId, VoidCallback? onListRefresh, }) { @@ -168,7 +177,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu final focusNode = _focusNodeForIndex(index); return FocusableMediaCard( - key: Key(item.ratingKey), + key: Key(_idForItem(item)), item: item, focusNode: focusNode, disableScale: true, @@ -202,7 +211,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu final focusNode = _focusNodeForIndex(index); return FocusableMediaCard( - key: Key(item.ratingKey), + key: Key(_idForItem(item)), item: item, focusNode: focusNode, onRefresh: onRefresh, @@ -227,8 +236,8 @@ mixin FocusableDetailScreenMixin on State, GridFocu /// the caller can kick off a page fetch containing that index. Widget buildSparseFocusableGrid({ required int totalItems, - required PlexMetadata? Function(int index) itemAt, - required void Function(String ratingKey) onRefresh, + required MediaItem? Function(int index) itemAt, + required void Function(String itemId) onRefresh, void Function(int index)? onSkeletonVisible, String? collectionId, VoidCallback? onListRefresh, @@ -245,7 +254,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu } final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item'); return FocusableMediaCard( - key: Key(item.ratingKey), + key: Key(item.id), item: item, focusNode: focusNode, disableScale: disableScale, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index b463374b..98dbafa2 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../services/plex_client.dart'; -import '../models/plex_hub.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_sort.dart'; +import '../media/media_hub.dart'; +import '../media/media_item.dart'; +import '../media/media_sort.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; -import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/grid_size_calculator.dart'; +import '../utils/provider_extensions.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/media_grid_delegate.dart'; import '../widgets/desktop_app_bar.dart'; @@ -25,7 +24,7 @@ import 'focusable_detail_screen_mixin.dart'; /// Screen to display full content of a recommendation hub class HubDetailScreen extends StatefulWidget { - final PlexHub hub; + final MediaHub hub; const HubDetailScreen({super.key, required this.hub}); @@ -35,12 +34,10 @@ class HubDetailScreen extends StatefulWidget { class _HubDetailScreenState extends State with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin { - PlexClient get client => _getClientForHub(); - - List _items = []; - List _filteredItems = []; - List _sortOptions = []; - PlexSort? _selectedSort; + List _items = []; + List _filteredItems = []; + List _sortOptions = []; + MediaSort? _selectedSort; bool _isSortDescending = false; bool _isLoading = false; String? _errorMessage; @@ -76,11 +73,6 @@ class _HubDetailScreenState extends State FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'hub_detail_item'); - /// Get the correct PlexClient for this hub's server - PlexClient _getClientForHub() { - return context.getClientForServer(widget.hub.serverId!); - } - @override void initState() { super.initState(); @@ -105,16 +97,23 @@ class _HubDetailScreenState extends State Future _loadSorts() async { try { - final client = _getClientForHub(); + final serverId = widget.hub.serverId; + if (serverId == null) { + appLogger.w('Hub has no serverId; using default sort options'); + if (!mounted) return; + setState(() { + _sortOptions = _getDefaultSortOptions(); + }); + return; + } - // Get the library key from the hub key - // Hub keys can have various formats: - // - /hubs/sections/1/... - // - /library/sections/1/all?... - final hubKey = widget.hub.hubKey; + // Hub ids can have various formats: + // - /hubs/sections/1/... (Plex) + // - /library/sections/1/all?... (Plex) + // - home.recent / library..continue (Jellyfin synthesized) + final hubKey = widget.hub.id; appLogger.d('Hub key: $hubKey'); - // Try different patterns RegExpMatch? match = RegExp(r'/hubs/sections/(\d+)').firstMatch(hubKey); match ??= RegExp(r'/library/sections/(\d+)').firstMatch(hubKey); match ??= RegExp(r'sections/(\d+)').firstMatch(hubKey); @@ -123,42 +122,37 @@ class _HubDetailScreenState extends State final sectionId = match.group(1)!; appLogger.d('Loading sorts for section: $sectionId'); - // Load sorts for this library - final sorts = await client.getLibrarySorts(sectionId); + final client = context.tryGetMediaClientForServer(serverId); + final sorts = client == null ? const [] : await client.fetchSortOptions(sectionId); appLogger.d('Loaded ${sorts.length} sorts'); if (!mounted) return; setState(() { _sortOptions = sorts.isNotEmpty ? sorts : _getDefaultSortOptions(); - // Don't set a default sort - let items stay in original order }); } else { appLogger.w('Could not extract section ID from hub key: $hubKey'); - // Provide default sort options even if we can't get library-specific ones if (!mounted) return; setState(() { _sortOptions = _getDefaultSortOptions(); - // Don't set a default sort - let items stay in original order }); } } catch (e) { appLogger.e('Failed to load sorts', error: e); - // Provide default sort options on error if (!mounted) return; setState(() { _sortOptions = _getDefaultSortOptions(); - // Don't set a default sort - let items stay in original order }); } } - List _getDefaultSortOptions() { + List _getDefaultSortOptions() { return [ - PlexSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'), - PlexSort(key: 'year', descKey: 'year:desc', title: t.hubDetail.releaseYear, defaultDirection: 'desc'), - PlexSort(key: 'addedAt', descKey: 'addedAt:desc', title: t.hubDetail.dateAdded, defaultDirection: 'desc'), - PlexSort(key: 'rating', descKey: 'rating:desc', title: t.hubDetail.rating, defaultDirection: 'desc'), + MediaSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'), + MediaSort(key: 'year', descKey: 'year:desc', title: t.hubDetail.releaseYear, defaultDirection: 'desc'), + MediaSort(key: 'addedAt', descKey: 'addedAt:desc', title: t.hubDetail.dateAdded, defaultDirection: 'desc'), + MediaSort(key: 'rating', descKey: 'rating:desc', title: t.hubDetail.rating, defaultDirection: 'desc'), ]; } @@ -226,21 +220,25 @@ class _HubDetailScreenState extends State Future _loadMoreItems() async { if (_isLoading) return; + final serverId = widget.hub.serverId; + if (serverId == null) { + appLogger.w('Hub has no serverId; cannot load more items for ${widget.hub.id}'); + return; + } + setState(() { _isLoading = true; _errorMessage = null; }); try { - final client = _getClientForHub(); - - // Fetch items from the hub, tagged with server info at the source - var items = await client.getHubContent(widget.hub.hubKey); + final client = context.tryGetMediaClientForServer(serverId); + var items = client == null ? const [] : await client.fetchMoreHubItems(widget.hub.id); // Filter to specific library if this hub was split from a multi-library hub - final sectionFilter = widget.hub.librarySectionID; + final sectionFilter = int.tryParse(widget.hub.libraryId ?? ''); if (sectionFilter != null) { - items = items.where((item) => item.librarySectionID == sectionFilter).toList(); + items = items.where((item) => int.tryParse(item.libraryId ?? '') == sectionFilter).toList(); } if (!mounted) return; @@ -250,7 +248,6 @@ class _HubDetailScreenState extends State _isLoading = false; }); - // Apply any existing sort _applySort(); appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}'); @@ -267,7 +264,7 @@ class _HubDetailScreenState extends State void _handleItemRefresh(String ratingKey) { // Refresh the specific item in the list setState(() { - final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + final index = _items.indexWhere((item) => item.id == ratingKey); if (index != -1) { // The item will be refreshed by the MediaCard itself appLogger.d('Item refresh requested for: $ratingKey'); diff --git a/lib/screens/libraries/alpha_jump_bar.dart b/lib/screens/libraries/alpha_jump_bar.dart index 1286d5f6..9a74f553 100644 --- a/lib/screens/libraries/alpha_jump_bar.dart +++ b/lib/screens/libraries/alpha_jump_bar.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../models/plex_first_character.dart'; +import '../../media/library_first_character.dart'; import 'alpha_jump_helper.dart'; /// Vertical strip of letters for jumping through sorted library items. @@ -14,7 +14,7 @@ import 'alpha_jump_helper.dart'; /// highest-count letters (by item size) and drops the rest. /// Supports both touch (tap/drag) and D-pad (up/down/select) input. class AlphaJumpBar extends StatefulWidget { - final List firstCharacters; + final List firstCharacters; final void Function(int targetIndex) onJump; /// The letter currently visible at the top of the grid, derived from the @@ -101,7 +101,11 @@ class _AlphaJumpBarState extends State { } /// Find the nearest displayed letter at or before [letter] in the full list. + /// Returns an empty string when [letter] is empty so callers can render an + /// "unhighlighted" state — important for the Jellyfin filter UX where no + /// letter is selected by default. String _nearestDisplayed(String letter) { + if (letter.isEmpty) return ''; if (_displayed.isEmpty) return letter; if (_displayed.contains(letter)) return letter; final pos = _helper.letters.indexOf(letter); diff --git a/lib/screens/libraries/alpha_jump_helper.dart b/lib/screens/libraries/alpha_jump_helper.dart index ac92ad8c..c7f5a1fb 100644 --- a/lib/screens/libraries/alpha_jump_helper.dart +++ b/lib/screens/libraries/alpha_jump_helper.dart @@ -1,14 +1,15 @@ import '../../data/ducet_order.dart'; -import '../../models/plex_first_character.dart'; +import '../../media/library_first_character.dart'; /// Shared letter-index mapping logic used by both [AlphaJumpBar] (desktop/tablet/TV) /// and [AlphaScrollHandle] (phone). /// -/// Builds a dynamic letter list and cumulative index map from [PlexFirstCharacter] -/// data returned by the Plex API. Only letters that have items are included, -/// supporting non-Latin scripts (Korean, Japanese, Cyrillic, etc.). +/// Builds a dynamic letter list and cumulative index map from [LibraryFirstCharacter] +/// data returned by the server's first-character endpoint. Only letters that +/// have items are included, supporting non-Latin scripts (Korean, Japanese, +/// Cyrillic, etc.). /// -/// The API's firstCharacter endpoint returns characters in Unicode codepoint +/// Plex's `/firstCharacter` endpoint returns characters in Unicode codepoint /// order, which doesn't match the content endpoint's ICU locale-aware sort. /// We re-sort using ICU collation so cumulative indices are correct. class AlphaJumpHelper { @@ -27,7 +28,7 @@ class AlphaJumpHelper { AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount); - factory AlphaJumpHelper(List firstCharacters) { + factory AlphaJumpHelper(List firstCharacters) { // Collect characters with their sizes. final entries = <({String letter, int size})>[]; final letterSizes = {}; diff --git a/lib/screens/libraries/alpha_scroll_handle.dart b/lib/screens/libraries/alpha_scroll_handle.dart index 4b097433..cfcce3a2 100644 --- a/lib/screens/libraries/alpha_scroll_handle.dart +++ b/lib/screens/libraries/alpha_scroll_handle.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../models/plex_first_character.dart'; +import '../../media/library_first_character.dart'; import 'alpha_jump_helper.dart'; /// Phone-optimized draggable scroll handle that appears on scroll and shows @@ -11,7 +11,7 @@ import 'alpha_jump_helper.dart'; /// /// Desktop/tablet/TV should use [AlphaJumpBar] instead. class AlphaScrollHandle extends StatefulWidget { - final List firstCharacters; + final List firstCharacters; final void Function(int targetIndex) onJump; /// The letter currently visible at the top of the grid, derived from the diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index c373ca2b..d7f44b02 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../models/plex_filter.dart'; +import '../../media/media_filter.dart'; +import '../../services/plex_client.dart'; import '../../utils/scroll_utils.dart'; import '../../widgets/app_bar_back_button.dart'; import '../../widgets/bottom_sheet_header.dart'; @@ -11,12 +12,18 @@ import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; class FiltersBottomSheet extends StatefulWidget { - final List filters; + final List filters; final Map selectedFilters; final Function(Map) onFiltersChanged; final String serverId; final String libraryKey; + /// Optional pre-fetched values per filter name. When non-null the sheet + /// reads from this instead of calling `client.getFilterValues` — used + /// for Jellyfin libraries where values come back in the same call that + /// lists the categories. + final Map>? cachedValues; + const FiltersBottomSheet({ super.key, required this.filters, @@ -24,6 +31,7 @@ class FiltersBottomSheet extends StatefulWidget { required this.onFiltersChanged, required this.serverId, required this.libraryKey, + this.cachedValues, }); @override @@ -31,13 +39,13 @@ class FiltersBottomSheet extends StatefulWidget { } class _FiltersBottomSheetState extends State { - PlexFilter? _currentFilter; - List _filterValues = []; + MediaFilter? _currentFilter; + List _filterValues = []; bool _isLoadingValues = false; final Map _tempSelectedFilters = {}; static final Map _filterDisplayNames = {}; // Cache for display names static const int _maxCachedDisplayNames = 1000; - late List _sortedFilters; + late List _sortedFilters; late final FocusNode _initialFocusNode; final _valuesFirstItemKey = GlobalKey(); final _valuesScrollController = ScrollController(); @@ -68,20 +76,35 @@ class _FiltersBottomSheetState extends State { _sortedFilters = [...booleanFilters, ...regularFilters]; } - bool _isBooleanFilter(PlexFilter filter) { + bool _isBooleanFilter(MediaFilter filter) { return filter.filterType == 'boolean'; } - Future _loadFilterValues(PlexFilter filter) async { + Future _loadFilterValues(MediaFilter filter) async { setState(() { _currentFilter = filter; _isLoadingValues = true; }); try { - final client = context.getClientForServer(widget.serverId); - - final values = await client.getFilterValues(filter.key); + // Cached path (Jellyfin) — `/Items/Filters` returned values inline. + final cached = widget.cachedValues?[filter.filter]; + // Backend-neutral lookup so a Jellyfin server with an empty/missing + // cache row doesn't throw from `getPlexClientForServer`. Jellyfin's + // canonical filter values come from the cached `/Items/Filters` + // payload; if that's unavailable, an empty list is the honest answer + // until a `getFilterValues` lands on [MediaServerClient]. + final List values; + if (cached != null) { + values = cached; + } else { + final client = context.tryGetMediaClientForServer(widget.serverId); + if (client is PlexClient) { + values = await client.getFilterValues(filter.key); + } else { + values = const []; + } + } if (!mounted) return; setState(() { _filterValues = values; diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index 5a970ce4..cd8fabe5 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -6,21 +6,23 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_wrapper.dart'; -import '../../models/plex_metadata.dart'; +import '../../media/media_item.dart'; +import '../../media/media_item_types.dart'; +import '../../media/media_kind.dart'; import '../../providers/settings_provider.dart'; import '../../services/settings_service.dart' show EpisodePosterMode; -import '../../utils/content_utils.dart'; import '../../utils/formatters.dart'; import '../../utils/provider_extensions.dart'; import '../../widgets/media_progress_bar.dart'; -import '../../widgets/plex_optimized_image.dart'; +import '../../widgets/optimized_media_image.dart'; import '../../theme/mono_tokens.dart'; import '../../i18n/strings.g.dart'; +import '../../widgets/loading_indicator_box.dart'; /// Individual item in the folder tree /// Can be either a folder (expandable) or a file (tappable) class FolderTreeItem extends StatelessWidget { - final PlexMetadata item; + final MediaItem item; final int depth; final bool isExpanded; final bool isFolder; @@ -54,12 +56,12 @@ class FolderTreeItem extends StatelessWidget { return Symbols.folder_rounded; } - return switch (item.mediaType) { - PlexMediaType.movie => Symbols.movie_rounded, - PlexMediaType.show => Symbols.tv_rounded, - PlexMediaType.season => Symbols.video_library_rounded, - PlexMediaType.episode => Symbols.play_circle_rounded, - PlexMediaType.collection => Symbols.collections_rounded, + return switch (item.kind) { + MediaKind.movie => Symbols.movie_rounded, + MediaKind.show => Symbols.tv_rounded, + MediaKind.season => Symbols.video_library_rounded, + MediaKind.episode => Symbols.play_circle_rounded, + MediaKind.collection => Symbols.collections_rounded, _ => Symbols.insert_drive_file_rounded, }; } @@ -98,8 +100,8 @@ class FolderTreeItem extends StatelessWidget { if (item.year != null) { parts.add(item.year.toString()); } - if (item.duration != null && item.duration! > 0) { - parts.add(formatDurationTextual(item.duration!)); + if (item.durationMs != null && item.durationMs! > 0) { + parts.add(formatDurationTextual(item.durationMs!)); } if (item.rating != null) { parts.add('★ ${item.rating!.toStringAsFixed(1)}'); @@ -118,9 +120,7 @@ class FolderTreeItem extends StatelessWidget { children: [ SizedBox( width: 24, - child: isLoading - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : AppIcon(expandIcon, fill: 1, size: 20), + child: isLoading ? const LoadingIndicatorBox(size: 16) : AppIcon(expandIcon, fill: 1, size: 20), ), const SizedBox(width: 8), AppIcon(_getIcon(), fill: 1, size: 20, color: Theme.of(context).colorScheme.primary), @@ -228,13 +228,14 @@ class FolderTreeItem extends StatelessWidget { double height, ) { final posterUrl = item.posterThumb(mode: episodePosterMode); - final client = serverId != null ? context.getClientForServer(serverId!) : null; + // Backend-neutral so Jellyfin items render via Jellyfin's transcoder. + final client = context.tryGetMediaClientWithFallback(serverId); final shouldBlur = hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail; Widget image; if (item.usesWideAspectRatio(episodePosterMode)) { - image = PlexOptimizedImage.thumb( + image = OptimizedMediaImage.thumb( client: client, imagePath: posterUrl, width: width, @@ -242,7 +243,7 @@ class FolderTreeItem extends StatelessWidget { fit: BoxFit.cover, ); } else { - image = PlexOptimizedImage.poster( + image = OptimizedMediaImage.poster( client: client, imagePath: posterUrl, width: width, @@ -260,8 +261,7 @@ class FolderTreeItem extends StatelessWidget { } Widget _buildWatchOverlay(BuildContext context, bool showUnwatchedCount) { - final hasActiveProgress = - item.viewOffset != null && item.duration != null && item.viewOffset! > 0 && item.viewOffset! < item.duration!; + final hasActiveProgress = item.hasActiveProgress; return Stack( children: [ @@ -283,7 +283,7 @@ class FolderTreeItem extends StatelessWidget { // Unwatched count for shows/seasons if (showUnwatchedCount && !item.isWatched && - (item.mediaType == PlexMediaType.show || item.mediaType == PlexMediaType.season) && + (item.kind == MediaKind.show || item.kind == MediaKind.season) && (item.leafCount != null && item.leafCount! > 0 && item.viewedLeafCount != null)) Positioned( top: 3, @@ -311,7 +311,7 @@ class FolderTreeItem extends StatelessWidget { right: 0, child: ClipRRect( borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(6), bottomRight: Radius.circular(6)), - child: MediaProgressBar(viewOffset: item.viewOffset!, duration: item.duration!), + child: MediaProgressBar(viewOffset: item.viewOffsetMs!, duration: item.durationMs!), ), ), // Season progress diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index 764977d4..6d4bac3b 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../models/plex_metadata.dart'; +import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; import '../../services/play_queue_launcher.dart'; import '../../utils/app_logger.dart'; import '../../utils/media_navigation_helper.dart'; @@ -33,13 +34,23 @@ class FolderTreeView extends StatefulWidget { } class _FolderTreeViewState extends State { - List _rootFolders = []; - final Map> _childrenCache = {}; + /// Folders/items returned by the Plex `/library/sections/{id}/folder` + /// endpoint, mapped to neutral [MediaItem]s. The Plex `key` (folder URL) + /// survives in [MediaItem.raw] under the `'key'` slot — see + /// [_folderKey]. + List _rootFolders = []; + final Map> _childrenCache = {}; final Set _expandedFolders = {}; final Set _loadingFolders = {}; bool _isLoadingRoot = false; String? _errorMessage; + /// Resolve the Plex folder key from a [MediaItem]'s `raw` map. The key is + /// a relative URL (e.g. `/library/sections/1/folder?parent=...`) used as + /// the cache key and to recursively fetch children from + /// [PlexClient.getFolderChildren]. + String? _folderKey(MediaItem item) => item.raw?['key'] as String?; + @override void initState() { super.initState(); @@ -53,23 +64,16 @@ class _FolderTreeViewState extends State { }); try { - final client = context.getClientForServer(widget.serverId!); + final client = context.getPlexClientForServer(widget.serverId!); - final folders = await client.getLibraryFolders(widget.libraryKey); + // PlexClient.fetchLibraryFolders returns neutral [MediaItem]s; folders + // come back already tagged with the client's serverId/serverName. + final folders = await client.fetchLibraryFolders(widget.libraryKey); if (!mounted) return; - final taggedFolders = folders - .map( - (folder) => folder.copyWith( - serverId: widget.serverId!, - serverName: null, // server name not required for folders listing - ), - ) - .toList(); - setState(() { - _rootFolders = taggedFolders; + _rootFolders = folders; _isLoadingRoot = false; }); @@ -85,34 +89,37 @@ class _FolderTreeViewState extends State { } } - Future _loadFolderChildren(PlexMetadata folder) async { + Future _loadFolderChildren(MediaItem folder) async { + final folderKey = _folderKey(folder); + if (folderKey == null) return; + // Already loading this folder - if (_loadingFolders.contains(folder.key!)) return; + if (_loadingFolders.contains(folderKey)) return; // Already loaded and cached - if (_childrenCache.containsKey(folder.key!)) { + if (_childrenCache.containsKey(folderKey)) { setState(() { - _expandedFolders.add(folder.key!); + _expandedFolders.add(folderKey); }); return; } setState(() { - _loadingFolders.add(folder.key!); + _loadingFolders.add(folderKey); }); try { - final client = context.getClientForServer(widget.serverId!); + final client = context.getPlexClientForServer(widget.serverId!); - // Items are automatically tagged with server info by PlexClient - final children = await client.getFolderChildren(folder.key!); + // Items are automatically tagged with server info by PlexClient. + final children = await client.fetchFolderChildren(folderKey); if (!mounted) return; setState(() { - _childrenCache[folder.key!] = children; - _expandedFolders.add(folder.key!); - _loadingFolders.remove(folder.key!); + _childrenCache[folderKey] = children; + _expandedFolders.add(folderKey); + _loadingFolders.remove(folderKey); }); appLogger.d('Loaded ${children.length} children for folder: ${folder.title}'); @@ -121,7 +128,7 @@ class _FolderTreeViewState extends State { appLogger.e('Failed to load folder children', error: e); setState(() { - _loadingFolders.remove(folder.key!); + _loadingFolders.remove(folderKey); }); if (mounted) { @@ -130,56 +137,64 @@ class _FolderTreeViewState extends State { } } - void _toggleFolder(PlexMetadata folder) { - if (_expandedFolders.contains(folder.key!)) { + void _toggleFolder(MediaItem folder) { + final folderKey = _folderKey(folder); + if (folderKey == null) return; + if (_expandedFolders.contains(folderKey)) { setState(() { - _expandedFolders.remove(folder.key!); + _expandedFolders.remove(folderKey); }); } else { _loadFolderChildren(folder); } } - Future _handleItemTap(PlexMetadata item) async { + Future _handleItemTap(MediaItem item) async { await navigateToMediaItem(context, item, onRefresh: widget.onRefresh); } - Future _handleFolderPlay(PlexMetadata folder) async { - final client = context.getClientForServer(widget.serverId!); - final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder(folderKey: folder.key!, shuffle: false); + Future _handleFolderPlay(MediaItem folder) async { + final folderKey = _folderKey(folder); + if (folderKey == null) return; + final client = context.getPlexClientForServer(widget.serverId!); + final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); + await launcher.launchFromFolder(folderKey: folderKey, shuffle: false); } - Future _handleFolderShuffle(PlexMetadata folder) async { - final client = context.getClientForServer(widget.serverId!); - final launcher = PlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder(folderKey: folder.key!, shuffle: true); + Future _handleFolderShuffle(MediaItem folder) async { + final folderKey = _folderKey(folder); + if (folderKey == null) return; + final client = context.getPlexClientForServer(widget.serverId!); + final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); + await launcher.launchFromFolder(folderKey: folderKey, shuffle: true); } - bool _isFolder(PlexMetadata item) { - // Folders typically don't have a specific type or might have special indicators - // Check for common folder indicators - return item.key?.contains('/folder') == true || - item.type == null || - item.type!.isEmpty || - item.mediaType == PlexMediaType.unknown; + bool _isFolder(MediaItem item) { + // Folders typically have no media kind (Plex returns `type: 'folder'`, + // mapped to [MediaKind.unknown]) or expose `/folder` in their key. + final folderKey = _folderKey(item); + return folderKey?.contains('/folder') == true || item.kind == MediaKind.unknown; } /// Flatten the visible tree into a list of (item, depth, path) tuples so /// `ListView.builder` can lazy-build only the rows currently on screen. void _flattenTreeItems( - List items, + List items, int depth, String parentPath, - List<({PlexMetadata item, int depth, String path})> out, + List<({MediaItem item, int depth, String path})> out, ) { for (int i = 0; i < items.length; i++) { final item = items[i]; final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i'; out.add((item: item, depth: depth, path: itemPath)); - if (_isFolder(item) && _expandedFolders.contains(item.key) && _childrenCache.containsKey(item.key)) { - _flattenTreeItems(_childrenCache[item.key]!, depth + 1, itemPath, out); + final folderKey = _folderKey(item); + if (_isFolder(item) && + folderKey != null && + _expandedFolders.contains(folderKey) && + _childrenCache.containsKey(folderKey)) { + _flattenTreeItems(_childrenCache[folderKey]!, depth + 1, itemPath, out); } } } @@ -203,7 +218,7 @@ class _FolderTreeViewState extends State { return EmptyStateWidget(message: t.libraries.noFoldersFound, icon: Symbols.folder_open_rounded); } - final flattened = <({PlexMetadata item, int depth, String path})>[]; + final flattened = <({MediaItem item, int depth, String path})>[]; _flattenTreeItems(_rootFolders, 0, '', flattened); return RefreshIndicator( @@ -215,8 +230,9 @@ class _FolderTreeViewState extends State { final entry = flattened[index]; final item = entry.item; final isFolder = _isFolder(item); - final isExpanded = _expandedFolders.contains(item.key); - final isLoading = _loadingFolders.contains(item.key); + final folderKey = _folderKey(item); + final isExpanded = folderKey != null && _expandedFolders.contains(folderKey); + final isLoading = folderKey != null && _loadingFolders.contains(folderKey); final isFirstRootItem = index == 0; return FolderTreeItem( diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index c97cf45f..ed791ea5 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -13,11 +13,12 @@ import '../../focus/input_mode_tracker.dart'; import '../../focus/key_event_utils.dart'; import '../../mixins/tab_navigation_mixin.dart'; import '../../../services/plex_client.dart'; -import '../../models/plex_library.dart'; -import '../../models/plex_metadata.dart'; +import '../../media/media_backend.dart'; +import '../../media/media_item.dart'; +import '../../media/media_library.dart'; +import '../../media/media_server_client.dart'; import '../../providers/hidden_libraries_provider.dart'; import '../../providers/libraries_provider.dart'; -import '../../providers/multi_server_provider.dart'; import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/platform_detector.dart'; @@ -38,7 +39,7 @@ import 'tabs/library_playlists_tab.dart'; enum LibraryTabType { recommended, browse, collections, playlists } -List _getVisibleTabs(PlexLibrary library) { +List _getVisibleTabs(MediaLibrary library) { if (library.isShared) return [LibraryTabType.browse, LibraryTabType.playlists]; return LibraryTabType.values; } @@ -82,16 +83,6 @@ class _LibrariesScreenState extends State ItemUpdatable, TickerProviderStateMixin, TabNavigationMixin { - @override - PlexClient get client { - final multiServerProvider = Provider.of(context, listen: false); - final serverId = multiServerProvider.onlineServerIds.firstOrNull; - if (serverId == null) { - throw Exception(t.errors.noClientAvailable); - } - return context.getClientForServer(serverId); - } - // GlobalKeys for tabs to enable refresh final _recommendedTabKey = GlobalKey(); final _browseTabKey = GlobalKey(); @@ -368,7 +359,7 @@ class _LibrariesScreenState extends State Widget _buildTabContent( LibraryTabType type, { - required PlexLibrary library, + required MediaLibrary library, required bool isActive, required int tabIndex, }) { @@ -409,7 +400,7 @@ class _LibrariesScreenState extends State } /// Check if libraries come from multiple servers - bool _hasMultipleServers(List libraries) { + bool _hasMultipleServers(List libraries) { final uniqueServerIds = libraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet(); return uniqueServerIds.length > 1; } @@ -479,7 +470,7 @@ class _LibrariesScreenState extends State } @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + void updateItemInLists(String itemId, MediaItem updatedItem) { // Delegate to the active tab — parent doesn't maintain its own item list } @@ -515,7 +506,7 @@ class _LibrariesScreenState extends State _initializeWithLibraries(); } - Future _toggleLibraryVisibility(PlexLibrary library) async { + Future _toggleLibraryVisibility(MediaLibrary library) async { if (!mounted) return; final librariesProvider = context.read(); final hiddenLibrariesProvider = Provider.of(context, listen: false); @@ -544,7 +535,24 @@ class _LibrariesScreenState extends State } } - List _getLibraryMenuItems(PlexLibrary library) { + 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). + final refresh = ContextMenuItem( + value: 'refresh', + icon: Symbols.sync_rounded, + label: t.libraries.refreshMetadata, + requiresConfirmation: true, + confirmationTitle: t.libraries.refreshMetadata, + confirmationMessage: t.libraries.refreshMetadataConfirm(title: library.title), + isDestructive: true, + ); + // Scan / analyze / empty trash hit Plex-only endpoints. Gating them keeps + // [getPlexClientForLibrary] from falling back through `_resolveClient` to + // the first online Plex server and firing the action against the wrong + // backend. + if (library.backend != MediaBackend.plex) return [refresh]; return [ ContextMenuItem( value: 'scan', @@ -562,15 +570,7 @@ class _LibrariesScreenState extends State confirmationTitle: t.libraries.analyzeLibrary, confirmationMessage: t.libraries.analyzeLibraryConfirm(title: library.title), ), - ContextMenuItem( - value: 'refresh', - icon: Symbols.sync_rounded, - label: t.libraries.refreshMetadata, - requiresConfirmation: true, - confirmationTitle: t.libraries.refreshMetadata, - confirmationMessage: t.libraries.refreshMetadataConfirm(title: library.title), - isDestructive: true, - ), + refresh, ContextMenuItem( value: 'empty_trash', icon: Symbols.delete_outline_rounded, @@ -583,7 +583,7 @@ class _LibrariesScreenState extends State ]; } - Future _handleLibraryMenuAction(String action, PlexLibrary library) async { + Future _handleLibraryMenuAction(String action, MediaLibrary library) async { // Find the menu item for confirmation details final menuItems = _getLibraryMenuItems(library); final item = menuItems.where((i) => i.value == action).firstOrNull; @@ -655,14 +655,14 @@ class _LibrariesScreenState extends State } Future _performLibraryAction({ - required PlexLibrary library, + required MediaLibrary library, required Future Function(PlexClient client) action, required String progressMessage, required String successMessage, required String Function(Object error) failureMessage, }) async { try { - final client = context.getClientForLibrary(library); + final client = context.getPlexClientForLibrary(library); if (mounted) { showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); @@ -681,40 +681,71 @@ class _LibrariesScreenState extends State } } - Future _scanLibrary(PlexLibrary library) { + /// Backend-neutral counterpart to [_performLibraryAction] for ops that exist + /// on the [MediaServerClient] interface (currently just refresh metadata). + /// Resolves the client through `getMediaClientForLibrary` so a Jellyfin + /// library is routed to its own server, not a fallback Plex one. + Future _performMediaLibraryAction({ + required MediaLibrary library, + required Future Function(MediaServerClient client) action, + required String progressMessage, + required String successMessage, + required String Function(Object error) failureMessage, + }) async { + try { + final client = context.getMediaClientForLibrary(library); + + if (mounted) { + showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); + } + + await action(client); + + if (mounted) { + showSuccessSnackBar(context, successMessage); + } + } catch (e) { + appLogger.e('Library action failed', error: e); + if (mounted) { + showErrorSnackBar(context, failureMessage(e)); + } + } + } + + Future _scanLibrary(MediaLibrary library) { return _performLibraryAction( library: library, - action: (client) => client.scanLibrary(library.key), + action: (client) => client.scanLibrary(library.id), progressMessage: t.messages.libraryScanning(title: library.title), successMessage: t.messages.libraryScanStarted(title: library.title), failureMessage: (error) => t.messages.libraryScanFailed(error: error.toString()), ); } - Future _refreshLibraryMetadata(PlexLibrary library) { - return _performLibraryAction( + Future _refreshLibraryMetadata(MediaLibrary library) { + return _performMediaLibraryAction( library: library, - action: (client) => client.refreshLibraryMetadata(library.key), + action: (client) => client.refreshLibraryMetadata(library.id), progressMessage: t.messages.metadataRefreshing(title: library.title), successMessage: t.messages.metadataRefreshStarted(title: library.title), failureMessage: (error) => t.messages.metadataRefreshFailed(error: error.toString()), ); } - Future _emptyLibraryTrash(PlexLibrary library) { + Future _emptyLibraryTrash(MediaLibrary library) { return _performLibraryAction( library: library, - action: (client) => client.emptyLibraryTrash(library.key), + action: (client) => client.emptyLibraryTrash(library.id), progressMessage: t.libraries.emptyingTrash(title: library.title), successMessage: t.libraries.trashEmptied(title: library.title), failureMessage: (error) => t.libraries.failedToEmptyTrash(error: error), ); } - Future _analyzeLibrary(PlexLibrary library) { + Future _analyzeLibrary(MediaLibrary library) { return _performLibraryAction( library: library, - action: (client) => client.analyzeLibrary(library.key), + action: (client) => client.analyzeLibrary(library.id), progressMessage: t.libraries.analyzing(title: library.title), successMessage: t.libraries.analysisStarted(title: library.title), failureMessage: (error) => t.libraries.failedToAnalyze(error: error), @@ -722,7 +753,7 @@ class _LibrariesScreenState extends State } /// Get set of library names that appear more than once (not globally unique) - Set _getNonUniqueLibraryNames(List libraries) { + Set _getNonUniqueLibraryNames(List libraries) { final nameCounts = {}; for (final lib in libraries) { nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1; @@ -731,7 +762,7 @@ class _LibrariesScreenState extends State } /// Build dropdown menu items with server subtitle for non-unique names - List> _buildGroupedLibraryMenuItems(List visibleLibraries) { + List> _buildGroupedLibraryMenuItems(List visibleLibraries) { // Find which library names are not unique final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries); @@ -744,7 +775,7 @@ class _LibrariesScreenState extends State child: Row( children: [ AppIcon( - ContentTypeHelper.getLibraryIcon(library.type), + ContentTypeHelper.getLibraryIcon(library.kind.id), fill: 1, size: 20, color: isSelected ? Theme.of(context).colorScheme.primary : null, @@ -780,7 +811,7 @@ class _LibrariesScreenState extends State } /// Build the app bar title - either dropdown on mobile or simple title on desktop - Widget _buildAppBarTitle(List visibleLibraries, PlexLibrary? selectedLibrary) { + Widget _buildAppBarTitle(List visibleLibraries, MediaLibrary? selectedLibrary) { // No selection at all, or visible list is empty AND we're not browsing a hidden library if (_selectedLibraryGlobalKey == null || (visibleLibraries.isEmpty && selectedLibrary == null)) { return Text(t.libraries.title); @@ -809,7 +840,7 @@ class _LibrariesScreenState extends State return _buildLibraryDropdownTitle(visibleLibraries); } - Widget _buildLibraryDropdownTitle(List visibleLibraries) { + Widget _buildLibraryDropdownTitle(List visibleLibraries) { final selectedLibrary = visibleLibraries.where((lib) => lib.globalKey == _selectedLibraryGlobalKey).firstOrNull ?? visibleLibraries.firstOrNull; @@ -828,7 +859,7 @@ class _LibrariesScreenState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - AppIcon(ContentTypeHelper.getLibraryIcon(selectedLibrary.type), fill: 1, size: 20), + AppIcon(ContentTypeHelper.getLibraryIcon(selectedLibrary.kind.id), fill: 1, size: 20), const SizedBox(width: 8), if (_hasMultipleServers(visibleLibraries) && selectedLibrary.serverName != null) Column( @@ -994,12 +1025,12 @@ class _LibrariesScreenState extends State class _LibraryManagementSheet extends StatefulWidget { final bool isDialog; - final List allLibraries; + final List allLibraries; final Set hiddenLibraryKeys; - final Function(List) onReorder; - final Function(PlexLibrary) onToggleVisibility; - final List Function(PlexLibrary) getLibraryMenuItems; - final void Function(String action, PlexLibrary library) onLibraryMenuAction; + final Function(List) onReorder; + final Function(MediaLibrary) onToggleVisibility; + final List Function(MediaLibrary) getLibraryMenuItems; + final void Function(String action, MediaLibrary library) onLibraryMenuAction; const _LibraryManagementSheet({ this.isDialog = false, @@ -1016,14 +1047,14 @@ class _LibraryManagementSheet extends StatefulWidget { } class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { - late List _tempLibraries; + late List _tempLibraries; // Keyboard navigation state int _focusedIndex = 0; int _focusedColumn = 0; // 0 = row, 1 = visibility button, 2 = options button int? _movingIndex; // Non-null when in move mode int? _originalIndex; // Original position before move (for cancel) - List? _originalOrder; // Original order before move (for cancel) + List? _originalOrder; // Original order before move (for cancel) final FocusNode _listFocusNode = FocusNode(); final ScrollController _dialogScrollController = ScrollController(); bool _backKeyDownSeen = false; @@ -1204,7 +1235,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { widget.onReorder(_tempLibraries); } - void _showLibraryMenuBottomSheet(BuildContext outerContext, PlexLibrary library) { + void _showLibraryMenuBottomSheet(BuildContext outerContext, MediaLibrary library) { final menuItems = widget.getLibraryMenuItems(library); OverlaySheetController.pushAdaptive( outerContext, @@ -1391,7 +1422,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { /// Build a single library tile Widget _buildLibraryTile( - PlexLibrary library, + MediaLibrary library, int index, Set hiddenLibraryKeys, { bool showServerName = false, @@ -1433,7 +1464,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ), ), const SizedBox(width: 8), - AppIcon(ContentTypeHelper.getLibraryIcon(library.type), fill: 1), + AppIcon(ContentTypeHelper.getLibraryIcon(library.kind.id), fill: 1), ], ), title: Text(library.title), diff --git a/lib/screens/libraries/library_alpha_bar_strategy.dart b/lib/screens/libraries/library_alpha_bar_strategy.dart new file mode 100644 index 00000000..c21d3fb9 --- /dev/null +++ b/lib/screens/libraries/library_alpha_bar_strategy.dart @@ -0,0 +1,212 @@ +import '../../media/library_first_character.dart'; +import '../../media/media_backend.dart'; +import '../../services/plex_client.dart'; +import 'alpha_jump_helper.dart'; + +/// Backend-specific alpha-jump-bar behaviour. +/// +/// Plex libraries use real per-letter counts from +/// `/library/sections/{id}/firstCharacter`, so the bar is scroll-position +/// 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). +abstract class LibraryAlphaBarStrategy { + /// Whether the bar should be rendered at all. Implementations consider + /// total item count, sort key, and current filter state. + bool shouldShow({ + required int totalItemCount, + required int loadedCharacterCount, + required String? sortKey, + required bool isFolderGrouping, + required String? jellyfinAlphaPrefix, + required bool isPhone, + }); + + /// Load the first-character buckets for the current filter state. + /// Returns the new helper plus the synthesised character list — the caller + /// stores both in widget state. + Future<({List chars, AlphaJumpHelper helper})> loadCharacters({ + required Map filters, + required int? typeId, + }); + + /// 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}); + + /// 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 + /// `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 void Function(int index) onPlexJump, + required void Function(String? nextPrefix) onJellyfinPrefixChange, + }); + + /// Construct the right strategy for [backend]. + factory LibraryAlphaBarStrategy.forBackend( + MediaBackend backend, { + required PlexClient Function() plexClientProvider, + required String libraryKey, + required bool isShared, + }) { + return switch (backend) { + MediaBackend.plex => PlexAlphaBarStrategy( + plexClientProvider: plexClientProvider, + libraryKey: libraryKey, + isShared: isShared, + ), + MediaBackend.jellyfin => const JellyfinAlphaBarStrategy(), + }; + } +} + +/// Plex strategy — calls `/library/sections/{id}/firstCharacter` for real +/// per-letter counts and uses the cumulative offsets to drive scroll +/// position. +class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy { + final PlexClient Function() plexClientProvider; + final String libraryKey; + final bool isShared; + + PlexAlphaBarStrategy({required this.plexClientProvider, required this.libraryKey, required this.isShared}); + + @override + bool shouldShow({ + required int totalItemCount, + required int loadedCharacterCount, + required String? sortKey, + required bool isFolderGrouping, + required String? jellyfinAlphaPrefix, + required bool isPhone, + }) { + if (isFolderGrouping) return false; + if (loadedCharacterCount < 6 || totalItemCount < 80) return false; + final s = sortKey ?? ''; + return s.isEmpty || s.startsWith('titleSort'); + } + + @override + Future<({List chars, AlphaJumpHelper helper})> loadCharacters({ + required Map filters, + required int? typeId, + }) async { + if (isShared) { + // Shared libraries don't support first-characters. + return (chars: const [], helper: AlphaJumpHelper(const [])); + } + final client = plexClientProvider(); + final params = Map.from(filters); + params['includeCollections'] = '1'; + final chars = await client.getFirstCharacters(libraryKey, type: typeId, filters: params.isNotEmpty ? params : null); + return (chars: chars, helper: AlphaJumpHelper(chars)); + } + + @override + String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix}) => 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 + /// the server's `/firstCharacter` counts. + @override + void onLetterPressed( + int targetIndex, + AlphaJumpHelper helper, { + required String? currentJellyfinPrefix, + required void Function(int index) onPlexJump, + required void Function(String? nextPrefix) onJellyfinPrefixChange, + }) { + onPlexJump(targetIndex); + } +} + +/// Jellyfin strategy — synthesises the 27-letter alphabet locally and uses +/// the bar as a `NameStartsWith` filter. +class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy { + static const _letters = [ + '#', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + ]; + + const JellyfinAlphaBarStrategy(); + + @override + bool shouldShow({ + required int totalItemCount, + required int loadedCharacterCount, + required String? sortKey, + required bool isFolderGrouping, + required String? jellyfinAlphaPrefix, + required bool isPhone, + }) { + if (isPhone) return false; + if (isFolderGrouping) return false; + if (loadedCharacterCount == 0) return false; + return totalItemCount >= 80 || jellyfinAlphaPrefix != null; + } + + @override + Future<({List chars, AlphaJumpHelper helper})> loadCharacters({ + required Map filters, + required int? typeId, + }) async { + final synthetic = [for (final l in _letters) LibraryFirstCharacter(key: l, title: l, size: 1)]; + return (chars: synthetic, helper: AlphaJumpHelper(synthetic)); + } + + @override + String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix}) => jellyfinAlphaPrefix ?? ''; + + /// 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. + @override + void onLetterPressed( + int targetIndex, + AlphaJumpHelper helper, { + required String? currentJellyfinPrefix, + required void Function(int index) onPlexJump, + required void Function(String? nextPrefix) onJellyfinPrefixChange, + }) { + if (targetIndex < 0 || targetIndex >= helper.letters.length) return; + final letter = helper.letters[targetIndex]; + final next = (currentJellyfinPrefix == letter) ? null : letter; + onJellyfinPrefixChange(next); + } +} diff --git a/lib/screens/libraries/library_filter_sort_loader.dart b/lib/screens/libraries/library_filter_sort_loader.dart new file mode 100644 index 00000000..0cfffd55 --- /dev/null +++ b/lib/screens/libraries/library_filter_sort_loader.dart @@ -0,0 +1,43 @@ +import '../../media/library_filter_result.dart'; +import '../../media/media_filter.dart'; +import '../../media/media_library.dart'; +import '../../media/media_server_client.dart'; +import '../../media/media_sort.dart'; + +/// Combined filter + sort listing loaded for a [MediaLibrary]. +/// +/// Plex returns categories from `/library/sections/{id}/filters` and sort +/// options from `/library/sections/{id}/sorts` separately, with values +/// fetched lazily per-category via `FiltersBottomSheet`. Jellyfin returns +/// categories *and* values together via `/Items/Filters` (so [cachedValues] +/// is populated up-front) and has no sort-listing endpoint, so its sorts +/// come from a client-side hardcoded list. +class LoadedFiltersAndSorts { + final List filters; + final List sorts; + final Map> cachedValues; + + const LoadedFiltersAndSorts({required this.filters, required this.sorts, this.cachedValues = const {}}); +} + +/// Loads filter categories and sort options for a [MediaLibrary] across both +/// backends through the unified [MediaServerClient] interface — Plex pulls +/// categories with no values (FiltersBottomSheet fetches lazily), Jellyfin +/// pre-populates [cachedValues] from `/Items/Filters`. [clientFor] resolves +/// the right concrete client for the library being loaded. +class LibraryFilterSortLoader { + final MediaServerClient Function(MediaLibrary library) clientFor; + + LibraryFilterSortLoader({required this.clientFor}); + + Future load(MediaLibrary library) async { + final client = clientFor(library); + final results = await Future.wait([ + client.fetchLibraryFiltersWithValues(library.id), + client.fetchSortOptions(library.id, libraryType: library.kind.id), + ]); + final filterResult = results[0] as LibraryFilterResult; + final sorts = results[1] as List; + return LoadedFiltersAndSorts(filters: filterResult.filters, sorts: sorts, cachedValues: filterResult.cachedValues); + } +} diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index 97a87d26..cd2e1874 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -3,7 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/dpad_navigator.dart'; import '../../focus/input_mode_tracker.dart'; -import '../../models/plex_sort.dart'; +import '../../media/media_sort.dart'; import '../../utils/scroll_utils.dart'; import '../../widgets/bottom_sheet_header.dart'; import '../../widgets/focusable_list_tile.dart'; @@ -11,10 +11,10 @@ import '../../widgets/overlay_sheet.dart'; import '../../i18n/strings.g.dart'; class SortBottomSheet extends StatefulWidget { - final List sortOptions; - final PlexSort? selectedSort; + final List sortOptions; + final MediaSort? selectedSort; final bool isSortDescending; - final Function(PlexSort, bool) onSortChanged; + final Function(MediaSort, bool) onSortChanged; final VoidCallback? onClear; const SortBottomSheet({ @@ -31,7 +31,7 @@ class SortBottomSheet extends StatefulWidget { } class _SortBottomSheetState extends State { - late PlexSort? _currentSort; + late MediaSort? _currentSort; late bool _currentDescending; late final FocusNode _initialFocusNode; final _firstItemKey = GlobalKey(); @@ -74,7 +74,7 @@ class _SortBottomSheetState extends State { super.dispose(); } - void _handleSortSelect(PlexSort sort) { + void _handleSortSelect(MediaSort sort) { final descending = (_currentSort?.key == sort.key) ? _currentDescending : sort.isDefaultDescending; setState(() { _currentSort = sort; @@ -83,7 +83,7 @@ class _SortBottomSheetState extends State { widget.onSortChanged(sort, descending); } - void _handleDirectionChange(PlexSort sort, bool descending) { + void _handleDirectionChange(MediaSort sort, bool descending) { setState(() { _currentDescending = descending; }); @@ -109,7 +109,7 @@ class _SortBottomSheetState extends State { action: widget.onClear != null ? TextButton(onPressed: _handleClear, child: Text(t.common.clear)) : null, ), Expanded( - child: RadioGroup( + child: RadioGroup( groupValue: _currentSort, onChanged: (value) { if (value != null) _handleSortSelect(value); @@ -139,7 +139,7 @@ class _SortBottomSheetState extends State { } return KeyEventResult.ignored; }, - child: FocusableRadioListTile( + child: FocusableRadioListTile( focusNode: (widget.selectedSort?.key == sort.key || (widget.selectedSort == null && index == 0)) ? _initialFocusNode : null, diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index ade589f8..1565e36e 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../focus/input_mode_tracker.dart'; -import '../../../models/plex_library.dart'; +import '../../../media/media_library.dart'; import '../../../utils/app_logger.dart'; import '../../../mixins/library_tab_state.dart'; import '../../../mixins/refreshable.dart'; @@ -22,7 +22,7 @@ import '../content_state_builder.dart'; /// - [errorContext]: Context for error messages (defaults to "content") /// - [getRefreshStream]: Stream to listen for refresh events abstract class BaseLibraryTab extends StatefulWidget { - final PlexLibrary library; + final MediaLibrary library; final String? viewMode; final String? density; @@ -62,7 +62,7 @@ abstract class BaseLibraryTabState> extends State bool get wantKeepAlive => true; @override - PlexLibrary get library => widget.library; + MediaLibrary get library => widget.library; @override void refresh() { diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 1e0916a8..fd20ba84 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -4,24 +4,30 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import '../../../utils/plex_http_client.dart'; -import '../../../utils/plex_http_exception.dart'; +import '../../../media/library_first_character.dart'; +import '../../../media/library_query.dart'; +import '../../../media/media_item.dart'; +import '../../../providers/multi_server_provider.dart'; +import '../../../utils/media_server_http_client.dart'; +import '../../../exceptions/media_server_exceptions.dart'; import '../../../focus/dpad_navigator.dart'; import '../../../focus/input_mode_tracker.dart'; -import '../../../../services/plex_client.dart'; -import '../../../models/plex_metadata.dart'; -import '../../../models/plex_filter.dart'; -import '../../../models/plex_first_character.dart'; -import '../../../models/plex_sort.dart'; +import '../../../media/media_filter.dart'; +import '../../../media/media_sort.dart'; import '../../../providers/settings_provider.dart'; import '../../../services/image_cache_service.dart'; +import '../../../services/library_query_translator.dart'; +import '../../../services/plex_constants.dart'; import '../../../utils/error_message_utils.dart'; import '../../../utils/grid_size_calculator.dart'; import '../../../utils/layout_constants.dart'; -import '../../../utils/plex_image_helper.dart'; +import '../../../utils/media_image_helper.dart'; +import '../../../utils/provider_extensions.dart'; import '../alpha_jump_bar.dart'; import '../alpha_jump_helper.dart'; import '../alpha_scroll_handle.dart'; +import '../library_alpha_bar_strategy.dart'; +import '../library_filter_sort_loader.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; import '../../../widgets/media_grid_delegate.dart'; @@ -49,7 +55,7 @@ import 'base_library_tab.dart'; /// Browse tab for library screen /// Shows library items with grouping, filtering, and sorting -class LibraryBrowseTab extends BaseLibraryTab { +class LibraryBrowseTab extends BaseLibraryTab { const LibraryBrowseTab({ super.key, required super.library, @@ -65,10 +71,10 @@ class LibraryBrowseTab extends BaseLibraryTab { State createState() => _LibraryBrowseTabState(); } -class _LibraryBrowseTabState extends BaseLibraryTabState +class _LibraryBrowseTabState extends BaseLibraryTabState with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin, DeletionAware, PaginatedItemLoader { @override - PlexClient get client => getClientForLibrary(); + String? get itemServerId => widget.library.serverId; String _toGlobalKey(String ratingKey, {String? serverId}) => buildGlobalKey(serverId ?? widget.library.serverId ?? '', ratingKey); @@ -77,7 +83,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState widget.library.serverId; @override - Set? get deletionRatingKeys => loadedItems.values.map((e) => e.ratingKey).toSet(); + Set? get deletionIds => loadedItems.values.map((e) => e.id).toSet(); @override Set? get deletionGlobalKeys { @@ -87,7 +93,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState e.value.ratingKey == event.ratingKey).firstOrNull; + final matchEntry = loadedItems.entries.where((e) => e.value.id == event.itemId).firstOrNull; if (matchEntry != null) { setState(() { removeLoadedItemAndShift(matchEntry.key); @@ -107,7 +113,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState e.value.ratingKey == parentKey).firstOrNull; + final parentEntry = loadedItems.entries.where((e) => e.value.id == parentKey).firstOrNull; if (parentEntry != null) { final item = parentEntry.value; final newLeafCount = (item.leafCount ?? 1) - event.leafCount; @@ -126,7 +132,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState totalSize; @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { + void updateItemInLists(String itemId, MediaItem updatedMetadata) { setState(() { for (final entry in loadedItems.entries) { - if (entry.value.ratingKey == ratingKey) { + if (entry.value.id == itemId) { loadedItems[entry.key] = updatedMetadata; break; } @@ -150,16 +156,37 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _filters = []; - List _sortOptions = []; + List _filters = []; + List _sortOptions = []; Map _selectedFilters = {}; - PlexSort? _selectedSort; + MediaSort? _selectedSort; bool _isSortDescending = false; String _selectedGrouping = 'all'; // all, seasons, episodes, folders // Alpha jump bar state - List _firstCharacters = []; + List _firstCharacters = []; AlphaJumpHelper _alphaHelper = AlphaJumpHelper(const []); + late final LibraryAlphaBarStrategy _alphaStrategy = LibraryAlphaBarStrategy.forBackend( + widget.library.backend, + // Resolved on demand and only invoked by [PlexAlphaBarStrategy], which is + // only constructed when the library's backend is Plex — the bang is safe. + plexClientProvider: () { + final manager = context.read().serverManager; + return manager.getPlexClient(widget.library.serverId ?? '')!; + }, + libraryKey: widget.library.id, + isShared: widget.library.isShared, + ); + + /// On Jellyfin libraries the alpha bar acts as a filter (matches the + /// JF web client's UX). Holds the active letter (`#`, `A`–`Z`) or null + /// when no filter is applied. + String? _jellyfinAlphaPrefix; + + /// Pre-fetched filter values for Jellyfin libraries — populated by + /// `_loadContent` and consumed by the FiltersBottomSheet so the sheet + /// doesn't need to call back into a Plex client for value listings. + Map> _jellyfinFilterValues = const {}; final ValueNotifier _currentFirstVisibleIndex = ValueNotifier(0); int _currentColumnCount = 1; double _lastCrossAxisExtent = 0; @@ -240,7 +267,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> loadData() async { + Future> loadData() async { // This is called by base class loadItems(), but we override loadItems() entirely // So this just returns empty - actual loading is done in _loadContent return []; @@ -264,7 +291,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState items) => const SizedBox.shrink(); + Widget buildContent(List items) => const SizedBox.shrink(); /// Focus the first item in the grid/list/folder tree (for tab activation) @override @@ -334,45 +361,32 @@ class _LibraryBrowseTabState extends BaseLibraryTabState s.key == sortKey).firstOrNull; + final sort = loaded.sorts.where((s) => s.key == sortKey).firstOrNull; if (sort != null) { _selectedSort = sort; _isSortDescending = (savedSort['descending'] as bool?) ?? false; @@ -401,6 +415,25 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildFilterParams() { final filterParams = Map.from(_selectedFilters); @@ -413,7 +446,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState fetchPage(int start, int size, AbortController? abort) { - return getClientForLibrary().getLibraryContent( - widget.library.key, - start: start, - size: size, - filters: _buildFilterParams(), + Future> fetchPage(int start, int size, AbortController? abort) async { + final client = context.getMediaClientForLibrary(widget.library); + final query = libraryQueryFromPlexMap( + map: _buildFilterParams(), + libraryKind: widget.library.kind, + offset: start, + limit: size, + ); + return client.fetchLibraryPagedContent( + widget.library.id, + query: query, + libraryKind: widget.library.kind, abort: abort, ); } @override - void onPageLoaded(int start, List pageItems) { + void onPageLoaded(int start, List pageItems) { _prefetchImages(start, pageItems); } String _getDefaultGrouping() { - final type = widget.library.type.toLowerCase(); + final type = widget.library.kind.id.toLowerCase(); if (type == 'show') return 'shows'; if (type == 'movie') return 'movies'; return 'all'; @@ -503,16 +548,21 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _getGroupingOptions() { - final type = widget.library.type.toLowerCase(); + final type = widget.library.kind.id.toLowerCase(); + // Folder browsing relies on a section folder API + // (Plex `/library/sections/{id}/folders`); gated by capability so any + // future backend that exposes the same can opt in without touching + // this method. + final canFolder = context.tryGetMediaClientForServer(widget.library.serverId)?.capabilities.folderGrouping ?? false; if (type == 'show') { - return ['shows', 'seasons', 'episodes', 'folders']; + return ['shows', 'seasons', 'episodes', if (canFolder) 'folders']; } else if (type == 'movie') { - return ['movies', 'folders']; + return ['movies', if (canFolder) 'folders']; } else if (type == 'mixed') { // Shared libraries: all video content types, no folders return ['all', 'movies', 'shows', 'seasons', 'episodes']; } - return ['all', 'folders']; + return ['all', if (canFolder) 'folders']; } String _getGroupingLabel(String grouping) { @@ -533,7 +583,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _alphaHelper.currentLetter(index); + String _alphaLetterFor(int index) => + _alphaStrategy.currentLetter(index, _alphaHelper, jellyfinAlphaPrefix: _jellyfinAlphaPrefix); /// Whether the alpha jump bar should be shown. /// Only shown when sorting by title (titleSort) and not in folders mode. - bool get _shouldShowAlphaJumpBar { - if (_selectedGrouping == 'folders') return false; - if (_firstCharacters.isEmpty) return false; - if (_firstCharacters.length < 6 || _alphaHelper.totalItemCount < 80) return false; - // Show when no sort is selected (default is titleSort) or when explicitly sorting by title - final sortKey = _selectedSort?.key ?? ''; - return sortKey.isEmpty || sortKey.startsWith('titleSort'); - } + bool get _shouldShowAlphaJumpBar => _alphaStrategy.shouldShow( + totalItemCount: totalSize, + loadedCharacterCount: _firstCharacters.length, + sortKey: _selectedSort?.key, + isFolderGrouping: _selectedGrouping == 'folders', + jellyfinAlphaPrefix: _jellyfinAlphaPrefix, + isPhone: _isPhone(context), + ); /// Fetch first characters for the current library/filter state Future _loadFirstCharacters({int? requestId}) async { - // Shared libraries don't support first characters - if (widget.library.isShared) return; final currentRequestId = requestId ?? ++_firstCharactersRequestId; - final client = getClientForLibrary(); final filterParams = Map.from(_selectedFilters); final typeId = _getGroupingTypeId(); - filterParams['includeCollections'] = '1'; - try { - final chars = await client.getFirstCharacters( - widget.library.key, - type: typeId.isNotEmpty ? int.tryParse(typeId) : null, - filters: filterParams.isNotEmpty ? filterParams : null, + final result = await _alphaStrategy.loadCharacters( + filters: filterParams, + typeId: typeId.isNotEmpty ? int.tryParse(typeId) : null, ); if (!mounted || currentRequestId != _firstCharactersRequestId) return; - setState(() { - _firstCharacters = chars; - _alphaHelper = AlphaJumpHelper(chars); + _firstCharacters = result.chars; + _alphaHelper = result.helper; }); } catch (_) { // Non-critical — hide the bar on failure if (!mounted || currentRequestId != _firstCharactersRequestId) return; - setState(() { _firstCharacters = []; _alphaHelper = AlphaJumpHelper(const []); @@ -879,11 +926,24 @@ class _LibraryBrowseTabState extends BaseLibraryTabState items) { + void _prefetchImages(int startIndex, List items) { if (!_scrollController.hasClients || _lastCrossAxisExtent <= 0 || _currentColumnCount < 1) return; final offset = _scrollController.offset; @@ -1094,30 +1172,30 @@ class _LibraryBrowseTabState extends BaseLibraryTabState prefetchEnd) continue; - final thumb = items[i].thumb; + final thumb = items[i].thumbPath; if (thumb == null || thumb.isEmpty) continue; - final imageUrl = PlexImageHelper.getOptimizedImageUrl( + final imageUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: thumb, maxWidth: itemWidth, maxHeight: itemHeight, devicePixelRatio: devicePixelRatio, - enableTranscoding: PlexImageHelper.shouldTranscode(thumb), + enableTranscoding: MediaImageHelper.shouldTranscode(thumb), imageType: ImageType.poster, ); if (imageUrl.isEmpty) continue; final scaledWidth = itemWidth * devicePixelRatio; final scaledHeight = itemHeight * devicePixelRatio; - final (_, memHeight) = PlexImageHelper.getMemCacheDimensions( + final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0, displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0, imageType: ImageType.poster, @@ -1333,7 +1411,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState { +class LibraryCollectionsTab extends BaseLibraryTab { const LibraryCollectionsTab({ super.key, required super.library, @@ -26,7 +26,7 @@ class LibraryCollectionsTab extends BaseLibraryTab { State createState() => _LibraryCollectionsTabState(); } -class _LibraryCollectionsTabState extends LibraryGridTabState { +class _LibraryCollectionsTabState extends LibraryGridTabState { @override String get focusNodeDebugLabel => 'collections_first_item'; @@ -43,18 +43,15 @@ class _LibraryCollectionsTabState extends LibraryGridTabState? getRefreshStream() => LibraryRefreshNotifier().collectionsStream; @override - Future> loadData() async { - // Use server-specific client for this library - final client = getClientForLibrary(); - - // Collections are automatically tagged with server info by PlexClient - return await client.getLibraryCollections(widget.library.key); + Future> loadData() async { + final client = getMediaClientForLibrary(); + return client.fetchCollections(widget.library.id); } @override - Widget buildGridItem(BuildContext context, PlexMetadata item, int index, [GridItemContext? gridContext]) { + Widget buildGridItem(BuildContext context, MediaItem item, int index, [GridItemContext? gridContext]) { return FocusableMediaCard( - key: Key(item.ratingKey), + key: Key(item.id), item: item, focusNode: index == 0 ? firstItemFocusNode : null, disableScale: gridContext?.isListMode ?? false, diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 85f9eef3..2cf2cb2f 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../models/plex_playlist.dart'; +import '../../../media/media_playlist.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../i18n/strings.g.dart'; @@ -10,7 +10,7 @@ import 'library_grid_tab_state.dart'; /// Playlists tab for library screen /// Shows playlists that contain items from the current library -class LibraryPlaylistsTab extends BaseLibraryTab { +class LibraryPlaylistsTab extends BaseLibraryTab { const LibraryPlaylistsTab({ super.key, required super.library, @@ -26,7 +26,7 @@ class LibraryPlaylistsTab extends BaseLibraryTab { State createState() => _LibraryPlaylistsTabState(); } -class _LibraryPlaylistsTabState extends LibraryGridTabState { +class _LibraryPlaylistsTabState extends LibraryGridTabState { @override String get focusNodeDebugLabel => 'playlists_first_item'; @@ -43,18 +43,17 @@ class _LibraryPlaylistsTabState extends LibraryGridTabState? getRefreshStream() => LibraryRefreshNotifier().playlistsStream; @override - Future> loadData() async { - // Use server-specific client for this library - final client = getClientForLibrary(); - - // Playlists are automatically tagged with server info by PlexClient - return await client.getLibraryPlaylists(playlistType: 'video'); + Future> loadData() async { + // Both backends return playlists scoped to the server (not the library) — + // neither Plex nor Jellyfin's API filters playlists by section. + final client = getMediaClientForLibrary(); + return client.fetchPlaylists(playlistType: 'video'); } @override - Widget buildGridItem(BuildContext context, PlexPlaylist playlist, int index, [GridItemContext? gridContext]) { + Widget buildGridItem(BuildContext context, MediaPlaylist playlist, int index, [GridItemContext? gridContext]) { return FocusableMediaCard( - key: Key(playlist.ratingKey), + key: Key(playlist.id), item: playlist, focusNode: index == 0 ? firstItemFocusNode : null, disableScale: gridContext?.isListMode ?? false, diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 9957b64a..98d951b2 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -1,18 +1,18 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../../services/plex_client.dart'; import '../../../i18n/strings.g.dart'; +import '../../../media/media_hub.dart'; +import '../../../media/media_item.dart'; import '../../../mixins/item_updatable.dart'; -import '../../../models/plex_hub.dart'; -import '../../../models/plex_metadata.dart'; +import '../../../utils/provider_extensions.dart'; import '../../../widgets/hub_section.dart'; import '../../main_screen.dart'; import 'base_library_tab.dart'; /// Recommended tab for library screen /// Shows library-specific hubs and recommendations, including dedicated Continue Watching -class LibraryRecommendedTab extends BaseLibraryTab { +class LibraryRecommendedTab extends BaseLibraryTab { const LibraryRecommendedTab({ super.key, required super.library, @@ -26,20 +26,24 @@ class LibraryRecommendedTab extends BaseLibraryTab { State createState() => _LibraryRecommendedTabState(); } -class _LibraryRecommendedTabState extends BaseLibraryTabState with ItemUpdatable { +class _LibraryRecommendedTabState extends BaseLibraryTabState with ItemUpdatable { /// GlobalKeys for each hub section to enable vertical navigation final List> _hubKeys = []; @override - PlexClient get client => getClientForLibrary(); + String? get itemServerId => widget.library.serverId; @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { - // Update the item in any hub that contains it - for (final hub in items) { - final itemIndex = hub.items.indexWhere((item) => item.ratingKey == ratingKey); + void updateItemInLists(String itemId, MediaItem updatedItem) { + // Update the item in any hub that contains it. MediaHub items are + // immutable lists; rebuild the affected hub in-place. + for (var i = 0; i < items.length; i++) { + final hub = items[i]; + final itemIndex = hub.items.indexWhere((item) => item.id == itemId); if (itemIndex != -1) { - hub.items[itemIndex] = updatedMetadata; + final newItems = List.from(hub.items); + newItems[itemIndex] = updatedItem; + items[i] = hub.copyWith(items: newItems); } } } @@ -53,20 +57,22 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState t.libraries.tabs.recommended; - /// Detects Continue Watching hubs by hubIdentifier. + /// Detects Continue Watching hubs by hub identifier. /// Section-specific CW hubs use identifiers like "movie.inprogress.1". - static bool _isContinueWatchingHub(PlexHub hub) { - final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; + static bool _isContinueWatchingHub(MediaHub hub) { + final hubId = hub.identifier?.toLowerCase() ?? ''; return hubId.contains('inprogress'); } @override - Future> loadData() async { + Future> loadData() async { // Clear hub keys before loading new hubs to prevent stale references _hubKeys.clear(); - final client = getClientForLibrary(); - final hubs = await client.getLibraryHubs(widget.library.key, limit: 12); + // Backend-aware fetch: Plex hits /hubs/sections, Jellyfin synthesises + // Continue Watching + Next Up + Recently Added. + final client = context.tryGetMediaClientForServer(widget.library.serverId); + final hubs = client == null ? [] : List.of(await client.fetchLibraryHubs(widget.library.id, limit: 12)); // Move Continue Watching hub to the front if present final cwIndex = hubs.indexWhere(_isContinueWatchingHub); @@ -127,7 +133,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState items) { + Widget buildContent(List items) { _ensureHubKeys(items.length); return ListView.builder( @@ -161,7 +167,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState on State { } /// Start live playback for [channel] on its owning server. + /// + /// Both backends route through the live-TV navigator so the player + /// inherits the live-only branches (no Trakt scrobble, no progress + /// scrobble, channel up/down nav, no resume bookmark). Plex passes a + /// `client + dvrKey` and tunes inside the player; Jellyfin pre-resolves + /// the channel's `/Videos/{id}/stream` URL and lets the engine play it + /// directly. Future tuneChannel(LiveTvChannel channel) async { final multiServer = context.read(); - final serverInfo = - multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? - multiServer.liveTvServers.firstOrNull; - if (serverInfo == null) return; - - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) return; - - await navigateToLiveTv( - context, - client: client, - dvrKey: serverInfo.dvrKey, - channel: channel, - channels: liveTvChannels, - ); + await tuneAndNavigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: liveTvChannels); } /// Open the program-details bottom sheet. The poster is resolved from @@ -56,12 +49,12 @@ mixin LiveTvActionsMixin on State { final client = multiServer.getClientForServer(posterServerId); String? posterUrl; if (posterThumb != null && client != null) { - posterUrl = PlexImageHelper.getOptimizedImageUrl( + posterUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: posterThumb, maxWidth: 80, maxHeight: 120, - devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(context), imageType: ImageType.poster, ); } diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 08e2d31d..1dbe693b 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../../focus/focusable_action_bar.dart'; import '../../i18n/strings.g.dart'; +import '../../media/live_tv_support.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_dvr.dart'; import '../../mixins/refreshable.dart'; @@ -47,21 +48,41 @@ class _LiveTvScreenState extends State // Favorites bool _showFavoritesOnly = false; - Set _favoriteChannelIds = {}; + Set _favoriteKeys = {}; List _favoriteChannels = []; - /// Source URI per server, built from machineIdentifier + EPG provider identifier. - final Map _favoriteSourceByServer = {}; + /// Source URI per Live TV server/DVR, built from machineIdentifier + EPG provider identifier. + final Map _favoriteSourceByLiveServer = {}; + final Map _favoriteSourceByChannel = {}; + final Map _favoriteStoreByLiveServer = {}; + final Map _favoriteStoreByChannel = {}; + final Map _favoriteStoreBySource = {}; + final Map _favoriteModeByStore = {}; List get _filteredChannels { - if (!_showFavoritesOnly || _favoriteChannelIds.isEmpty) return _channels; - final channelMap = {for (final c in _channels) c.key: c}; + if (!_showFavoritesOnly) return _channels; + if (_favoriteKeys.isEmpty) return const []; + final channelMap = {for (final c in _channels) _favoriteKeyForChannel(c): c}; return [ for (final fav in _favoriteChannels) - if (channelMap.containsKey(fav.id)) channelMap[fav.id]!, + if (channelMap.containsKey(fav.stableKey)) channelMap[fav.stableKey]!, ]; } + String _liveServerScopeKey(LiveTvServerInfo serverInfo) => '${serverInfo.serverId}\u0000${serverInfo.dvrKey}'; + + String _sourceForChannel(LiveTvChannel channel) { + return channel.favoriteSource ?? _favoriteSourceByChannel[liveTvChannelScopeKey(channel)] ?? ''; + } + + String _favoriteKeyForChannel(LiveTvChannel channel) => favoriteChannelKey(_sourceForChannel(channel), channel.key); + + bool _isFavoriteChannel(LiveTvChannel channel) => _favoriteKeys.contains(_favoriteKeyForChannel(channel)); + + void _refreshFavoriteKeys() { + _favoriteKeys = _favoriteChannels.map((f) => f.stableKey).toSet(); + } + @override List get tabChipFocusNodes => [_guideTabFocusNode, _whatsOnTabFocusNode]; @@ -114,6 +135,11 @@ class _LiveTvScreenState extends State return hasMappings ? enabledKeys : null; } + Set? _extractEnabledChannelKeysForServerInfo(LiveTvServerInfo serverInfo) { + final matching = serverInfo.dvrs.where((dvr) => dvr.key == serverInfo.dvrKey).toList(); + return _extractEnabledChannelKeys(matching.isNotEmpty ? matching : serverInfo.dvrs); + } + Future _loadChannels() async { if (!mounted) return; setState(() { @@ -135,38 +161,60 @@ class _LiveTvScreenState extends State final allChannels = []; final seenChannels = {}; + _favoriteSourceByLiveServer.clear(); + _favoriteSourceByChannel.clear(); + _favoriteStoreByLiveServer.clear(); + _favoriteStoreByChannel.clear(); + _favoriteStoreBySource.clear(); + _favoriteModeByStore.clear(); appLogger.d( 'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}', ); - // Build a set of enabled channel keys per server from cached DVR data - final enabledKeysByServer = >{}; - final processedServers = {}; + // Build a set of enabled channel keys per Live TV DVR from cached DVR data. + final enabledKeysByLiveServer = >{}; for (final serverInfo in liveTvServers) { - if (!processedServers.add(serverInfo.serverId)) continue; - final enabledKeys = _extractEnabledChannelKeys(serverInfo.dvrs); + final enabledKeys = _extractEnabledChannelKeysForServerInfo(serverInfo); if (enabledKeys != null) { - enabledKeysByServer[serverInfo.serverId] = enabledKeys; + enabledKeysByLiveServer[_liveServerScopeKey(serverInfo)] = enabledKeys; } } for (final serverInfo in liveTvServers) { try { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; + final genericClient = multiServer.getClientForServer(serverInfo.serverId); + if (genericClient == null) continue; - final channels = await client.getEpgChannels(lineup: serverInfo.lineup); - final enabledKeys = enabledKeysByServer[serverInfo.serverId]; + final liveTv = genericClient.liveTv; + final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); + final storeKey = liveTv.favoriteStoreKey; + final liveServerKey = _liveServerScopeKey(serverInfo); + _favoriteSourceByLiveServer[liveServerKey] = source; + _favoriteStoreByLiveServer[liveServerKey] = storeKey; + _favoriteStoreBySource[source] = storeKey; + _favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; + + final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup); + // Plex's DVR exposes a separate enabled-channel mapping; Jellyfin + // already filters to subscribed channels server-side. + final enabledKeys = enabledKeysByLiveServer[liveServerKey]; appLogger.d( - 'Channels from DVR ${serverInfo.dvrKey}: ${channels.length} channels (${enabledKeys?.length ?? 'all'} enabled)', + 'Channels from ${serverInfo.dvrKey}: ${channels.length} channels (${enabledKeys?.length ?? 'all'} enabled)', ); for (final channel in channels) { - // Skip disabled channels if DVR has mapping data if (enabledKeys != null && !enabledKeys.contains(channel.key)) continue; - final dedupKey = '${serverInfo.serverId}:${channel.key}'; + final scopedChannel = channel.copyWith( + liveDvrKey: serverInfo.dvrKey, + favoriteSource: source, + favoriteStoreKey: storeKey, + ); + final dedupKey = liveTvChannelScopeKey(scopedChannel); if (seenChannels.add(dedupKey)) { - allChannels.add(channel); + final scopeKey = liveTvChannelScopeKey(scopedChannel); + _favoriteSourceByChannel[scopeKey] = source; + _favoriteStoreByChannel[scopeKey] = storeKey; + allChannels.add(scopedChannel); } } } catch (e) { @@ -189,7 +237,7 @@ class _LiveTvScreenState extends State _isLoading = false; }); - // Load favorites from the first available server (favorites are cloud-synced) + // Load favorites by backend store: Plex is cloud/account-scoped, Jellyfin per server. unawaited(_loadFavorites(multiServer)); if (allChannels.isNotEmpty && PlatformDetector.shouldUseSideNavigation(context)) { @@ -210,25 +258,37 @@ class _LiveTvScreenState extends State Future _loadFavorites(MultiServerProvider multiServer) async { try { - // Use the first available server's client to fetch favorites + _favoriteSourceByLiveServer.clear(); + _favoriteStoreBySource.clear(); + _favoriteModeByStore.clear(); + final merged = []; + final fetchedStores = {}; + final seenFavorites = {}; for (final serverInfo in multiServer.liveTvServers) { final client = multiServer.getClientForServer(serverInfo.serverId); if (client == null) continue; - - // Build and cache the source URI for this server - final source = await client.buildFavoriteChannelSource(); - _favoriteSourceByServer[serverInfo.serverId] = source; - - final favorites = await client.getFavoriteChannels(); - if (!mounted) return; - - setState(() { - _favoriteChannels = favorites; - _favoriteChannelIds = favorites.map((f) => f.id).toSet(); - }); - appLogger.d('Live TV: loaded ${favorites.length} favorite channels'); - break; // Favorites are cloud-synced, only need to fetch once + final liveTv = client.liveTv; + final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); + final storeKey = liveTv.favoriteStoreKey; + final liveServerKey = _liveServerScopeKey(serverInfo); + _favoriteSourceByLiveServer[liveServerKey] = source; + _favoriteStoreByLiveServer[liveServerKey] = storeKey; + _favoriteStoreBySource[source] = storeKey; + _favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; + if (!fetchedStores.add(storeKey)) continue; + final serverFavorites = await liveTv.fetchFavoriteChannels(); + for (final favorite in serverFavorites) { + _favoriteStoreBySource[favorite.source] = storeKey; + if (seenFavorites.add(favorite.stableKey)) merged.add(favorite); + } } + + if (!mounted) return; + setState(() { + _favoriteChannels = merged; + _refreshFavoriteKeys(); + }); + appLogger.d('Live TV: loaded ${merged.length} favorite channels'); } catch (e) { appLogger.e('Failed to load favorite channels', error: e); } @@ -241,23 +301,26 @@ class _LiveTvScreenState extends State } void _toggleFavorite(LiveTvChannel channel) { - final source = _favoriteSourceByServer[channel.serverId] ?? ''; + final source = _sourceForChannel(channel); + final favoriteKey = favoriteChannelKey(source, channel.key); + final scopeKey = liveTvChannelScopeKey(channel); + final storeKey = channel.favoriteStoreKey ?? _favoriteStoreByChannel[scopeKey]; + if (storeKey != null) _favoriteStoreBySource[source] = storeKey; setState(() { - if (_favoriteChannelIds.contains(channel.key)) { - _favoriteChannelIds = Set.from(_favoriteChannelIds)..remove(channel.key); - _favoriteChannels = _favoriteChannels.where((f) => f.id != channel.key).toList(); + if (_favoriteKeys.contains(favoriteKey)) { + _favoriteChannels = _favoriteChannels.where((f) => f.id != channel.key || f.source != source).toList(); } else { - _favoriteChannelIds = Set.from(_favoriteChannelIds)..add(channel.key); _favoriteChannels = [..._favoriteChannels, FavoriteChannel.fromLiveTvChannel(channel, source)]; } + _refreshFavoriteKeys(); }); _persistFavorites(); } void _showReorderFavorites() { - final channelMap = {for (final c in _channels) c.key: c}; + final channelMap = {for (final c in _channels) _favoriteKeyForChannel(c): c}; OverlaySheetController.showAdaptive( context, @@ -267,14 +330,14 @@ class _LiveTvScreenState extends State onReorder: (reordered) { setState(() { _favoriteChannels = reordered; - _favoriteChannelIds = reordered.map((f) => f.id).toSet(); + _refreshFavoriteKeys(); }); _persistFavorites(); }, onRemove: (removed) { setState(() { - _favoriteChannels = _favoriteChannels.where((f) => f.id != removed.id).toList(); - _favoriteChannelIds = Set.from(_favoriteChannelIds)..remove(removed.id); + _favoriteChannels = _favoriteChannels.where((f) => f.stableKey != removed.stableKey).toList(); + _refreshFavoriteKeys(); }); _persistFavorites(); }, @@ -284,12 +347,28 @@ class _LiveTvScreenState extends State void _persistFavorites() { final multiServer = context.read(); + final byStore = >{}; + for (final f in _favoriteChannels) { + final storeKey = _favoriteStoreBySource[f.source]; + if (storeKey == null) continue; + byStore.putIfAbsent(storeKey, () => []).add(f); + } + final writtenStores = {}; for (final serverInfo in multiServer.liveTvServers) { final client = multiServer.getClientForServer(serverInfo.serverId); - if (client != null) { - client.setFavoriteChannels(_favoriteChannels); - break; - } + if (client == null) continue; + final liveServerKey = _liveServerScopeKey(serverInfo); + final storeKey = _favoriteStoreByLiveServer[liveServerKey]; + if (storeKey == null || !writtenStores.add(storeKey)) continue; + final mode = _favoriteModeByStore[storeKey] ?? client.liveTv.favoritePersistenceMode; + final source = _favoriteSourceByLiveServer[liveServerKey]; + if (source == null) continue; // not yet resolved — skip; next toggle will catch up + final channels = switch (mode) { + FavoriteChannelPersistenceMode.sharedFullList => byStore[storeKey] ?? const [], + FavoriteChannelPersistenceMode.serverSlice => + (byStore[storeKey] ?? const []).where((f) => f.source == source).toList(), + }; + unawaited(client.liveTv.setFavoriteChannels(channels)); } } @@ -419,7 +498,7 @@ class _LiveTvScreenState extends State GuideTab( key: _guideTabKey, channels: guideChannels, - favoriteChannelIds: _favoriteChannelIds, + isFavoriteChannel: _isFavoriteChannel, onToggleFavorite: _toggleFavorite, onNavigateUp: focusTabBar, onBack: onTabBarBack, diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index 25c26024..5a106ec8 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -47,8 +47,8 @@ class _LiveTvShowScheduleScreenState extends State Future _loadSchedule() async { final multiServer = context.read(); - final client = multiServer.getClientForServer(widget.serverId); - if (client == null) { + final genericClient = multiServer.getClientForServer(widget.serverId); + if (genericClient == null) { if (mounted) setState(() => _isLoading = false); return; } @@ -58,7 +58,9 @@ class _LiveTvShowScheduleScreenState extends State final beginsAt = now.subtract(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000; final endsAt = now.add(const Duration(hours: 48)).millisecondsSinceEpoch ~/ 1000; - final programs = await client.getEpgGrid(beginsAt: beginsAt, endsAt: endsAt); + final fromDt = DateTime.fromMillisecondsSinceEpoch(beginsAt * 1000, isUtc: true); + final toDt = DateTime.fromMillisecondsSinceEpoch(endsAt * 1000, isUtc: true); + final programs = await genericClient.liveTv.fetchSchedule(from: fromDt, to: toDt); // Filter for this show final filtered = programs.where((p) { diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index eee0f4ce..6263235d 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -10,7 +10,7 @@ import '../../services/image_cache_service.dart'; import '../../utils/formatters.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/overlay_sheet.dart'; -import '../../widgets/plex_optimized_image.dart' show blurArtwork; +import '../../widgets/optimized_media_image.dart' show blurArtwork; /// Shows a bottom sheet with program details and actions (Record, Watch Channel, Play). void showProgramDetailsSheet( diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart index 82214f8d..b20b2bc0 100644 --- a/lib/screens/livetv/reorder_favorites_sheet.dart +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -13,7 +13,7 @@ import '../../providers/multi_server_provider.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/bottom_sheet_header.dart'; import '../../widgets/overlay_sheet.dart'; -import '../../widgets/plex_optimized_image.dart'; +import '../../widgets/optimized_media_image.dart'; class ReorderFavoritesSheet extends StatefulWidget { final List favorites; @@ -239,12 +239,12 @@ class _ReorderFavoritesSheetState extends State { buildDefaultDragHandles: false, itemBuilder: (context, index) { final fav = _tempFavorites[index]; - final channel = widget.channelMap[fav.id]; + final channel = widget.channelMap[fav.stableKey]; final isFocused = isKeyboardMode && index == _focusedIndex; final isMoving = index == _movingIndex; return _buildFavoriteTile( - key: ValueKey(fav.id), + key: ValueKey(fav.stableKey), fav: fav, channel: channel, index: index, @@ -304,7 +304,7 @@ class _ReorderFavoritesSheetState extends State { width: 40, height: 40, child: channel?.thumb != null && client != null - ? PlexOptimizedImage.thumb( + ? OptimizedMediaImage.thumb( client: client, imagePath: channel!.thumb, width: 40, diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index c71f9baa..36794f5b 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -13,19 +13,19 @@ import '../../../i18n/strings.g.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_program.dart'; import '../../../providers/multi_server_provider.dart'; -import '../../../services/plex_client.dart'; +import '../../../media/media_server_client.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/formatters.dart'; -import '../../../utils/plex_image_helper.dart'; +import '../../../utils/media_image_helper.dart'; import '../../../utils/live_tv_player_navigation.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/overlay_sheet.dart'; -import '../../../widgets/plex_optimized_image.dart'; +import '../../../widgets/optimized_media_image.dart'; import '../program_details_sheet.dart'; class GuideTab extends StatefulWidget { final List channels; - final Set favoriteChannelIds; + final bool Function(LiveTvChannel channel)? isFavoriteChannel; final void Function(LiveTvChannel)? onToggleFavorite; final VoidCallback? onNavigateUp; final VoidCallback? onBack; @@ -33,7 +33,7 @@ class GuideTab extends StatefulWidget { const GuideTab({ super.key, required this.channels, - this.favoriteChannelIds = const {}, + this.isFavoriteChannel, this.onToggleFavorite, this.onNavigateUp, this.onBack, @@ -200,13 +200,15 @@ class GuideTabState extends State { for (final serverInfo in liveTvServers) { if (!queriedServers.add(serverInfo.serverId)) continue; try { - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) continue; + final genericClient = multiServer.getClientForServer(serverInfo.serverId); + if (genericClient == null) continue; final startEpoch = _gridStart.millisecondsSinceEpoch ~/ 1000; final endEpoch = _gridEnd.millisecondsSinceEpoch ~/ 1000; - final programs = await client.getEpgGrid(beginsAt: startEpoch, endsAt: endEpoch); + final fromDt = DateTime.fromMillisecondsSinceEpoch(startEpoch * 1000, isUtc: true); + final toDt = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true); + final programs = await genericClient.liveTv.fetchSchedule(from: fromDt, to: toDt); allPrograms.addAll(programs); } catch (e) { appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e); @@ -262,23 +264,7 @@ class GuideTabState extends State { Future _tuneChannel(LiveTvChannel channel) async { final multiServer = context.read(); - - final serverInfo = - multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? - multiServer.liveTvServers.firstOrNull; - - if (serverInfo == null) return; - - final client = multiServer.getClientForServer(serverInfo.serverId); - if (client == null) return; - - await navigateToLiveTv( - context, - client: client, - dvrKey: serverInfo.dvrKey, - channel: channel, - channels: widget.channels, - ); + await tuneAndNavigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels); } // --------------------------------------------------------------------------- @@ -906,7 +892,7 @@ class GuideTabState extends State { onTap: () => _tuneChannel(channel), onLongPress: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(channel) : null, isFocused: isFocused, - isFavorite: widget.favoriteChannelIds.contains(channel.key), + isFavorite: widget.isFavoriteChannel?.call(channel) ?? false, fallbackBuilder: () => _buildChannelNameFallback(channel, theme), ); } @@ -1115,12 +1101,12 @@ class GuideTabState extends State { final client = multiServer.getClientForServer(channel.serverId ?? ''); String? posterUrl; if (program.thumb != null && client != null) { - posterUrl = PlexImageHelper.getOptimizedImageUrl( + posterUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: program.thumb, maxWidth: 80, maxHeight: 120, - devicePixelRatio: PlexImageHelper.effectiveDevicePixelRatio(context), + devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(context), imageType: ImageType.poster, ); } @@ -1139,7 +1125,7 @@ class _ChannelCell extends StatefulWidget { final double rowHeight; final double channelColumnWidth; final String? channelThumb; - final PlexClient? client; + final MediaServerClient? client; final LiveTvChannel channel; final ThemeData theme; final VoidCallback onTap; @@ -1201,7 +1187,7 @@ class _ChannelCellState extends State<_ChannelCell> { opacity: showAction ? 0.3 : 1.0, duration: const Duration(milliseconds: 150), child: widget.channelThumb != null && widget.client != null - ? PlexOptimizedImage.thumb( + ? OptimizedMediaImage.thumb( client: widget.client!, imagePath: widget.channelThumb, width: widget.channelColumnWidth - 16, diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 678bfe39..4b6d3f3e 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -9,9 +9,9 @@ import '../../../focus/dpad_navigator.dart'; import '../../../focus/key_event_utils.dart'; import '../../../focus/locked_hub_controller.dart'; import '../../../i18n/strings.g.dart'; +import '../../../media/media_item_types.dart'; import '../../../models/livetv_channel.dart'; import '../../../models/livetv_hub_result.dart'; -import '../../../models/plex_metadata.dart'; import '../../../providers/multi_server_provider.dart'; import '../../../providers/settings_provider.dart'; import '../../../utils/grid_size_calculator.dart'; @@ -23,7 +23,7 @@ import '../../../widgets/focus_builders.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../utils/scroll_utils.dart'; import '../../../widgets/horizontal_scroll_with_arrows.dart'; -import '../../../widgets/plex_optimized_image.dart'; +import '../../../widgets/optimized_media_image.dart'; import '../live_tv_actions_mixin.dart'; import '../live_tv_show_schedule_screen.dart'; @@ -84,7 +84,8 @@ class WhatsOnTabState extends State with LiveTvActionsMixin with LiveTvActionsMixin with LiveTvActionsMixin with LiveTvActionsMixin showProgramDetails( program: entry.program, channel: findChannel(entry.program.channelIdentifier), - posterThumb: entry.metadata.grandparentThumb ?? entry.metadata.thumb, + posterThumb: entry.metadata.grandparentThumbPath ?? entry.metadata.thumbPath, posterServerId: entry.metadata.serverId ?? '', ), onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), @@ -533,7 +534,7 @@ class _LiveTvPosterCard extends StatelessWidget { Widget build(BuildContext context) { final metadata = entry.metadata; // Always use poster image: show poster for episodes, thumb for others - final posterImage = metadata.grandparentThumb ?? metadata.thumb; + final posterImage = metadata.grandparentThumbPath ?? metadata.thumbPath; return FocusBuilders.buildLockedFocusWrapper( context: context, @@ -553,8 +554,8 @@ class _LiveTvPosterCard extends StatelessWidget { height: posterHeight, child: ClipRRect( borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: PlexOptimizedImage.poster( - client: context.getClientWithFallback(metadata.serverId), + child: OptimizedMediaImage.poster( + client: context.tryGetMediaClientWithFallback(metadata.serverId), imagePath: posterImage, width: double.infinity, height: double.infinity, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 780e55a4..10b2ea99 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -8,10 +8,10 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; -import '../../services/plex_client.dart'; import '../i18n/strings.g.dart'; import '../services/update_service.dart'; import '../utils/app_logger.dart'; +import '../widgets/auth_error_banner.dart'; import '../widgets/dialog_action_button.dart'; import '../utils/dialogs.dart'; import '../utils/provider_extensions.dart'; @@ -23,17 +23,23 @@ import '../mixins/refreshable.dart'; import '../widgets/overlay_sheet.dart'; import '../mixins/tab_visibility_aware.dart'; import '../navigation/navigation_tabs.dart'; +import '../connection/connection_registry.dart'; +import '../profiles/active_plex_identity.dart'; +import '../profiles/active_profile_binder.dart'; +import '../profiles/active_profile_provider.dart'; +import '../profiles/plex_home_service.dart'; +import '../profiles/profile_connection_registry.dart'; +import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; import '../providers/settings_provider.dart'; -import '../providers/user_profile_provider.dart'; +import '../services/api_cache.dart'; +import '../services/multi_server_manager.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/settings_service.dart'; import '../providers/offline_mode_provider.dart'; -import '../services/plex_auth_service.dart'; -import '../services/storage_service.dart'; import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/fullscreen_state_manager.dart'; import '../providers/companion_remote_provider.dart'; @@ -78,10 +84,14 @@ class MainScreenFocusScope extends InheritedWidget { } class MainScreen extends StatefulWidget { - final PlexClient? client; final bool isOfflineMode; - const MainScreen({super.key, this.client, this.isOfflineMode = false}); + /// When `true`, the previous screen (typically [SetupScreen]) already + /// resolved the launch profile prompt — skip the postFrame prompt that + /// would otherwise re-fire it. + final bool initialPromptHandled; + + const MainScreen({super.key, this.isOfflineMode = false, this.initialPromptHandled = false}); @override State createState() => _MainScreenState(); @@ -132,6 +142,45 @@ class _MainScreenState extends State with RouteAware, WindowListener final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); bool _isSidebarFocused = false; + /// The binder is now owned by a top-level [Provider] (see main.dart) so + /// the splash can await its first settle before navigating here. We just + /// observe its [ActiveProfileProvider.isBinding] state for the once-only + /// priming below. + PlexHomeService? _plexHomeService; + ActiveProfileProvider? _activeProfileForListener; + String? _lastSeenProfileId; + // Tracks ActiveProfileProvider.isBinding from the previous notification + // so we can detect a binding-just-settled transition for the *same* + // active profile id (e.g. after a borrow/remove rebind). Without this + // we only invalidate on id change and the libraries sidebar keeps + // stale entries until the user switches profiles. + bool _wasBindingPrev = false; + + /// Subscription to MultiServerManager status changes. Used to resume any + /// queued downloads as soon as a Plex client comes online for the first + /// time after launch (legacy main.dart used to do this from SetupScreen + /// before navigating). + StreamSubscription>? _serverStatusSub; + bool _downloadResumeFired = false; + + /// Listener that fires when [ActiveProfileBinder] settles (Plex *and* + /// Jellyfin both bound). Drives the once-per-launch priming of + /// LibrariesProvider + watch sync + tab fullRefresh — wiring this off + /// the first online-server emission instead would prime before + /// Jellyfin gets added, leaving its libraries out of the navbar. + VoidCallback? _bindingSettleListener; + bool _startupServicesPrimed = false; + Timer? _startupSettleTimeout; + + /// Hard ceiling on how long we wait for [ActiveProfileBinder] to settle + /// before priming the UI anyway. The binder always calls + /// `markBindingFinished` in its `finally`, but this is a defence in depth: + /// if a transient bug or hung HTTP path keeps `isBinding` true, the user + /// would otherwise see an empty Discover screen forever. After the + /// fallback fires the screens render their normal "no servers" state and + /// the user can pull-to-refresh / open settings. + static const _startupSettleFallback = Duration(seconds: 15); + @override void initState() { super.initState(); @@ -163,23 +212,34 @@ class _MainScreenState extends State with RouteAware, WindowListener _setupWatchNextDeepLink(); } - // Set up data invalidation callback for profile switching (skip in offline mode) + // Wire profile binder + tracker bootstrap (skip in offline mode) WidgetsBinding.instance.addPostFrameCallback((_) async { + if (mounted) { + final activeProfile = context.read(); + _activeProfileForListener = activeProfile; + _lastSeenProfileId = activeProfile.activeId; + activeProfile.addListener(_onActiveProfileChanged); + _plexHomeService = context.read(); + unawaited(_plexHomeService!.start()); + final manager = context.read().serverManager; + // Read the binder so the Provider's `lazy: false` create has fired + // for sure; it manages its own lifecycle and disposal. + context.read(); + _runStartupOnFirstOnlineServer(manager); + } if (!_isOffline) { - // Initialize UserProfileProvider to ensure it's ready after sign-in + // Settings-only initialization — profile identity is managed by + // ActiveProfileProvider + ActiveProfileBinder. final userProfileProvider = context.userProfile; await userProfileProvider.initialize(); - // Set up data invalidation callback for profile switching - userProfileProvider.setDataInvalidationCallback(_invalidateAllScreens); + // Ensure first login (or any unset profile state) requires explicit selection. + await _promptForInitialProfileSelection(); - // Auto-start companion remote server now that home data is available + // Auto-start companion remote server once the active profile is known. if (_companionRemoteSetup && mounted) { unawaited(_autoStartCompanionRemoteServer(context.read())); } - - // Ensure first login (or any unset profile state) requires explicit selection. - await _promptForInitialProfileSelection(userProfileProvider); } // Focus content initially (replaces autofocus which caused focus stealing issues) @@ -193,16 +253,188 @@ class _MainScreenState extends State with RouteAware, WindowListener }); } - Future _promptForInitialProfileSelection(UserProfileProvider userProfileProvider) async { + /// Run startup tasks that depend on having at least one online server: + /// initialize and load the libraries provider, kick off the initial + /// watch-state sync, and (for Plex) resume any queued downloads. The + /// legacy [SetupScreen] path used to do all this before navigating to + /// MainScreen; with the binder taking over for the connect, we hook + /// into [ActiveProfileProvider.isBinding] (for the once-only priming, + /// which must wait for *all* connections — Plex *and* Jellyfin — to + /// land so the navbar shows libraries from both backends) and + /// [MultiServerManager.statusStream] (for download resume, which only + /// cares about the first online Plex client). Fires at most once per + /// MainScreen lifetime. + void _runStartupOnFirstOnlineServer(MultiServerManager manager) { + if (_isOffline || _downloadResumeFired) return; + + final activeProfile = context.read(); + + void primeServicesOnBindingSettle({bool fromTimeout = false}) { + if (_startupServicesPrimed || !mounted) return; + // Wait for the binder to finish — `_rebind` only flips `isBinding` + // false after both `_bindPlexHome` AND `_bindJoinRows` (where + // Jellyfin gets added) complete. Priming on the first Plex status + // emit instead would load libraries before Jellyfin is registered. + // + // The `fromTimeout` escape hatch lets the [_startupSettleTimeout] + // bypass this gate if the binder has somehow not flipped the flag + // within [_startupSettleFallback]. Logs a warning so the silent + // path is still surfaced in diagnostics. + if (activeProfile.isBinding && !fromTimeout) return; + if (fromTimeout) { + appLogger.w( + 'ActiveProfileBinder still binding after ${_startupSettleFallback.inSeconds}s ' + '— priming UI anyway so the user is not stuck on an empty screen.', + ); + } + // Set the guard before the await so re-entrant listener fires can't + // race a second prime. + _startupServicesPrimed = true; + _startupSettleTimeout?.cancel(); + _startupSettleTimeout = null; + + // Mirror `_invalidateAllScreens`: await the libraries fetch BEFORE + // calling `fullRefresh` on the tab screens. Without the await the + // libraries screen's `_initializeWithLibraries` runs against an + // empty provider, returns early, and never sets a selected library + // — so the tab renders nothing even though libraries arrive moments + // later. The Plex auth path goes through `_invalidateAllScreens` + // (active-profile id changes) and was unaffected; fresh-install + // Jellyfin sign-in is bound to the pre-existing placeholder Owner + // profile, so only this prime path runs. + unawaited(() async { + if (manager.onlineServerIds.isNotEmpty) { + if (!mounted) return; + final mp = context.read(); + final lp = context.read(); + lp.initialize(mp.aggregationService); + await lp.loadLibraries(); + if (!mounted) return; + context.read().onServersConnected(); + // DownloadProvider's initial load can race with [Connections] + // table inserts done by [ActiveProfileBinder]. Now that servers + // are connected the per-backend caches resolve, so retry. + unawaited(context.read().refreshMetadataFromCache()); + } + + // The tab screens called their initial load in `initState` — well + // before the binder finished its first connect — and stayed in + // their loading state. Re-trigger so they reload (or, if no + // servers came online, render their proper error state). + if (!mounted) return; + if (_discoverKey.currentState case final FullRefreshable refreshable) { + refreshable.fullRefresh(); + } + if (_librariesKey.currentState case final FullRefreshable refreshable) { + refreshable.fullRefresh(); + } + if (_searchKey.currentState case final FullRefreshable refreshable) { + refreshable.fullRefresh(); + } + }()); + } + + void tryDownloadResume() { + if (_downloadResumeFired || !mounted) return; + // Wait for any online client before firing the resume — the download + // pipeline is backend-neutral (resumeQueuedDownloads accepts a + // MediaServerClient and per-item resolution picks up the right + // backend), so a Jellyfin-only setup can resume too. + final onlineClient = manager.onlineClients.values.firstOrNull; + if (onlineClient == null) return; + _downloadResumeFired = true; + _serverStatusSub?.cancel(); + _serverStatusSub = null; + final downloadProvider = context.read(); + unawaited( + downloadProvider.ensureInitialized().then((_) { + if (!mounted) return; + downloadProvider.resumeQueuedDownloads(onlineClient); + }), + ); + } + + // Listen for binding-settle so the once-only priming runs after both + // Plex and Jellyfin are wired up. + _bindingSettleListener = () => primeServicesOnBindingSettle(); + activeProfile.addListener(_bindingSettleListener!); + + // Defence in depth: bypass the binder gate after a hard ceiling so a + // hung bind path can't strand the user on an empty screen. + _startupSettleTimeout?.cancel(); + _startupSettleTimeout = Timer(_startupSettleFallback, () { + primeServicesOnBindingSettle(fromTimeout: true); + }); + + // Fast paths: binder may have already settled / first Plex server may + // already be online (binder finished before this microtask). + primeServicesOnBindingSettle(); + tryDownloadResume(); + if (_downloadResumeFired) return; + + _serverStatusSub = manager.statusStream.listen((_) => tryDownloadResume()); + } + + void _onActiveProfileChanged() { + final activeProfile = _activeProfileForListener; + if (activeProfile == null) return; + final id = activeProfile.activeId; + final isBindingNow = activeProfile.isBinding; + + if (id != _lastSeenProfileId) { + _lastSeenProfileId = id; + _wasBindingPrev = isBindingNow; + // We're called inside the synchronous notify cascade *before* the + // binder's listener has fired (registration order). At this exact + // instant `_isBinding` is still false, so calling awaitBindingSettle + // here would resolve immediately. Hop to a microtask so the binder's + // listener gets to flip the flag first, then wait properly. + unawaited( + Future.microtask(() async { + if (!mounted) return; + await activeProfile.awaitBindingSettle(); + if (!mounted) return; + await _invalidateAllScreens(); + }), + ); + return; + } + + // Same active id, but a rebind cycle for that profile just settled + // (true → false transition). Fires after borrow / connection-removal + // flows trigger ActiveProfileBinder.rebindIfActive, so the libraries + // sidebar reflects the new server set without an app restart. + if (_wasBindingPrev && !isBindingNow) { + _wasBindingPrev = isBindingNow; + unawaited(_invalidateAllScreens()); + return; + } + _wasBindingPrev = isBindingNow; + } + + Future _promptForInitialProfileSelection() async { if (!mounted || _isShowingProfileSelection) return; + if (widget.initialPromptHandled) return; + + final activeProfile = context.read(); + // The provider's initialize() is fire-and-forget from MultiProvider — + // wait for it to settle so `active` and `profiles` reflect storage + // before we decide whether to prompt. + await activeProfile.initialize(); + if (!mounted) return; - final needsInitial = userProfileProvider.needsInitialProfileSelection; final settingsService = await SettingsService.getInstance(); if (!mounted) return; - final requireOnOpen = - settingsService.read(SettingsService.requireProfileSelectionOnOpen) && userProfileProvider.hasMultipleUsers; - if (!needsInitial && !requireOnOpen) return; + // Always prompt when there's no active profile but profiles exist + // (fresh sign-in with multiple Plex Home users): otherwise the binder + // has no profile to bind, and the user lands on an empty screen with + // no way back to the picker. + final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; + final requireOnOpen = + settingsService.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; + + if (!hasNoActive && !requireOnOpen) return; _isShowingProfileSelection = true; await Navigator.of( @@ -353,7 +585,7 @@ class _MainScreenState extends State with RouteAware, WindowListener return; } - final metadata = await client.getMetadataWithImages(ratingKey); + final metadata = await client.fetchItem(ratingKey); if (metadata == null || !mounted) return; @@ -453,8 +685,27 @@ class _MainScreenState extends State with RouteAware, WindowListener if (!settings.read(SettingsService.enableCompanionRemoteServer)) return; if (!mounted) return; - final home = context.read().home; - if (await companionRemote.ensureCryptoReady(home)) { + final connections = context.read(); + final activeProfile = context.read(); + final profileConnections = context.read(); + final plexHome = context.read(); + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); + if (!mounted) return; + final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); + if (!mounted) return; + final ok = await companionRemote.ensureCryptoReady( + home, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + identity: identity, + plexHomeForConnection: plexHome.materializePlexHomeForConnection, + ); + if (ok) { await companionRemote.startHostServer(); } } catch (e) { @@ -472,6 +723,13 @@ class _MainScreenState extends State with RouteAware, WindowListener } _offlineModeProvider?.removeListener(_handleOfflineStatusChanged); _multiServerProvider?.removeListener(_handleLiveTvChanged); + if (_bindingSettleListener != null) { + _activeProfileForListener?.removeListener(_bindingSettleListener!); + } + _activeProfileForListener?.removeListener(_onActiveProfileChanged); + _serverStatusSub?.cancel(); + _startupSettleTimeout?.cancel(); + _startupSettleTimeout = null; _sidebarFocusScope.dispose(); _contentFocusScope.dispose(); @@ -514,8 +772,8 @@ class _MainScreenState extends State with RouteAware, WindowListener if (!settingsService.read(SettingsService.requireProfileSelectionOnOpen)) return; if (!mounted) return; - final userProfileProvider = context.read(); - if (!userProfileProvider.hasMultipleUsers) return; + final activeProfile = context.read(); + if (!activeProfile.hasMultipleProfiles) return; _isShowingProfileSelection = true; await Navigator.of( @@ -527,9 +785,18 @@ class _MainScreenState extends State with RouteAware, WindowListener /// IndexedStack that disables tickers for offscreen children to prevent /// animation controllers on non-visible tabs from scheduling frames. Widget _buildTickerAwareStack() { - return IndexedStack( - index: _currentIndex, - children: [for (var i = 0; i < _screens.length; i++) TickerMode(enabled: i == _currentIndex, child: _screens[i])], + return Column( + children: [ + const AuthErrorBanner(), + Expanded( + child: IndexedStack( + index: _currentIndex, + children: [ + for (var i = 0; i < _screens.length; i++) TickerMode(enabled: i == _currentIndex, child: _screens[i]), + ], + ), + ), + ], ); } @@ -628,12 +895,9 @@ class _MainScreenState extends State with RouteAware, WindowListener _sideNavKey.currentState?.focusActiveItem(); }); - // Ensure profile provider is initialized when coming back online + // Ensure profile settings are warmed when coming back online if (!_isOffline) { - final userProfileProvider = context.userProfile; - userProfileProvider.initialize().then((_) { - userProfileProvider.setDataInvalidationCallback(_invalidateAllScreens); - }); + unawaited(context.userProfile.initialize()); } } @@ -789,61 +1053,55 @@ class _MainScreenState extends State with RouteAware, WindowListener _sideNavKey.currentState?.reloadLibraries(); } - /// Invalidate all cached data across all screens when profile is switched - /// Receives the list of servers with new profile tokens for reconnection - Future _invalidateAllScreens(List servers) async { - appLogger.d('Invalidating all screen data due to profile switch with ${servers.length} servers'); + /// Invalidate cached data across screens after a profile switch. + /// The [ActiveProfileBinder] has already pushed fresh per-server tokens + /// into [MultiServerManager], so this just clears UI caches and refreshes + /// the visible screens. + Future _invalidateAllScreens() async { + appLogger.d('Invalidating screen data after profile switch'); - // Get all providers final multiServerProvider = context.read(); final hiddenLibrariesProvider = context.read(); final librariesProvider = context.read(); final playbackStateProvider = context.read(); - // Clear libraries provider state before reconnecting + // Drop volatile API cache rows before screens kick off their refetch. + // Pinned rows back offline downloads and must survive profile switches. + try { + await ApiCache.instance.clearVolatile(); + } catch (e, st) { + appLogger.w('Failed to clear ApiCache on profile switch', error: e, stackTrace: st); + } + librariesProvider.clear(); - - // Reconnect to all servers with new profile tokens - if (servers.isNotEmpty) { - final storage = await StorageService.getInstance(); - final clientId = storage.getClientIdentifier(); - - final connectedCount = await multiServerProvider.reconnectWithServers(servers, clientIdentifier: clientId); - appLogger.d('Reconnected to $connectedCount/${servers.length} servers after profile switch'); - - // Trigger watch state sync now that servers are connected - if (connectedCount > 0) { - if (!mounted) return; - context.read().onServersConnected(); - // Reload libraries after reconnection - librariesProvider.initialize(multiServerProvider.aggregationService); - await librariesProvider.refresh(); - } + if (multiServerProvider.serverManager.serverIds.isNotEmpty) { + if (!mounted) return; + context.read().onServersConnected(); + // Profile switches re-bind connections — give DownloadProvider a chance + // to repopulate metadata that the per-backend caches now resolve. + unawaited(context.read().refreshMetadataFromCache()); + librariesProvider.initialize(multiServerProvider.aggregationService); + await librariesProvider.refresh(); } - // Reset other provider states unawaited(hiddenLibrariesProvider.refresh()); playbackStateProvider.clearShuffle(); - appLogger.d('Cleared all provider states for profile switch'); - - // Full refresh discover screen (reload all content for new profile) if (_discoverKey.currentState case final FullRefreshable refreshable) { refreshable.fullRefresh(); } - - // Full refresh libraries screen (clear filters and reload for new profile) if (_librariesKey.currentState case final FullRefreshable refreshable) { refreshable.fullRefresh(); } - - // Full refresh search screen (clear search for new profile) if (_searchKey.currentState case final FullRefreshable refreshable) { refreshable.fullRefresh(); } - // Sidebar automatically updates since it watches LibrariesProvider + // Refresh user-level settings (audio/sub defaults) for the new identity. + if (mounted) { + unawaited(context.userProfile.refreshProfileSettings()); + } } void _selectTab(NavigationTabId tab) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index cb921376..21186d81 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -19,24 +19,28 @@ import '../focus/focusable_wrapper.dart'; import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; import '../widgets/focus_builders.dart'; +import '../exceptions/media_server_exceptions.dart'; +import '../media/media_backend.dart'; +import '../media/media_hub.dart'; +import '../utils/provider_extensions.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_kind.dart'; +import '../media/media_role.dart'; import '../widgets/media_card.dart'; import '../i18n/strings.g.dart'; -import '../widgets/plex_optimized_image.dart'; -import '../utils/plex_image_helper.dart'; -import '../../services/plex_client.dart'; -import '../services/plex_api_cache.dart'; +import '../widgets/optimized_media_image.dart'; +import '../utils/media_image_helper.dart'; +import '../services/plex_client.dart'; +import '../media/media_server_client.dart'; +import '../services/media_list_playback_launcher.dart'; import '../services/offline_watch_sync_service.dart'; -import '../utils/plex_cache_parser.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_role.dart'; -import '../models/plex_video_playback_data.dart'; import '../utils/content_utils.dart'; import '../utils/rating_utils.dart'; import '../models/download_models.dart'; import '../services/download_storage_service.dart'; import '../utils/download_version_utils.dart'; import '../utils/download_utils.dart'; -import '../providers/playback_state_provider.dart'; import '../providers/settings_provider.dart'; import '../utils/grid_size_calculator.dart'; import '../providers/download_provider.dart'; @@ -64,12 +68,12 @@ import '../widgets/episode_card.dart'; import 'actor_media_screen.dart'; import '../widgets/focusable_tab_chip.dart'; import '../widgets/hub_section.dart'; -import '../models/plex_hub.dart'; +import '../widgets/loading_indicator_box.dart'; enum _SyncRuleAction { edit, remove, delete } class MediaDetailScreen extends StatefulWidget { - final PlexMetadata metadata; + final MediaItem metadata; final bool isOffline; /// If provided, auto-selects this season index when the screen loads. @@ -84,18 +88,20 @@ class MediaDetailScreen extends StatefulWidget { class _MediaDetailScreenState extends State with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin { - List _seasons = []; + /// Public input alias — used as the live source of truth until the detail + /// fetch returns. Holds backend-neutral [MediaItem] data. + MediaItem get _metadata => _fullMetadata ?? widget.metadata; + List _seasons = []; bool _isLoadingSeasons = false; Completer? _seasonsCompleter; - List _episodes = []; + List _episodes = []; bool _isLoadingEpisodes = false; bool _showEpisodesDirectly = false; - PlexMetadata? _fullMetadata; - PlexMetadata? _onDeckEpisode; - PlexVideoPlaybackData? _playbackData; + MediaItem? _fullMetadata; + MediaItem? _onDeckEpisode; bool _isLoadingMetadata = true; - List? _extras; - List _relatedHubs = []; + List? _extras; + List _relatedHubs = []; List> _relatedHubKeys = []; late final ScrollController _scrollController; final ScrollController _extrasScrollController = ScrollController(); @@ -104,7 +110,7 @@ class _MediaDetailScreenState extends State // Inline season tabs int _selectedSeasonIndex = 0; - final Map> _episodeCache = {}; + final Map> _episodeCache = {}; bool _isLoadingSeasonEpisodes = false; List _seasonTabFocusNodes = []; final Map> _seasonContextMenuKeys = {}; @@ -144,20 +150,20 @@ class _MediaDetailScreenState extends State final _infoRowsSectionKey = GlobalKey(); @override - PlexMetadata get serverBoundMetadata => widget.metadata; + MediaItem get serverBoundMetadata => _metadata; @override bool get isServerBoundOffline => widget.isOffline; // WatchStateAware: watch the show/movie and all season/episode ratingKeys @override - Set? get watchedRatingKeys { - final keys = {widget.metadata.ratingKey}; + Set? get watchedIds { + final keys = {_metadata.id}; for (final season in _seasons) { - keys.add(season.ratingKey); + keys.add(season.id); } for (final ep in _episodes) { - keys.add(ep.ratingKey); + keys.add(ep.id); } return keys; } @@ -170,37 +176,57 @@ class _MediaDetailScreenState extends State final serverId = serverBoundServerId; if (serverId == null) return null; - final keys = {toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; + final keys = {toServerBoundGlobalKey(_metadata.id, serverId: serverId)}; for (final season in _seasons) { - keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(season.id, serverId: season.serverId ?? serverId)); } for (final ep in _episodes) { - keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(ep.id, serverId: ep.serverId ?? serverId)); } return keys; } @override void onWatchStateChanged(WatchStateEvent event) { - if (!widget.isOffline) { - // If the event matches an episode currently shown, update it directly - final epIndex = _episodes.indexWhere((e) => e.ratingKey == event.ratingKey); - if (epIndex != -1) { - _updateEpisodeWatchState(event.ratingKey); - } else { - _refreshWatchState(); + final epIndex = _episodes.indexWhere((e) => e.id == event.itemId); + + if (widget.isOffline) { + // Offline: skip network refetch — patch the affected episode (or + // the show metadata) in-memory using the local watch flag the event + // already carries. The sync service drains queued actions to the + // server when the device reconnects. + if (epIndex != -1 && event.isNowWatched != null) { + setStateIfMounted(() { + final updated = _episodes[epIndex].copyWith( + viewCount: event.isNowWatched! ? 1 : 0, + viewOffsetMs: event.isNowWatched! ? _episodes[epIndex].viewOffsetMs : 0, + ); + _episodes[epIndex] = updated; + _syncEpisodeToCache(epIndex, updated); + }); + } else if (event.itemId == _metadata.id) { + unawaited(_updateWatchStateOffline()); } + return; + } + + // Online: re-fetch the affected row so server-derived counters + // (parent leafCounts, lastViewedAt) refresh too. + if (epIndex != -1) { + _updateEpisodeWatchState(event.itemId); + } else { + _refreshWatchState(); } } @override - Set? get deletionRatingKeys { - final keys = {widget.metadata.ratingKey}; + Set? get deletionIds { + final keys = {_metadata.id}; for (final season in _seasons) { - keys.add(season.ratingKey); + keys.add(season.id); } for (final ep in _episodes) { - keys.add(ep.ratingKey); + keys.add(ep.id); } return keys; } @@ -213,12 +239,12 @@ class _MediaDetailScreenState extends State final serverId = serverBoundServerId; if (serverId == null) return null; - final keys = {toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; + final keys = {toServerBoundGlobalKey(_metadata.id, serverId: serverId)}; for (final season in _seasons) { - keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(season.id, serverId: season.serverId ?? serverId)); } for (final ep in _episodes) { - keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(ep.id, serverId: ep.serverId ?? serverId)); } return keys; } @@ -229,22 +255,27 @@ class _MediaDetailScreenState extends State if (event.isDownloadOnly && !widget.isOffline) return; if (!event.isDownloadOnly && widget.isOffline) return; - // When showing episodes directly (season view or flattened), handle episode deletion - if (_showEpisodesDirectly) { - final epIndex = _episodes.indexWhere((e) => e.ratingKey == event.ratingKey); - if (epIndex != -1) { - setState(() { - _episodes.removeAt(epIndex); - }); - if (_episodes.isEmpty && (widget.metadata.isSeason || widget.metadata.isShow) && mounted) { - Navigator.of(context).pop(); - } - return; + // Drop the episode from any visible/cached list. This fires whether we're + // showing a flattened episode list or a season-tabs view of a show. + final epIndex = _episodes.indexWhere((e) => e.id == event.itemId); + if (epIndex != -1) { + setState(() { + _episodes.removeAt(epIndex); + }); + } + for (final cached in _episodeCache.values) { + cached.removeWhere((e) => e.id == event.itemId); + } + + if (epIndex != -1 && _showEpisodesDirectly) { + if (_episodes.isEmpty && (_metadata.isSeason || _metadata.isShow) && mounted) { + Navigator.of(context).pop(); } + return; } // If we have a season that matches the rating key exactly, then remove it from our list - final seasonIndex = _seasons.indexWhere((s) => s.ratingKey == event.ratingKey); + final seasonIndex = _seasons.indexWhere((s) => s.id == event.itemId); if (seasonIndex != -1) { setState(() { _seasons.removeAt(seasonIndex); @@ -263,7 +294,7 @@ class _MediaDetailScreenState extends State // If all children were deleted, remove our item. // Otherwise, just update the counts. for (final parentKey in event.parentChain) { - final idx = _seasons.indexWhere((s) => s.ratingKey == parentKey); + final idx = _seasons.indexWhere((s) => s.id == parentKey); if (idx != -1) { final season = _seasons[idx]; final newLeafCount = (season.leafCount ?? 1) - 1; @@ -292,56 +323,61 @@ class _MediaDetailScreenState extends State /// Lightweight refresh for watch state changes - no loader, preserves scroll Future _refreshWatchState() async { - final client = _getClientForMetadata(context); - if (client == null) return; + // Backend-neutral. Plex bundles metadata + on-deck in one round-trip + // (`?includeOnDeck=1`); Jellyfin's [fetchItemWithOnDeck] returns + // onDeckEpisode=null and on-deck repopulates from cached lists on + // the next navigation. + final mediaClient = _getMediaClientForMetadata(context); + if (mediaClient == null) return; + final serverId = _metadata.serverId; + if (serverId == null) return; + final serverName = _metadata.serverName; try { - // Fetch updated metadata + on-deck without showing loader - final result = await client.getMetadataWithImagesAndOnDeck(widget.metadata.ratingKey); - final metadata = result['metadata'] as PlexMetadata?; - final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; - + final result = await mediaClient.fetchItemWithOnDeck(_metadata.id); + final metadata = result.item; + final onDeckEpisode = result.onDeckEpisode; if (metadata != null) { setStateIfMounted(() { - _fullMetadata = metadata.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName); - _onDeckEpisode = onDeckEpisode?.copyWith( - serverId: widget.metadata.serverId, - serverName: widget.metadata.serverName, - ); + _fullMetadata = metadata.copyWith(serverId: serverId, serverName: serverName); + if (onDeckEpisode != null) { + _onDeckEpisode = onDeckEpisode.copyWith(serverId: serverId, serverName: serverName); + } }); } - // Refresh seasons for updated watched counts (also without loader) - if (widget.metadata.isShow) { - final seasons = await client.getChildren(widget.metadata.ratingKey); - // Clear episode cache so stale watch state data isn't reused + if (_metadata.isShow) { + final seasons = await mediaClient.fetchChildren(_metadata.id); _episodeCache.clear(); setStateIfMounted(() { - _seasons = seasons - .map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) - .toList(); + _seasons = seasons.map((s) => s.copyWith(serverId: serverId, serverName: serverName)).toList(); }); - // Re-fetch episodes for the currently selected season - if (!_showEpisodesDirectly && _seasons.isNotEmpty) { + if (_showEpisodesDirectly) { + await _fetchAllEpisodes(); + } else if (_seasons.isNotEmpty) { unawaited(_fetchSeasonEpisodes(_selectedSeasonIndex)); } - } else if (widget.metadata.isSeason) { + } else if (_metadata.isSeason) { + _episodeCache.clear(); await _fetchAllEpisodes(); } } catch (e) { - // Silently fail - data will refresh on next navigation + appLogger.d('Watch-state refresh failed', error: e); } } - /// Update a single episode's watch state without refetching everything + /// Update a single episode's watch state without refetching everything. + /// Backend-neutral so Jellyfin items refresh in place when their + /// watched flag changes (the previous Plex-only path no-op'd for + /// Jellyfin and left the row stale). Future _updateEpisodeWatchState(String ratingKey) async { - final client = _getClientForMetadata(context); - if (client == null) return; + final mediaClient = _getMediaClientForMetadata(context); + if (mediaClient == null) return; try { - final refreshed = await client.getMetadataWithImages(ratingKey); + final refreshed = await mediaClient.fetchItem(ratingKey); if (refreshed != null) { setStateIfMounted(() { - final i = _episodes.indexWhere((e) => e.ratingKey == ratingKey); + final i = _episodes.indexWhere((e) => e.id == ratingKey); if (i != -1) { _episodes[i] = refreshed; _syncEpisodeToCache(i, refreshed); @@ -439,7 +475,7 @@ class _MediaDetailScreenState extends State } /// Build action buttons row (play, shuffle, download, mark watched) - Widget _buildActionButtons(PlexMetadata metadata) { + Widget _buildActionButtons(MediaItem metadata) { final playButtonLabel = _getPlayButtonLabel(metadata); final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20); @@ -479,7 +515,6 @@ class _MediaDetailScreenState extends State metadata: metadata, isOffline: widget.isOffline, onRefresh: _loadFullMetadata, - playbackData: _playbackData, ); } } @@ -585,6 +620,7 @@ class _MediaDetailScreenState extends State Consumer( builder: (context, downloadProvider, _) { final globalKey = metadata.globalKey; + final ruleKey = _syncRuleKeyForMetadata(context, downloadProvider, metadata); final progress = downloadProvider.getProgress(globalKey); final isQueueing = downloadProvider.isQueueing(globalKey); @@ -599,7 +635,7 @@ class _MediaDetailScreenState extends State if (isQueueing) { return IconButton.filledTonal( onPressed: null, - icon: const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), + icon: const LoadingIndicatorBox(size: 20), iconSize: 20, style: actionButtonStyle(), ); @@ -642,7 +678,7 @@ class _MediaDetailScreenState extends State if (progress?.status == DownloadStatus.paused) { return IconButton.filledTonal( onPressed: () async { - final client = _getClientForMetadata(context); + final client = _getMediaClientForMetadata(context); if (client == null) return; await downloadProvider.resumeDownload(globalKey, client); if (context.mounted) { @@ -660,7 +696,7 @@ class _MediaDetailScreenState extends State if (progress?.status == DownloadStatus.failed) { return IconButton.filledTonal( onPressed: () async { - final client = _getClientForMetadata(context); + final client = _getMediaClientForMetadata(context); if (client == null) return; final versionConfig = await _resolveDownloadVersion(context, metadata, client); @@ -705,7 +741,7 @@ class _MediaDetailScreenState extends State showSuccessSnackBar(context, t.downloads.downloadDeleted); } } else if (retry && context.mounted) { - final client = _getClientForMetadata(context); + final client = _getMediaClientForMetadata(context); if (client == null) return; final versionConfig = await _resolveDownloadVersion(context, metadata, client); @@ -733,19 +769,25 @@ class _MediaDetailScreenState extends State // State 7: Partial Download (some episodes downloaded, not all) if (progress?.status == DownloadStatus.partial) { - final hasSyncRule = downloadProvider.hasSyncRule(globalKey); + final hasSyncRule = downloadProvider.hasSyncRule(ruleKey); final currentFile = progress?.currentFile; if (hasSyncRule) { // Synced partial — this is the normal state for sync rules - final syncRule = downloadProvider.getSyncRule(globalKey); + final syncRule = downloadProvider.getSyncRule(ruleKey); final isEnabled = syncRule?.enabled ?? true; final tooltip = currentFile != null ? '$currentFile (syncing ${t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?')})' : t.downloads.keepSynced; return IconButton.filledTonal( - onPressed: () => _showSyncRuleActions(context, downloadProvider, metadata, globalKey), + onPressed: () => _showSyncRuleActions( + context, + downloadProvider, + metadata, + ruleKey: ruleKey, + downloadGlobalKey: globalKey, + ), tooltip: tooltip, icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), iconSize: 20, @@ -759,7 +801,7 @@ class _MediaDetailScreenState extends State return IconButton.filledTonal( onPressed: () async { - final client = _getClientForMetadata(context); + final client = _getMediaClientForMetadata(context); if (client == null) return; final versionConfig = await _resolveDownloadVersion(context, metadata, client); @@ -787,14 +829,20 @@ class _MediaDetailScreenState extends State // State 8: Downloaded/Completed (can delete) if (downloadProvider.isDownloaded(globalKey)) { - final hasSyncRule = downloadProvider.hasSyncRule(globalKey); + final hasSyncRule = downloadProvider.hasSyncRule(ruleKey); if (hasSyncRule) { // Synced + complete — show sync icon - final syncRule = downloadProvider.getSyncRule(globalKey); + final syncRule = downloadProvider.getSyncRule(ruleKey); final isEnabled = syncRule?.enabled ?? true; return IconButton.filledTonal( - onPressed: () => _showSyncRuleActions(context, downloadProvider, metadata, globalKey), + onPressed: () => _showSyncRuleActions( + context, + downloadProvider, + metadata, + ruleKey: ruleKey, + downloadGlobalKey: globalKey, + ), icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'), iconSize: 20, @@ -828,7 +876,7 @@ class _MediaDetailScreenState extends State // State 9: Not downloaded (default - can download) return IconButton.filledTonal( onPressed: () async { - final client = _getClientForMetadata(context); + final client = _getMediaClientForMetadata(context); if (client == null) return; try { @@ -864,9 +912,9 @@ class _MediaDetailScreenState extends State // Offline mode: queue action for later sync final offlineWatch = context.read(); if (isWatched) { - await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey); + await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, itemId: metadata.id); } else { - await offlineWatch.markAsWatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey); + await offlineWatch.markAsWatched(serverId: metadata.serverId!, itemId: metadata.id); } if (mounted) { showAppSnackBar( @@ -877,14 +925,18 @@ class _MediaDetailScreenState extends State unawaited(_loadOfflineOnDeckEpisode()); } } else { - // Online mode: send to server - final client = _getClientForMetadata(context); + // Online mode: dispatch via the right backend's neutral + // method so Jellyfin items hit /UserPlayedItems and Plex + // items hit /:/scrobble. + final serverId = metadata.serverId; + if (serverId == null) return; + final client = context.tryGetMediaClientForServer(serverId); if (client == null) return; if (isWatched) { - await client.markAsUnwatched(metadata.ratingKey, metadata: metadata); + await client.markUnwatched(metadata); } else { - await client.markAsWatched(metadata.ratingKey, metadata: metadata); + await client.markWatched(metadata); } if (mounted) { _watchStateChanged = true; @@ -975,24 +1027,31 @@ class _MediaDetailScreenState extends State /// Build all rating chips for the metadata. /// When both critic and audience ratings are from Rotten Tomatoes, /// they are combined into a single badge. - List _buildRatingChips(PlexMetadata metadata) { + List _buildRatingChips(MediaItem metadata) { final chips = []; + // Plex-only fields (audienceRating / ratingImage / audienceRatingImage) + // — Jellyfin lacks rating-source attribution. Pull them via a typed + // narrow so the rest of the chip layout stays backend-neutral. + final plex = metadata is PlexMediaItem ? metadata : null; + final audienceRating = plex?.audienceRating; + final ratingImage = plex?.ratingImage; + final audienceRatingImage = plex?.audienceRatingImage; final bothRT = metadata.rating != null && - metadata.audienceRating != null && - isRottenTomatoes(metadata.ratingImage) && - isRottenTomatoes(metadata.audienceRatingImage); + audienceRating != null && + isRottenTomatoes(ratingImage) && + isRottenTomatoes(audienceRatingImage); if (bothRT) { - final critic = parseRatingImage(metadata.ratingImage, metadata.rating)!; - final audience = parseRatingImage(metadata.audienceRatingImage, metadata.audienceRating)!; + final critic = parseRatingImage(ratingImage, metadata.rating)!; + final audience = parseRatingImage(audienceRatingImage, audienceRating)!; chips.add(_buildCombinedRtChip(critic, audience)); } else { if (metadata.rating != null) { - chips.add(_buildRatingChip(metadata.ratingImage, metadata.rating!, Symbols.star_rounded)); + chips.add(_buildRatingChip(ratingImage, metadata.rating!, Symbols.star_rounded)); } - if (metadata.audienceRating != null) { - chips.add(_buildRatingChip(metadata.audienceRatingImage, metadata.audienceRating!, Symbols.people_rounded)); + if (audienceRating != null) { + chips.add(_buildRatingChip(audienceRatingImage, audienceRating, Symbols.people_rounded)); } } @@ -1004,7 +1063,9 @@ class _MediaDetailScreenState extends State return chips; } - Widget _buildUserRatingChip(PlexMetadata metadata) { + Widget _buildUserRatingChip(MediaItem metadata) { + final mediaClient = _getMediaClientForMetadata(context); + final isNumeric = mediaClient?.capabilities.numericUserRating ?? true; final hasRating = metadata.userRating != null && metadata.userRating! > 0; final starValue = hasRating ? metadata.userRating! / 2.0 : 0.0; final colorScheme = Theme.of(context).colorScheme; @@ -1014,9 +1075,18 @@ class _MediaDetailScreenState extends State final bgColor = showFocus ? colorScheme.inverseSurface : colorScheme.secondaryContainer.withValues(alpha: 0.8); final fgColor = showFocus ? colorScheme.onInverseSurface : colorScheme.onSecondaryContainer; + final activate = isNumeric ? () => _showRatingDialog(metadata, starValue) : () => _toggleLike(metadata); + + final iconData = isNumeric ? Symbols.star_rounded : Symbols.thumb_up_rounded; + final activeIconColor = isNumeric ? Colors.amber : Colors.teal; + // Numeric backends show the formatted rating when set; binary backends + // rely on the filled icon to communicate the like state and keep the + // "Rate" label as the action prompt either way. + final label = isNumeric && hasRating ? formatRating(starValue) : t.mediaMenu.rate; + return FocusableWrapper( focusNode: _ratingChipFocusNode, - onSelect: () => _showRatingDialog(metadata, starValue), + onSelect: activate, borderRadius: 100, disableScale: true, focusColor: Colors.transparent, @@ -1034,7 +1104,7 @@ class _MediaDetailScreenState extends State return KeyEventResult.ignored; }, child: GestureDetector( - onTap: () => _showRatingDialog(metadata, starValue), + onTap: activate, child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeOutCubic, @@ -1044,14 +1114,14 @@ class _MediaDetailScreenState extends State mainAxisSize: MainAxisSize.min, children: [ AppIcon( - Symbols.star_rounded, + iconData, fill: hasRating ? 1 : 0, - color: showFocus ? fgColor : (hasRating ? Colors.amber : fgColor), + color: showFocus ? fgColor : (hasRating ? activeIconColor : fgColor), size: 16, ), const SizedBox(width: 4), Text( - hasRating ? formatRating(starValue) : t.mediaMenu.rate, + label, style: TextStyle(color: fgColor, fontSize: 13, fontWeight: FontWeight.w500), ), ], @@ -1061,30 +1131,56 @@ class _MediaDetailScreenState extends State ); } - void _showRatingDialog(PlexMetadata metadata, double currentStarValue) { + /// Like/unlike toggle for backends that only support binary ratings + /// (Jellyfin). Maps to [MediaServerClient.rate] with 10 (like) or -1 + /// (clear) — the Jellyfin client routes those through POST/DELETE on + /// `/UserItems/{id}/Rating`. + Future _toggleLike(MediaItem metadata) async { + final client = _getMediaClientForMetadata(context); + if (client == null) return; + final wasLiked = metadata.userRating != null && metadata.userRating! >= 6; + final newRating = wasLiked ? -1.0 : 10.0; + try { + await client.rate(metadata, newRating); + setStateIfMounted(() { + _fullMetadata = _fullMetadata?.copyWith(userRating: wasLiked ? 0 : 10); + }); + } on MediaServerHttpException catch (e) { + appLogger.w('Failed to toggle rating', error: e); + if (mounted) showErrorSnackBar(context, t.errors.failedToRate); + } + } + + void _showRatingDialog(MediaItem metadata, double currentStarValue) { showModalBottomSheet( context: context, builder: (context) => RatingBottomSheet( currentRating: currentStarValue, onRate: (stars) async { - final client = _getClientForMetadata(this.context); + final client = _getMediaClientForMetadata(this.context); if (client == null) return; final plexRating = stars * 2.0; // Convert 0-5 stars to 0-10 scale - final success = await client.rateItem(metadata.ratingKey, plexRating); - if (success) { + try { + await client.rate(metadata, plexRating); setStateIfMounted(() { _fullMetadata = _fullMetadata?.copyWith(userRating: plexRating); }); + } on MediaServerHttpException catch (e) { + appLogger.w('Failed to set rating', error: e); + if (mounted) showErrorSnackBar(this.context, t.errors.failedToRate); } }, onClear: () async { - final client = _getClientForMetadata(this.context); + final client = _getMediaClientForMetadata(this.context); if (client == null) return; - final success = await client.rateItem(metadata.ratingKey, -1); - if (success) { + try { + await client.rate(metadata, -1); setStateIfMounted(() { _fullMetadata = _fullMetadata?.copyWith(userRating: 0); }); + } on MediaServerHttpException catch (e) { + appLogger.w('Failed to clear rating', error: e); + if (mounted) showErrorSnackBar(this.context, t.errors.failedToRate); } }, ), @@ -1117,15 +1213,27 @@ class _MediaDetailScreenState extends State ); } - /// Get the correct PlexClient for this metadata's server - /// Returns null in offline mode or if serverId is null - PlexClient? _getClientForMetadata(BuildContext context) { - return getServerBoundClient(context); + /// Backend-neutral counterpart of [getServerBoundPlexClient]. Returns a + /// [MediaServerClient] for Jellyfin items too, so image URLs use the + /// right server's transcoder. + MediaServerClient? _getMediaClientForMetadata(BuildContext context) { + return getServerBoundMediaClient(context); } - void _navigateToActorMedia(PlexRole actor) { - final personId = actor.id?.toString() ?? actor.tagKey; - if (personId == null || widget.metadata.serverId == null) return; + String _syncRuleKeyForMetadata(BuildContext context, DownloadProvider downloadProvider, MediaItem metadata) { + final serverId = metadata.serverId; + final client = _getMediaClientForMetadata(context); + if (client == null || serverId == null) return metadata.globalKey; + return downloadProvider.syncRuleKeyForClient(client, metadata.id, serverId: serverId); + } + + void _navigateToActorMedia(MediaRole actor) { + // Plex-only today — Jellyfin's `/Persons/{id}/Items` isn't wired yet. + // Cast cards still render for parity, but tapping is a no-op until the + // Jellyfin path lands. + if (_metadata.backend != MediaBackend.plex) return; + final personId = actor.id; + if (personId == null || _metadata.serverId == null) return; Navigator.push( context, @@ -1133,10 +1241,11 @@ class _MediaDetailScreenState extends State builder: (_) => ActorMediaScreen( actorName: actor.tag, personId: personId, - actorThumb: actor.thumb, + actorThumb: actor.thumbPath, characterName: actor.role, - serverId: widget.metadata.serverId!, - serverName: widget.metadata.serverName, + serverId: _metadata.serverId!, + serverName: _metadata.serverName, + backend: _metadata.backend, ), ), ); @@ -1145,20 +1254,22 @@ class _MediaDetailScreenState extends State /// Resolve version selection for download using shared utility. Future _resolveDownloadVersion( BuildContext context, - PlexMetadata metadata, - PlexClient client, + MediaItem metadata, + MediaServerClient client, ) { - return resolveDownloadVersion(context, metadata, client, fallbackVersions: _fullMetadata?.mediaVersions); + final fallback = _fullMetadata?.mediaVersions; + return resolveDownloadVersion(context, metadata, client, fallbackVersions: fallback); } /// Shows actions for a synced item: edit count, remove rule, delete downloads. Future _showSyncRuleActions( BuildContext context, DownloadProvider downloadProvider, - PlexMetadata metadata, - String globalKey, - ) async { - final syncRule = downloadProvider.getSyncRule(globalKey); + MediaItem metadata, { + required String ruleKey, + required String downloadGlobalKey, + }) async { + final syncRule = downloadProvider.getSyncRule(ruleKey); if (syncRule == null) return; final selected = await showOptionPickerDialog<_SyncRuleAction>( @@ -1178,7 +1289,7 @@ class _MediaDetailScreenState extends State final updated = await editSyncRuleCount( context, downloadProvider: downloadProvider, - globalKey: globalKey, + globalKey: ruleKey, currentCount: syncRule.episodeCount, ); if (updated && context.mounted) { @@ -1189,7 +1300,7 @@ class _MediaDetailScreenState extends State final removed = await confirmAndRemoveSyncRule( context, downloadProvider: downloadProvider, - globalKey: globalKey, + globalKey: ruleKey, displayTitle: metadata.displayTitle, ); if (removed && context.mounted) { @@ -1203,8 +1314,8 @@ class _MediaDetailScreenState extends State message: t.downloads.deleteConfirm(title: metadata.displayTitle), ); if (confirmed && context.mounted) { - await downloadProvider.deleteSyncRule(globalKey); - await downloadProvider.deleteDownload(globalKey); + await downloadProvider.deleteSyncRule(ruleKey); + await downloadProvider.deleteDownload(downloadGlobalKey); if (context.mounted) { showSuccessSnackBar(context, t.downloads.downloadDeleted); } @@ -1219,22 +1330,22 @@ class _MediaDetailScreenState extends State // Offline mode: try to load full metadata from cache (has clearLogo, summary, etc.) if (widget.isOffline) { - final cachedMetadata = await PlexApiCache.instance.getMetadata( - widget.metadata.serverId ?? '', - widget.metadata.ratingKey, + final cachedMetadata = await context.read().lookupOfflineMetadata( + _metadata.serverId ?? '', + _metadata.id, ); if (!mounted) return; setState(() { - _fullMetadata = cachedMetadata ?? widget.metadata; + _fullMetadata = cachedMetadata ?? _metadata; _isLoadingMetadata = false; }); - if (widget.metadata.isShow) { + if (_metadata.isShow) { _loadSeasonsFromDownloads(); // Get offline OnDeck episode unawaited(_loadOfflineOnDeckEpisode()); - } else if (widget.metadata.isSeason) { - _seasons = [widget.metadata]; + } else if (_metadata.isSeason) { + _seasons = [_metadata]; _showEpisodesDirectly = true; _loadEpisodesFromDownloads(); } @@ -1242,84 +1353,63 @@ class _MediaDetailScreenState extends State } try { - // Use server-specific client for this metadata - final client = _getClientForMetadata(context); + // Backend-neutral lookup. Plex returns the OnDeck episode bundled in + // the same response (`?includeOnDeck=1`); Jellyfin's + // [fetchItemWithOnDeck] returns onDeckEpisode=null and the UI + // populates resume separately if needed. + final client = getServerBoundMediaClient(context); if (client == null) { - // No client available, use passed metadata + // Truly orphaned item (server gone) — fall back to widget metadata + // and let downstream loaders no-op gracefully. setState(() { - _fullMetadata = widget.metadata; + _fullMetadata = _metadata; _isLoadingMetadata = false; }); return; } - // Fetch full metadata with clearLogo and OnDeck episode - final result = await client.getMetadataWithImagesAndOnDeck(widget.metadata.ratingKey); - final metadata = result['metadata'] as PlexMetadata?; - final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; - final playbackData = result['playbackData'] as PlexVideoPlaybackData?; + final result = await client.fetchItemWithOnDeck(_metadata.id); + final metadata = result.item; + final onDeckEpisode = result.onDeckEpisode; if (!mounted) return; - if (metadata != null) { - // Preserve serverId from original metadata - final metadataWithServerId = metadata.copyWith( - serverId: widget.metadata.serverId, - serverName: widget.metadata.serverName, - ); - final onDeckWithServerId = onDeckEpisode?.copyWith( - serverId: widget.metadata.serverId, - serverName: widget.metadata.serverName, - ); + // Preserve serverId from original metadata + final serverId = _metadata.serverId; + final serverName = _metadata.serverName; + final base = (metadata ?? _metadata).copyWith(serverId: serverId, serverName: serverName); + final onDeckWithServerId = onDeckEpisode?.copyWith(serverId: serverId, serverName: serverName); - setState(() { - _fullMetadata = metadataWithServerId; - _onDeckEpisode = onDeckWithServerId; - _playbackData = playbackData; - _isLoadingMetadata = false; - }); - - // Load seasons if it's a show - if (metadata.isShow) { - unawaited(_loadSeasons()); - } else if (metadata.isSeason) { - _seasons = [widget.metadata]; - _showEpisodesDirectly = true; - unawaited(_fetchAllEpisodes()); - } - - // Load extras (trailers, behind-the-scenes, etc.) - unawaited(_loadExtras()); - unawaited(_loadRelatedHubs()); - - return; - } - - // Fallback to passed metadata setState(() { - _fullMetadata = widget.metadata; + _fullMetadata = base; + _onDeckEpisode = onDeckWithServerId; _isLoadingMetadata = false; }); - if (widget.metadata.isShow) { + if (base.isShow) { unawaited(_loadSeasons()); - } else if (widget.metadata.isSeason) { - _seasons = [widget.metadata]; + } else if (base.isSeason) { + _seasons = [base]; _showEpisodesDirectly = true; unawaited(_fetchAllEpisodes()); } + + // [_loadExtras] and [_loadRelatedHubs] short-circuit for non-Plex + // backends; safe to call unconditionally. + unawaited(_loadExtras()); + unawaited(_loadRelatedHubs()); } catch (e) { // Fallback to passed metadata on error if (!mounted) return; setState(() { - _fullMetadata = widget.metadata; + _fullMetadata = _metadata; _isLoadingMetadata = false; }); - if (widget.metadata.isShow) { + if (_metadata.isShow) { unawaited(_loadSeasons()); - } else if (widget.metadata.isSeason) { - _seasons = [widget.metadata]; + } else if (_metadata.isSeason) { + _seasons = [_metadata]; _showEpisodesDirectly = true; unawaited(_fetchAllEpisodes()); } @@ -1328,38 +1418,53 @@ class _MediaDetailScreenState extends State Future _loadSeasons() async { _seasonsCompleter = Completer(); - setState(() { + setStateIfMounted(() { _isLoadingSeasons = true; }); - try { - // Use server-specific client for this metadata - final client = _getClientForMetadata(context); + final serverId = _metadata.serverId; + final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + if (client == null) { + setStateIfMounted(() => _isLoadingSeasons = false); + if (!(_seasonsCompleter?.isCompleted ?? true)) _seasonsCompleter?.complete(); + return; + } - // Fetch seasons and library prefs in parallel - final sectionId = (_fullMetadata ?? widget.metadata).librarySectionID?.toString(); - final seasonsFuture = client?.getChildren(widget.metadata.ratingKey) ?? Future.value([]); - final prefsFuture = (sectionId != null && client != null) + try { + // Plex has a server-side "flatten seasons" preference; + // Jellyfin has no equivalent, so fetch the prefs only when we have + // a Plex client and a section id. The library section id came from + // Plex as an int but lands in [MediaItem.libraryId] as the string + // form (or null on Jellyfin items). + final sectionId = (_fullMetadata ?? _metadata).libraryId; + final seasonsFuture = client.fetchChildren(_metadata.id); + final prefsFuture = (client is PlexClient && sectionId != null) ? client.getLibrarySectionPrefs(sectionId) : Future.value({}); final results = await Future.wait([seasonsFuture, prefsFuture]); - final seasons = results[0] as List; + final seasons = results[0] as List; final prefs = results[1] as Map; - // Preserve serverId for each season + // Preserve serverId for each season. final seasonsWithServerId = seasons - .map((season) => season.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) + .map((season) => season.copyWith(serverId: serverId, serverName: _metadata.serverName)) .toList(); - // Check the server setting the season display mode - const flattenSeasonsAlways = 1; - const flattenSeasonsSingleSeason = 2; - final flattenSeasons = int.tryParse(prefs['flattenSeasons']?.toString() ?? ''); - final isAlways = flattenSeasons == flattenSeasonsAlways; - final isSingleSeason = flattenSeasons == flattenSeasonsSingleSeason; - final shouldShowEpisodesDirectly = - isAlways || seasonsWithServerId.isEmpty || (isSingleSeason && seasonsWithServerId.length == 1); + // Plex's flattenSeasons modes: 1 = always, 2 = single-season only. + // Jellyfin falls through to "flatten when there's a single season". + bool shouldShowEpisodesDirectly; + if (client is PlexClient) { + const flattenSeasonsAlways = 1; + const flattenSeasonsSingleSeason = 2; + final flattenSeasons = int.tryParse(prefs['flattenSeasons']?.toString() ?? ''); + final isAlways = flattenSeasons == flattenSeasonsAlways; + final isSingleSeason = flattenSeasons == flattenSeasonsSingleSeason; + shouldShowEpisodesDirectly = + isAlways || seasonsWithServerId.isEmpty || (isSingleSeason && seasonsWithServerId.length == 1); + } else { + shouldShowEpisodesDirectly = seasonsWithServerId.length <= 1; + } // Create focus nodes for season tabs _updateSeasonTabFocusNodes(seasonsWithServerId.length); @@ -1380,7 +1485,8 @@ class _MediaDetailScreenState extends State // Fetch episodes for the auto-selected season unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex)); } - } catch (e) { + } catch (e, st) { + appLogger.w('Seasons load failed', error: e, stackTrace: st); setStateIfMounted(() { _isLoadingSeasons = false; }); @@ -1399,36 +1505,36 @@ class _MediaDetailScreenState extends State }); final downloadProvider = context.read(); - final episodes = downloadProvider.getDownloadedEpisodesForShow(widget.metadata.ratingKey); + final episodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id); // Group episodes by season - final Map> seasonMap = {}; + final Map> seasonMap = {}; for (final episode in episodes) { final seasonNum = episode.parentIndex ?? 0; seasonMap.putIfAbsent(seasonNum, () => []).add(episode); } - // Create season metadata from episodes + // Create synthetic season MediaItems from the grouped episodes. final seasons = seasonMap.entries.map((entry) { final firstEp = entry.value.first; - return PlexMetadata( - ratingKey: firstEp.parentRatingKey ?? '', - key: '/library/metadata/${firstEp.parentRatingKey}', - type: 'season', + return MediaItem( + id: firstEp.parentId ?? '', + backend: _metadata.backend, + kind: MediaKind.season, title: firstEp.parentTitle ?? 'Season ${entry.key}', index: entry.key, leafCount: entry.value.length, - thumb: firstEp.parentThumb, - parentRatingKey: firstEp.grandparentRatingKey, - serverId: widget.metadata.serverId, - serverName: widget.metadata.serverName, + thumbPath: firstEp.parentThumbPath, + parentId: firstEp.grandparentId, + serverId: _metadata.serverId, + serverName: _metadata.serverName, ); }).toList()..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); // Create focus nodes for season tabs and cache episodes per season _updateSeasonTabFocusNodes(seasons.length); for (final entry in seasonMap.entries) { - final seasonRatingKey = entry.value.first.parentRatingKey ?? ''; + final seasonRatingKey = entry.value.first.parentId ?? ''; _episodeCache[seasonRatingKey] = entry.value..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); } @@ -1453,8 +1559,8 @@ class _MediaDetailScreenState extends State /// Load episodes from downloaded content for a season void _loadEpisodesFromDownloads() { final downloadProvider = context.read(); - final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(widget.metadata.parentRatingKey ?? ''); - final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == widget.metadata.index).toList() + final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.parentId ?? ''); + final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == _metadata.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); setState(() { @@ -1475,7 +1581,7 @@ class _MediaDetailScreenState extends State } /// Find the season index matching the initial selection or on-deck episode, or fall back to 0 - int _findOnDeckSeasonIndex(List seasons) { + int _findOnDeckSeasonIndex(List seasons) { // Prefer explicit initial season (from navigation) if (widget.initialSeasonIndex != null && seasons.isNotEmpty) { final idx = seasons.indexWhere((s) => s.index == widget.initialSeasonIndex); @@ -1498,7 +1604,7 @@ class _MediaDetailScreenState extends State final season = _seasons[seasonIndex]; // Check cache first - final cached = _episodeCache[season.ratingKey]; + final cached = _episodeCache[season.id]; if (cached != null) { setStateIfMounted(() { _episodes = List.of(cached); @@ -1513,25 +1619,35 @@ class _MediaDetailScreenState extends State if (widget.isOffline) { // Offline: load from downloads final downloadProvider = context.read(); - final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(widget.metadata.ratingKey); + final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id); final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == season.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); - _episodeCache[season.ratingKey] = seasonEpisodes; + _episodeCache[season.id] = seasonEpisodes; setStateIfMounted(() { _episodes = List.of(seasonEpisodes); _isLoadingSeasonEpisodes = false; }); } else { - final client = _getClientForMetadata(context); - if (client == null) { + // Resolve the right backend client so Jellyfin (where the typed + // PlexClient helper returns null) loads episodes too. + final serverId = _metadata.serverId; + final mediaClient = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + if (serverId == null || mediaClient == null) { setStateIfMounted(() => _isLoadingSeasonEpisodes = false); return; } - final episodes = await client.getChildren(season.ratingKey); + final episodes = await mediaClient.fetchChildren(season.id); final episodesWithServerId = episodes - .map((e) => e.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) + .map( + (e) => e.copyWith( + serverId: _metadata.serverId, + serverName: _metadata.serverName, + grandparentId: _metadata.id, + grandparentTitle: _metadata.title, + ), + ) .toList(); - _episodeCache[season.ratingKey] = episodesWithServerId; + _episodeCache[season.id] = episodesWithServerId; setStateIfMounted(() { _episodes = List.of(episodesWithServerId); _isLoadingSeasonEpisodes = false; @@ -1542,10 +1658,11 @@ class _MediaDetailScreenState extends State } } - /// Load extras (trailers, behind-the-scenes, etc.) + /// Load extras (trailers, behind-the-scenes, etc.). Plex-only — Jellyfin + /// has no equivalent of `fetchExtras`. Future _loadExtras() async { // Only load extras for movies and shows - if (!widget.metadata.isMovie && !widget.metadata.isShow) { + if (!_metadata.isMovie && !_metadata.isShow) { return; } @@ -1554,17 +1671,19 @@ class _MediaDetailScreenState extends State return; } + if (_metadata.backend != MediaBackend.plex) return; + try { - final client = _getClientForMetadata(context); + final client = getServerBoundPlexClient(context); if (client == null) { return; } - final extras = await client.getExtras(widget.metadata.ratingKey); + final extras = await client.fetchExtras(_metadata.id); - // Preserve serverId for each extra (needed for multi-server setups) + // Preserve serverId for each extra (needed for multi-server setups). final extrasWithServerId = extras - .map((extra) => extra.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) + .map((extra) => extra.copyWith(serverId: _metadata.serverId, serverName: _metadata.serverName)) .toList(); setStateIfMounted(() { @@ -1575,9 +1694,11 @@ class _MediaDetailScreenState extends State } } - /// Load related hubs (collections, similar, "more from" director/actor) + /// Load related hubs (collections, similar, "more from" director/actor). + /// Backend-neutral — both Plex and Jellyfin implement + /// [MediaServerClient.fetchRelatedHubs]. Future _loadRelatedHubs() async { - if (!widget.metadata.isMovie && !widget.metadata.isShow) { + if (!_metadata.isMovie && !_metadata.isShow) { return; } @@ -1585,15 +1706,16 @@ class _MediaDetailScreenState extends State return; } - try { - final client = _getClientForMetadata(context); - if (client == null) return; + final serverId = _metadata.serverId; + final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); + if (client == null) return; - final hubs = await client.getRelatedHubs(widget.metadata.ratingKey); + try { + final relatedHubs = await client.fetchRelatedHubs(_metadata.id); setStateIfMounted(() { - _relatedHubs = hubs; - _relatedHubKeys = List.generate(hubs.length, (_) => GlobalKey()); + _relatedHubs = relatedHubs; + _relatedHubKeys = List.generate(relatedHubs.length, (_) => GlobalKey()); }); } catch (e) { // Silently fail - related sections won't appear if fetch fails @@ -1603,7 +1725,7 @@ class _MediaDetailScreenState extends State /// Focus the first visible section above cast: season tabs → overview → play button. /// Shared by cast UP, extras UP, and related hub UP handlers. void _focusSectionAboveCast() { - final metadata = _fullMetadata ?? widget.metadata; + final metadata = _fullMetadata ?? _metadata; if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); @@ -1618,8 +1740,8 @@ class _MediaDetailScreenState extends State /// Focus the first visible section above extras: cast → season tabs → overview → play button. void _focusSectionAboveExtras() { - final metadata = _fullMetadata ?? widget.metadata; - if (metadata.role != null && metadata.role!.isNotEmpty) { + final metadata = _fullMetadata ?? _metadata; + if (metadata.roles != null && metadata.roles!.isNotEmpty) { _castFocusNode.requestFocus(); _scrollSectionIntoView(_castSectionKey); } else { @@ -1628,7 +1750,7 @@ class _MediaDetailScreenState extends State } bool get _hasInfoRows { - final metadata = _fullMetadata ?? widget.metadata; + final metadata = _fullMetadata ?? _metadata; return metadata.studio != null || metadata.contentRating != null; } @@ -1671,7 +1793,7 @@ class _MediaDetailScreenState extends State if (!key.isDownKey) return KeyEventResult.ignored; - final metadata = _fullMetadata ?? widget.metadata; + final metadata = _fullMetadata ?? _metadata; // DOWN order: overview → seasons → cast → extras if (metadata.summary != null && metadata.summary!.isNotEmpty) { @@ -1693,7 +1815,7 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - if (metadata.role != null && metadata.role!.isNotEmpty) { + if (metadata.roles != null && metadata.roles!.isNotEmpty) { _castFocusNode.requestFocus(); _scrollSectionIntoView(_castSectionKey); return KeyEventResult.handled; @@ -1727,7 +1849,7 @@ class _MediaDetailScreenState extends State if (key.isBackKey) return KeyEventResult.ignored; if (!event.isActionable) return KeyEventResult.ignored; - final metadata = _fullMetadata ?? widget.metadata; + final metadata = _fullMetadata ?? _metadata; // UP: always play button (overview is directly below play) if (key.isUpKey) { @@ -1743,7 +1865,7 @@ class _MediaDetailScreenState extends State } else if (_episodes.isNotEmpty) { _firstEpisodeFocusNode.requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); - } else if (metadata.role != null && metadata.role!.isNotEmpty) { + } else if (metadata.roles != null && metadata.roles!.isNotEmpty) { _castFocusNode.requestFocus(); _scrollSectionIntoView(_castSectionKey); } else if (_extras != null && _extras!.isNotEmpty) { @@ -1797,14 +1919,14 @@ class _MediaDetailScreenState extends State final season = _seasons[index]; final contextMenuKey = _seasonContextMenuKeys.putIfAbsent(index, () => GlobalKey()); Offset? tapPosition; - final posterPath = season.thumb; + final posterPath = season.thumbPath; Widget? topImage; if (showPosters && posterPath != null && posterPath.isNotEmpty) { const posterWidth = 72.0; const posterHeight = 108.0; - final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); - final client = _getClientForMetadata(context); - final imageUrl = PlexImageHelper.getOptimizedImageUrl( + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final client = _getMediaClientForMetadata(context); + final imageUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: posterPath, maxWidth: posterWidth, @@ -1812,7 +1934,7 @@ class _MediaDetailScreenState extends State devicePixelRatio: dpr, imageType: ImageType.poster, ); - final (memWidth, _) = PlexImageHelper.getMemCacheDimensions( + final (memWidth, _) = MediaImageHelper.getMemCacheDimensions( displayWidth: (posterWidth * dpr).round(), displayHeight: (posterHeight * dpr).round(), imageType: ImageType.poster, @@ -1987,8 +2109,8 @@ class _MediaDetailScreenState extends State if (key.isBackKey) return KeyEventResult.ignored; if (!event.isActionable) return KeyEventResult.ignored; - final metadata = _fullMetadata ?? widget.metadata; - final roleCount = metadata.role?.length ?? 0; + final metadata = _fullMetadata ?? _metadata; + final roleCount = metadata.roles?.length ?? 0; // LEFT: previous cast member if (key.isLeftKey) { @@ -2044,9 +2166,9 @@ class _MediaDetailScreenState extends State // SELECT: navigate to actor media if (key.isSelectKey) { - final metadata = _fullMetadata ?? widget.metadata; - if (_focusedCastIndex < (metadata.role?.length ?? 0)) { - _navigateToActorMedia(metadata.role![_focusedCastIndex]); + final metadata = _fullMetadata ?? _metadata; + if (_focusedCastIndex < (metadata.roles?.length ?? 0)) { + _navigateToActorMedia(metadata.roles![_focusedCastIndex]); } return KeyEventResult.handled; } @@ -2094,7 +2216,7 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - IconData _getRelatedHubIcon(PlexHub hub) { + IconData _getRelatedHubIcon(MediaHub hub) { final lower = hub.title.toLowerCase(); if (lower.contains('collection')) return Symbols.video_library_rounded; if (lower.contains('similar')) return Symbols.auto_awesome_rounded; @@ -2105,7 +2227,7 @@ class _MediaDetailScreenState extends State /// Build episode list directly when the library hides seasons for single-season shows Widget _buildEpisodesList() { - final client = _getClientForMetadata(context); + final client = _getMediaClientForMetadata(context); return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -2132,7 +2254,7 @@ class _MediaDetailScreenState extends State ? () { if (!_showEpisodesDirectly) { _focusSelectedSeasonTab(); - } else if ((_fullMetadata ?? widget.metadata).summary?.isNotEmpty == true) { + } else if ((_fullMetadata ?? _metadata).summary?.isNotEmpty == true) { _overviewFocusNode.requestFocus(); _scrollSectionIntoView(_overviewSectionKey); } else { @@ -2148,7 +2270,7 @@ class _MediaDetailScreenState extends State metadata: episode, isOffline: widget.isOffline, onRefresh: () async { - final refreshed = await client?.getMetadataWithImages(episode.ratingKey); + final refreshed = await client?.fetchItem(episode.id); if (refreshed != null) { setStateIfMounted(() { _episodes[index] = refreshed; @@ -2161,10 +2283,10 @@ class _MediaDetailScreenState extends State onRefresh: widget.isOffline ? null : (ratingKey) async { - final refreshed = await client?.getMetadataWithImages(ratingKey); + final refreshed = await client?.fetchItem(ratingKey); if (refreshed != null) { setStateIfMounted(() { - final i = _episodes.indexWhere((e) => e.ratingKey == ratingKey); + final i = _episodes.indexWhere((e) => e.id == ratingKey); if (i != -1) { _episodes[i] = refreshed; _syncEpisodeToCache(i, refreshed); @@ -2179,11 +2301,11 @@ class _MediaDetailScreenState extends State } /// Sync an updated episode back into the episode cache - void _syncEpisodeToCache(int episodeIndex, PlexMetadata updated) { + void _syncEpisodeToCache(int episodeIndex, MediaItem updated) { if (_showEpisodesDirectly || _seasons.isEmpty) return; if (_selectedSeasonIndex >= _seasons.length) return; final season = _seasons[_selectedSeasonIndex]; - final cached = _episodeCache[season.ratingKey]; + final cached = _episodeCache[season.id]; if (cached != null && episodeIndex < cached.length) { cached[episodeIndex] = updated; } @@ -2196,20 +2318,42 @@ class _MediaDetailScreenState extends State } else if (_seasons.isNotEmpty) { // Clear cache for current season and re-fetch final season = _seasons[_selectedSeasonIndex]; - _episodeCache.remove(season.ratingKey); + _episodeCache.remove(season.id); await _fetchSeasonEpisodes(_selectedSeasonIndex); } } Future _fetchAllEpisodes() async { if (_seasons.isEmpty) return; - final client = _getClientForMetadata(context); + final serverId = _metadata.serverId; + if (serverId == null) return; + final client = context.tryGetMediaClientForServer(serverId); if (client == null) return; setStateIfMounted(() => _isLoadingEpisodes = true); try { - final episodeLists = await Future.wait(_seasons.map((season) => client.getChildren(season.ratingKey))); + // One-shot recursive expansion — Plex `/grandchildren`, Jellyfin + // Recursive=true. Replaces the previous per-season fan-out so a + // many-season show flatten doesn't fan out N parallel HTTP calls. + // Enrich each episode with serverId/serverName/grandparent fields — + // Jellyfin's recursive query doesn't always populate them, and the + // copy is a no-op for Plex where the mapper already does. + final episodes = await client.fetchPlayableDescendants(_metadata.id); + final fallbackGrandparentId = _metadata.isSeason ? (_metadata.grandparentId ?? _metadata.parentId) : _metadata.id; + final fallbackGrandparentTitle = _metadata.isSeason + ? (_metadata.grandparentTitle ?? _metadata.parentTitle) + : _metadata.title; + final enriched = episodes + .map( + (e) => e.copyWith( + serverId: serverId, + serverName: _metadata.serverName, + grandparentId: e.grandparentId ?? fallbackGrandparentId, + grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle, + ), + ) + .toList(); setStateIfMounted(() { - _episodes = episodeLists.expand((e) => e).toList(); + _episodes = enriched; _isLoadingEpisodes = false; }); } catch (e, st) { @@ -2221,7 +2365,7 @@ class _MediaDetailScreenState extends State /// Load the next unwatched episode for offline mode (offline OnDeck) Future _loadOfflineOnDeckEpisode() async { final offlineWatchProvider = context.read(); - final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(widget.metadata.ratingKey); + final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id); if (nextEpisode != null) { setStateIfMounted(() { @@ -2231,38 +2375,23 @@ class _MediaDetailScreenState extends State } } - /// Offline: update viewCount in the API cache and re-read metadata from it. + /// Offline: patch the in-memory metadata so the UI reflects a queued + /// watch/unwatch action immediately. The sync service holds the truth + /// for offline state and will reconcile with the server on reconnect, + /// so we don't need to round-trip through the per-backend cache here. Future _updateWatchStateOffline() async { - final serverId = widget.metadata.serverId; + final serverId = _metadata.serverId; if (serverId == null) return; - - final ratingKey = widget.metadata.ratingKey; - final cache = PlexApiCache.instance; - final syncService = context.read(); - - final endpoint = '/library/metadata/$ratingKey'; - final cached = await cache.get(serverId, endpoint); - final json = PlexCacheParser.extractFirstMetadata(cached); - if (json == null) return; - - final localStatus = await syncService.getLocalWatchStatus('$serverId:$ratingKey'); - if (localStatus == true) { - json['viewCount'] = 1; - } else if (localStatus == false) { - json['viewCount'] = 0; - json['viewOffset'] = 0; - } - - await cache.put(serverId, endpoint, { - 'MediaContainer': { - 'Metadata': [json], - }, - }); - + final localStatus = await context.read().getLocalWatchStatus('$serverId:${_metadata.id}'); + if (localStatus == null) return; setStateIfMounted(() { - _fullMetadata = PlexMetadata.fromJsonWithImages( - json, - ).copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName); + final base = _fullMetadata ?? _metadata; + _fullMetadata = base.copyWith( + viewCount: localStatus ? 1 : 0, + // Reset the resume position when transitioning to unwatched, mirroring + // the previous Plex cache-mutation behavior. + viewOffsetMs: localStatus ? base.viewOffsetMs : 0, + ); }); } @@ -2295,19 +2424,19 @@ class _MediaDetailScreenState extends State final firstSeason = _seasons.firstWhere((s) => (s.index ?? 0) > 0, orElse: () => _seasons.first); // Get episodes of the first season - List episodes; + List episodes; if (!mounted) return; if (widget.isOffline) { // In offline mode, get episodes from downloads final downloadProvider = context.read(); - final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(widget.metadata.ratingKey); + final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id); // Filter to episodes of this season episodes = allEpisodes.where((ep) => ep.parentIndex == firstSeason.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); } else { - final client = _getClientForMetadata(context); + final client = getServerBoundMediaClient(context); if (client == null) return; - episodes = await client.getChildren(firstSeason.ratingKey); + episodes = await client.fetchChildren(firstSeason.id); } if (episodes.isEmpty) { @@ -2320,10 +2449,7 @@ class _MediaDetailScreenState extends State // Play the first episode final firstEpisode = episodes.first; // Preserve serverId for the episode - final episodeWithServerId = firstEpisode.copyWith( - serverId: widget.metadata.serverId, - serverName: widget.metadata.serverName, - ); + final episodeWithServerId = firstEpisode.copyWith(serverId: _metadata.serverId, serverName: _metadata.serverName); if (mounted) { appLogger.d('Playing first episode: ${episodeWithServerId.title}'); await navigateToVideoPlayerWithRefresh( @@ -2340,10 +2466,10 @@ class _MediaDetailScreenState extends State } } - /// Handle shuffle play using play queues - /// Note: Shuffle requires server connectivity (play queue API) - Future _handleShufflePlayWithQueue(BuildContext context, PlexMetadata metadata) async { - // Shuffle requires server connectivity + /// Handle shuffle play. Routes through [MediaListPlaybackLauncher.forItem] + /// so Plex uses its server-side `/playQueues` and Jellyfin builds a local + /// shuffled queue from `fetchClientSideEpisodeQueue`. + Future _handleShufflePlayWithQueue(BuildContext context, MediaItem metadata) async { if (widget.isOffline) { if (context.mounted) { showErrorSnackBar(context, 'Shuffle not available offline'); @@ -2351,79 +2477,17 @@ class _MediaDetailScreenState extends State return; } - final client = _getClientForMetadata(context); - if (client == null) return; - - final playbackState = context.read(); - - try { - if (context.mounted) { - showLoadingDialog(context); - } - - // Determine the rating key for the play queue - String showRatingKey; - if (metadata.isShow) { - showRatingKey = metadata.ratingKey; - } else if (metadata.isSeason) { - // For seasons, we need the show's rating key - // The season's parentRatingKey should point to the show - if (metadata.parentRatingKey == null) { - throw Exception('Season is missing parentRatingKey'); - } - showRatingKey = metadata.parentRatingKey!; - } else { - throw Exception('Shuffle play only works for shows and seasons'); - } - - // Create a shuffled play queue for the show - final playQueue = await client.createShowPlayQueue(showRatingKey: showRatingKey, shuffle: 1); - - // Close loading indicator - if (context.mounted) { - Navigator.pop(context); - } - - if (playQueue == null || playQueue.items == null || playQueue.items!.isEmpty) { - if (context.mounted) { - showErrorSnackBar(context, t.messages.noEpisodesFound); - } - return; - } - - // Initialize playback state with the play queue - await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey); - - // Set the client for the playback state provider - playbackState.setClient(client); - - // Navigate to the first episode in the shuffled queue - final firstEpisode = playQueue.items!.first.copyWith( - serverId: metadata.serverId, - serverName: metadata.serverName, - ); - - if (context.mounted) { - await navigateToVideoPlayer(context, metadata: firstEpisode); - // Refresh metadata when returning from video player - unawaited(_loadFullMetadata()); - } - } catch (e) { - // Close loading indicator if it's still open - if (context.mounted && Navigator.canPop(context)) { - Navigator.pop(context); - } - - if (context.mounted) { - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - } + final launcher = MediaListPlaybackLauncher.forItem(context, metadata); + final result = await launcher.launchShuffledShow(metadata: metadata); + if (result is PlayQueueSuccess && mounted) { + unawaited(_loadFullMetadata()); } } @override Widget build(BuildContext context) { // Use full metadata if loaded, otherwise use passed metadata - final metadata = _fullMetadata ?? widget.metadata; + final metadata = _fullMetadata ?? _metadata; final isShow = metadata.isShow; final isMobile = PlatformDetector.isMobile(context); final isTv = PlatformDetector.isTV(); @@ -2474,20 +2538,20 @@ class _MediaDetailScreenState extends State SizedBox( height: headerHeight, width: double.infinity, - child: (metadata.art != null || metadata.backgroundSquare != null) + child: (metadata.artPath != null || metadata.backgroundSquarePath != null) ? Builder( builder: (context) { final containerAspect = size.width / headerHeight; final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect); // Check for offline local file first - if (widget.isOffline && widget.metadata.serverId != null) { + if (widget.isOffline && _metadata.serverId != null) { final localPath = context.read().getArtworkLocalPath( - widget.metadata.serverId!, + _metadata.serverId!, heroArtPath, ); if (localPath != null && File(localPath).existsSync()) { - return PlexOptimizedImage( + return OptimizedMediaImage( client: null, imagePath: null, localFilePath: localPath, @@ -2501,10 +2565,10 @@ class _MediaDetailScreenState extends State } // Online - use network image - final client = _getClientForMetadata(context); + final client = _getMediaClientForMetadata(context); final mqSize = MediaQuery.sizeOf(context); - final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); - final imageUrl = PlexImageHelper.getOptimizedImageUrl( + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final imageUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: heroArtPath, maxWidth: mqSize.width, @@ -2513,7 +2577,7 @@ class _MediaDetailScreenState extends State imageType: ImageType.art, ); - final (_, memHeight) = PlexImageHelper.getMemCacheDimensions( + final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( displayWidth: (mqSize.width * dpr).round(), displayHeight: (mqSize.height * 0.6 * dpr).round(), imageType: ImageType.art, @@ -2569,20 +2633,20 @@ class _MediaDetailScreenState extends State mainAxisSize: MainAxisSize.min, children: [ // Clear logo or title - if (metadata.clearLogo != null) + if (metadata.clearLogoPath != null) SizedBox( height: 120, width: 400, child: Builder( builder: (context) { // Check for offline local file first - if (widget.isOffline && widget.metadata.serverId != null) { + if (widget.isOffline && _metadata.serverId != null) { final localPath = context.read().getArtworkLocalPath( - widget.metadata.serverId!, - metadata.clearLogo, + _metadata.serverId!, + metadata.clearLogoPath, ); if (localPath != null && File(localPath).existsSync()) { - return PlexOptimizedImage( + return OptimizedMediaImage( client: null, imagePath: null, localFilePath: localPath, @@ -2598,11 +2662,11 @@ class _MediaDetailScreenState extends State } // Online - use network image - final client = _getClientForMetadata(context); - final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); - final logoUrl = PlexImageHelper.getOptimizedImageUrl( + final client = _getMediaClientForMetadata(context); + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final logoUrl = MediaImageHelper.getOptimizedImageUrl( client: client, - thumbPath: metadata.clearLogo, + thumbPath: metadata.clearLogoPath, maxWidth: 400, maxHeight: 120, devicePixelRatio: dpr, @@ -2661,11 +2725,12 @@ class _MediaDetailScreenState extends State runSpacing: 8, children: [ if (metadata.year != null) _buildMetadataChip('${metadata.year}'), - if (metadata.editionTitle != null) _buildMetadataChip(metadata.editionTitle!), + if (metadata case PlexMediaItem(:final editionTitle?)) + _buildMetadataChip(editionTitle), if (metadata.contentRating != null) _buildMetadataChip(formatContentRating(metadata.contentRating!)), - if (metadata.duration != null) - _buildMetadataChip(formatDurationTextual(metadata.duration!)), + if (metadata.durationMs != null) + _buildMetadataChip(formatDurationTextual(metadata.durationMs!)), ..._buildRatingChips(metadata), ], ), @@ -2807,7 +2872,7 @@ class _MediaDetailScreenState extends State ], // Cast - if (metadata.role != null && metadata.role!.isNotEmpty) ...[ + if (metadata.roles != null && metadata.roles!.isNotEmpty) ...[ Text( key: _castSectionKey, t.discover.cast, @@ -2934,24 +2999,26 @@ class _MediaDetailScreenState extends State } /// Get the primary trailer from the extras list - PlexMetadata? _getPrimaryTrailer() { + MediaItem? _getPrimaryTrailer() { if (_extras == null || _extras!.isEmpty) return null; - // If there's a primaryExtraKey, try to find that specific trailer - final metadata = _fullMetadata ?? widget.metadata; - if (metadata.primaryExtraKey != null) { - // Extract rating key from primaryExtraKey (e.g., "/library/metadata/52601" -> "52601") - final primaryKey = metadata.primaryExtraKey!.split('/').last; + // If there's a trailerKey (Plex `primaryExtraKey`), try to find that specific trailer + final metadata = _fullMetadata ?? _metadata; + if (metadata case PlexMediaItem(:final trailerKey?)) { + // Extract rating key from trailerKey (e.g., "/library/metadata/52601" -> "52601") + final primaryKey = trailerKey.split('/').last; try { - return _extras!.firstWhere((extra) => extra.ratingKey == primaryKey); + return _extras!.firstWhere((extra) => extra.id == primaryKey); } catch (_) { // Primary key not found, fall through to find any trailer } } - // Otherwise, find the first item with subtype 'trailer' + // Otherwise, find the first item with subtype 'trailer'. Extras are + // always Plex-sourced so the cast is safe; non-Plex backends route + // around this method entirely. try { - return _extras!.firstWhere((extra) => extra.subtype == 'trailer'); + return _extras!.firstWhere((extra) => extra is PlexMediaItem && extra.subtype == 'trailer'); } catch (_) { // No trailer found, return null (button won't appear) return null; @@ -2960,7 +3027,7 @@ class _MediaDetailScreenState extends State /// Build the cast section with locked focus pattern for D-pad navigation /// Uses same layout pattern as seasons/extras (ListView.builder + Padding(horizontal: 2)) - Widget _buildCastSection(PlexMetadata metadata) { + Widget _buildCastSection(MediaItem metadata) { final cardWidth = _getResponsiveCardWidth(); const innerPadding = 3.0; final imageSize = cardWidth; @@ -2985,9 +3052,9 @@ class _MediaDetailScreenState extends State scrollDirection: Axis.horizontal, clipBehavior: Clip.none, padding: const EdgeInsets.symmetric(vertical: 5), - itemCount: metadata.role!.length, + itemCount: metadata.roles!.length, itemBuilder: (context, index) { - final actor = metadata.role![index]; + final actor = metadata.roles![index]; final isFocused = hasFocus && index == _focusedCastIndex; return Padding( @@ -3006,9 +3073,9 @@ class _MediaDetailScreenState extends State children: [ ClipRRect( borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: PlexOptimizedImage( - client: _getClientForMetadata(context), - imagePath: actor.thumb, + child: OptimizedMediaImage( + client: getServerBoundMediaClient(context), + imagePath: actor.thumbPath, width: imageSize, height: imageSize, fit: BoxFit.cover, @@ -3112,7 +3179,7 @@ class _MediaDetailScreenState extends State ); } - String _getPlayButtonLabel(PlexMetadata metadata) { + String _getPlayButtonLabel(MediaItem metadata) { // For TV shows - use compact S1E1 format if (metadata.isShow) { if (_onDeckEpisode != null) { @@ -3133,19 +3200,19 @@ class _MediaDetailScreenState extends State return ''; } - IconData _getPlayButtonIcon(PlexMetadata metadata) { + IconData _getPlayButtonIcon(MediaItem metadata) { // For TV shows if (metadata.isShow) { if (_onDeckEpisode != null) { final episode = _onDeckEpisode!; // Check if episode has been partially watched - if (episode.viewOffset != null && episode.viewOffset! > 0) { + if (episode.viewOffsetMs != null && episode.viewOffsetMs! > 0) { return Symbols.resume_rounded; // Resume icon } } } else { // For movies or episodes - if (metadata.viewOffset != null && metadata.viewOffset! > 0) { + if (metadata.viewOffsetMs != null && metadata.viewOffsetMs! > 0) { return Symbols.resume_rounded; // Resume icon } } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index e68dc229..e53bef53 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../services/plex_client.dart'; -import '../../services/play_queue_launcher.dart'; -import '../../models/plex_playlist.dart'; -import '../../models/plex_metadata.dart'; +import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; +import '../../media/media_playlist.dart'; +import '../../services/media_list_playback_launcher.dart'; +import '../../services/playlist_items_loader.dart'; import '../../utils/app_logger.dart'; -import '../../utils/provider_extensions.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../focus/dpad_navigator.dart'; @@ -17,11 +17,9 @@ import 'package:provider/provider.dart'; import 'playlist_item_card.dart'; import '../../i18n/strings.g.dart'; import '../../providers/download_provider.dart'; -import '../../utils/content_utils.dart'; import '../../utils/platform_detector.dart'; import '../../utils/dialogs.dart'; import '../../utils/download_utils.dart'; -import '../../utils/global_key_utils.dart'; import '../../utils/snackbar_helper.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; @@ -29,7 +27,7 @@ import '../../mixins/grid_focus_node_mixin.dart'; /// Screen to display the contents of a playlist class PlaylistDetailScreen extends StatefulWidget { - final PlexPlaylist playlist; + final MediaPlaylist playlist; const PlaylistDetailScreen({super.key, required this.playlist}); @@ -43,7 +41,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen, FocusableDetailScreenMixin { @override - dynamic get mediaItem => widget.playlist; + Object get mediaItem => widget.playlist; @override String get title => widget.playlist.title; @@ -57,13 +55,18 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen items.isNotEmpty; + /// True when the playlist can't be reordered or have items removed. + /// Currently only Plex smart playlists (server-side rule-based; managed via + /// filter rules, not direct edits). Jellyfin has no equivalent concept. + bool get _isReadOnly => widget.playlist.smart; + @override List getAppBarActions() { final isVideoPlaylist = widget.playlist.playlistType == 'video'; - final globalKey = _playlistGlobalKey(); + final ruleKey = _playlistSyncRuleKey(); // Select the specific bool we care about so unrelated DownloadProvider // ticks (e.g. active download progress) don't rebuild the app bar. - final hasRule = isVideoPlaylist && context.select((p) => p.hasSyncRule(globalKey)); + final hasRule = isVideoPlaylist && context.select((p) => p.hasSyncRule(ruleKey)); return [ if (items.isNotEmpty) ...[ @@ -83,6 +86,10 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen PlexMetadata( - ratingKey: widget.playlist.ratingKey, - type: ContentTypes.playlist, + /// Synthesise a [MediaItem] view of the current playlist for the + /// download_utils helpers. + MediaItem _playlistAsMetadata() => MediaItem( + id: widget.playlist.id, + backend: widget.playlist.backend, + kind: MediaKind.playlist, title: widget.playlist.title, - thumb: widget.playlist.thumb, - serverId: widget.playlist.serverId ?? client.serverId, + thumbPath: widget.playlist.thumbPath, + serverId: widget.playlist.serverId ?? mediaClient.serverId, serverName: widget.playlist.serverName, ); - String _playlistGlobalKey() => buildGlobalKey(widget.playlist.serverId ?? client.serverId, widget.playlist.ratingKey); + String _playlistSyncRuleKey() { + final serverId = widget.playlist.serverId ?? mediaClient.serverId; + return context.read().syncRuleKeyForClient(mediaClient, widget.playlist.id, serverId: serverId); + } Future _managePlaylistSyncRule() => - manageSyncRule(context, downloadProvider: context.read(), globalKey: _playlistGlobalKey()); + manageSyncRule(context, downloadProvider: context.read(), globalKey: _playlistSyncRuleKey()); Future _removePlaylistSyncRule() => removeSyncRuleAndSnack( context, downloadProvider: context.read(), - globalKey: _playlistGlobalKey(), + globalKey: _playlistSyncRuleKey(), displayTitle: widget.playlist.title, ); @@ -124,7 +137,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen? _originalOrder; + List? _originalOrder; // Estimated item height for scroll-into-view (card + vertical margins) static const double _estimatedItemHeight = 114.0; @@ -137,8 +150,8 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen> fetchItems() async { - return await client.fetchAllPlaylistItems(widget.playlist.ratingKey); + Future> fetchItems() async { + return fetchAllPlaylistItems(mediaClient, widget.playlist.id); } @override @@ -155,7 +168,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _downloadPlaylist() async { final downloadProvider = Provider.of(context, listen: false); @@ -198,7 +206,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _onReorder(int oldIndex, int newIndex) async { @@ -260,20 +267,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _persistMoveToServer(int originalIndex, int newIndex) async { - // Item is already at newIndex in the list final movedItem = items[newIndex]; - // Check if item has playlistItemID (required for reordering) - if (movedItem.playlistItemID == null) { - appLogger.e('Cannot persist move: item missing playlistItemID'); - if (mounted) { - showErrorSnackBar(context, t.playlists.errorReordering); - _revertMove(newIndex, originalIndex); - } - return; + appLogger.d('Persisting move from $originalIndex to $newIndex'); + + bool success = false; + try { + success = await mediaClient.movePlaylistItem( + playlistId: widget.playlist.id, + item: movedItem, + newIndex: newIndex, + afterItem: _afterItemForIndex(newIndex), + ); + } catch (e) { + appLogger.e('Failed to persist move', error: e); } - // Determine the "after" item ID based on where the item is now - final afterPlaylistItemId = _getAfterPlaylistItemId(newIndex, showError: false); - if (afterPlaylistItemId == null) { - if (mounted) { - showErrorSnackBar(context, t.playlists.errorReordering); - _revertMove(newIndex, originalIndex); - } - return; - } - - appLogger.d('Persisting move from $originalIndex to $newIndex (after ID: $afterPlaylistItemId)'); - - // Call API to persist the change (UI is already updated) - final success = await client.movePlaylistItem( - playlistId: widget.playlist.ratingKey, - playlistItemId: movedItem.playlistItemID!, - afterPlaylistItemId: afterPlaylistItemId, - ); - if (!success) { // Revert on failure appLogger.e('Failed to persist move, reverting UI'); @@ -360,16 +343,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen= items.length) return; final item = items[index]; - // Check if item has playlistItemID (required for removal) - if (item.playlistItemID == null) { - appLogger.e('Cannot remove: item missing playlistItemID'); - if (mounted) { - showErrorSnackBar(context, t.playlists.errorRemoving); - } - return; - } - - appLogger.d('Removing item ${item.title} (playlistItemID: ${item.playlistItemID}) from playlist'); + appLogger.d('Removing item ${item.title} from playlist'); // Optimistically update UI setState(() { @@ -382,11 +356,12 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _playFromItem(int index) async { if (items.isEmpty || index < 0 || index >= items.length) return; - final plexClient = _getClientForPlaylist(); final selectedItem = items[index]; - - final launcher = PlayQueueLauncher( - context: context, - client: plexClient, - serverId: widget.playlist.serverId, - serverName: widget.playlist.serverName, - ); - - await launcher.launchFromPlaylistItem( - playlist: widget.playlist, - selectedItem: selectedItem, + final launcher = MediaListPlaybackLauncher.forItem(context, widget.playlist); + await launcher.launchFromCollectionOrPlaylist( + item: widget.playlist, + shuffle: false, + startItem: selectedItem, showLoadingIndicator: true, ); } @@ -528,7 +496,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _focusedColumn = 1); return KeyEventResult.handled; @@ -540,7 +508,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _focusedColumn = 2); return KeyEventResult.handled; @@ -554,7 +522,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen 'p:$playlistItemId', + JellyfinMediaItem(:final playlistItemId?) => 'j:$playlistItemId', + _ => item.id, + }; return RepaintBoundary( - key: ValueKey(item.playlistItemID ?? item.ratingKey), + key: ValueKey(keyId), child: PlaylistItemCard( item: item, index: index, onRemove: () => _removeItem(index), onTap: () => _playFromItem(index), onRefresh: updateItem, - canReorder: !widget.playlist.smart, + canReorder: !_isReadOnly, isFocused: isFocused, focusedColumn: isFocused ? _focusedColumn : null, isMoving: isMoving, diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 01c3d59e..0389e009 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -1,24 +1,24 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; import '../../mixins/context_menu_tap_mixin.dart'; -import '../../services/plex_client.dart'; -import '../../models/plex_metadata.dart'; import '../../utils/formatters.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; import '../../widgets/media_context_menu.dart'; import '../../widgets/media_progress_bar.dart'; -import '../../widgets/plex_optimized_image.dart'; +import '../../widgets/optimized_media_image.dart'; /// Custom list item widget for playlist items /// Shows drag handle, poster, title/metadata, duration, and remove button class PlaylistItemCard extends StatefulWidget { - final PlexMetadata item; + final MediaItem item; final int index; final VoidCallback onRemove; final VoidCallback? onTap; - final void Function(String ratingKey)? onRefresh; + final void Function(String itemId)? onRefresh; final bool canReorder; // Whether drag handle should be shown // Focus state for keyboard/D-pad navigation @@ -149,12 +149,12 @@ class _PlaylistItemCardState extends State with ContextMenuTap ), // Progress indicator if partially watched - if (widget.item.viewOffset != null && widget.item.duration != null) + if (widget.item.viewOffsetMs != null && widget.item.durationMs != null) Padding( padding: const EdgeInsets.only(top: 6), child: MediaProgressBar( - viewOffset: widget.item.viewOffset!, - duration: widget.item.duration!, + viewOffset: widget.item.viewOffsetMs!, + duration: widget.item.durationMs!, minHeight: 3, ), ), @@ -165,9 +165,9 @@ class _PlaylistItemCardState extends State with ContextMenuTap const SizedBox(width: 12), // Duration - if (widget.item.duration != null) + if (widget.item.durationMs != null) Text( - formatDurationTextual(widget.item.duration!), + formatDurationTextual(widget.item.durationMs!), style: TextStyle(fontSize: 13, color: Colors.grey[400]), ), @@ -196,17 +196,14 @@ class _PlaylistItemCardState extends State with ContextMenuTap ); } - /// Get the correct PlexClient for this item's server - PlexClient _getClientForItem(BuildContext context) { - return context.getClientForServer(widget.item.serverId!); - } - Widget _buildPosterImage(BuildContext context) { final posterUrl = widget.item.posterThumb(); return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(6)), - child: PlexOptimizedImage.poster( - client: _getClientForItem(context), + child: OptimizedMediaImage.poster( + // Backend-neutral lookup so Jellyfin items render via their own + // image transcoder; null falls through to the placeholder below. + client: context.tryGetMediaClientWithFallback(widget.item.serverId), imagePath: posterUrl, width: 60, height: 90, @@ -227,9 +224,9 @@ class _PlaylistItemCardState extends State with ContextMenuTap } String _buildSubtitle() { - final itemType = widget.item.mediaType; + final kind = widget.item.kind; - if (itemType == PlexMediaType.episode) { + if (kind == MediaKind.episode) { // For episodes, show "S#E# - Episode Title" final season = widget.item.parentIndex; final episode = widget.item.index; @@ -237,16 +234,17 @@ class _PlaylistItemCardState extends State with ContextMenuTap return 'S${season}E$episode${widget.item.displaySubtitle != null ? ' - ${widget.item.displaySubtitle}' : ''}'; } return widget.item.displaySubtitle ?? t.discover.tvShow; - } else if (itemType == PlexMediaType.movie) { - // For movies, show year and edition + } else if (kind == MediaKind.movie) { + // For movies, show year and edition (edition is Plex-only; null elsewhere) final year = widget.item.year?.toString(); - if (year != null && widget.item.editionTitle != null) { - return '$year · ${widget.item.editionTitle}'; + final edition = widget.item.editionTitle; + if (year != null && edition != null) { + return '$year · $edition'; } return year ?? t.discover.movie; } // Default to type - return widget.item.mediaType.name; + return kind.name; } } diff --git a/lib/screens/match_screen.dart b/lib/screens/plex_match_screen.dart similarity index 82% rename from lib/screens/match_screen.dart rename to lib/screens/plex_match_screen.dart index 5bc325a2..9095ddf4 100644 --- a/lib/screens/match_screen.dart +++ b/lib/screens/plex_match_screen.dart @@ -6,30 +6,34 @@ import '../focus/focusable_button.dart'; import '../focus/focusable_text_field.dart'; import '../focus/input_mode_tracker.dart'; import '../i18n/strings.g.dart'; -import '../models/plex_match_result.dart'; -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; +import '../models/plex/plex_match_result.dart'; import '../services/plex_client.dart'; +import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/app_icon.dart'; import '../widgets/focusable_list_tile.dart'; import '../widgets/focused_scroll_scaffold.dart'; import '../widgets/pill_input_decoration.dart'; -import '../widgets/plex_optimized_image.dart'; +import '../widgets/optimized_media_image.dart'; +import '../widgets/loading_indicator_box.dart'; -/// Fix / apply a metadata match on a movie or show. +/// Fix / apply a metadata match on a movie or show. Plex-only feature; the +/// underlying [PlexClient.findMatches]/[PlexClient.applyMatch] endpoints +/// are not part of the neutral [MediaServerClient] surface. /// /// On success, pops `true` so the caller can refresh its view. -class MatchScreen extends StatefulWidget { - final PlexMetadata metadata; +class PlexMatchScreen extends StatefulWidget { + final MediaItem metadata; - const MatchScreen({super.key, required this.metadata}); + const PlexMatchScreen({super.key, required this.metadata}); @override - State createState() => _MatchScreenState(); + State createState() => _PlexMatchScreenState(); } -class _MatchScreenState extends State { +class _PlexMatchScreenState extends State { late final PlexClient _client; late final TextEditingController _nameController; late final TextEditingController _yearController; @@ -43,10 +47,17 @@ class _MatchScreenState extends State { bool get _isApplying => _applyingGuid != null; + /// Treat the item as unmatched if its [MediaItem.guid] is missing or + /// references the Plex no-agent marker. + bool get _isUnmatched { + final guid = widget.metadata.guid; + return guid == null || guid.isEmpty || guid.contains('agents.none://'); + } + @override void initState() { super.initState(); - _client = context.getClientWithFallback(widget.metadata.serverId); + _client = context.getPlexClientWithFallback(widget.metadata.serverId); _nameController = TextEditingController(text: widget.metadata.title); _yearController = TextEditingController(text: widget.metadata.year?.toString() ?? ''); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -72,7 +83,7 @@ class _MatchScreenState extends State { if (_isSearching) return; setState(() => _isSearching = true); final results = await _client.findMatches( - widget.metadata.ratingKey, + widget.metadata.id, title: _nameController.text.trim(), year: _yearController.text.trim(), ); @@ -86,12 +97,19 @@ class _MatchScreenState extends State { Future _applyMatch(PlexMatchResult result) async { if (_isApplying) return; setState(() => _applyingGuid = result.guid); - final success = await _client.applyMatch( - widget.metadata.ratingKey, - guid: result.guid, - name: result.name, - year: result.year?.toString(), - ); + bool success = false; + try { + success = await _client.applyMatch( + widget.metadata.id, + guid: result.guid, + name: result.name, + year: result.year?.toString(), + ); + } catch (e, st) { + // [PlexClient._wrapBoolApiCall] rethrows — catch here so + // `_applyingGuid` doesn't get stuck non-null. + appLogger.e('Failed to apply match', error: e, stackTrace: st); + } if (!mounted) return; setState(() => _applyingGuid = null); if (success) { @@ -105,7 +123,7 @@ class _MatchScreenState extends State { @override Widget build(BuildContext context) { return FocusedScrollScaffold( - title: Text(widget.metadata.isUnmatched ? t.matchScreen.match : t.matchScreen.fixMatch), + title: Text(_isUnmatched ? t.matchScreen.match : t.matchScreen.fixMatch), slivers: [ SliverPadding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), @@ -208,7 +226,7 @@ class _MatchScreenState extends State { height: 72, child: ClipRRect( borderRadius: BorderRadius.circular(4), - child: PlexOptimizedImage( + child: OptimizedMediaImage( client: _client, imagePath: result.thumb, fit: BoxFit.cover, @@ -221,7 +239,7 @@ class _MatchScreenState extends State { ? Text(result.summary!, maxLines: 2, overflow: TextOverflow.ellipsis) : null, trailing: isApplyingThis - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + ? const LoadingIndicatorBox(size: 24) : result.score != null ? _ScoreChip(score: result.score!) : null, diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/plex_metadata_edit_screen.dart similarity index 76% rename from lib/screens/metadata_edit_screen.dart rename to lib/screens/plex_metadata_edit_screen.dart index ff3162eb..1218b648 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/plex_metadata_edit_screen.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../widgets/dialog_action_button.dart'; import '../i18n/strings.g.dart'; -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; import '../services/plex_client.dart'; +import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../utils/language_codes.dart'; import '../utils/provider_extensions.dart'; @@ -12,21 +14,44 @@ import '../widgets/app_icon.dart'; import '../widgets/artwork_picker_dialog.dart'; import '../widgets/focusable_list_tile.dart'; import '../widgets/focused_scroll_scaffold.dart'; -import '../widgets/plex_optimized_image.dart'; +import '../widgets/optimized_media_image.dart'; import '../widgets/tag_edit_dialog.dart'; +import '../widgets/loading_indicator_box.dart'; -class MetadataEditScreen extends StatefulWidget { - final PlexMetadata metadata; +/// Plex `type` number used by `/library/sections/{id}/all` PUT — required by +/// [PlexClient.updateMetadata]. Mirrors the legacy `PlexMediaType.typeNumber` +/// helper so the migrated [PlexMetadataEditScreen] (which now operates on +/// [MediaItem]) can still talk to the Plex update endpoint. +int _plexTypeNumberForKind(MediaKind kind) => switch (kind) { + MediaKind.movie => 1, + MediaKind.show => 2, + MediaKind.season => 3, + MediaKind.episode => 4, + MediaKind.artist => 8, + MediaKind.album => 9, + MediaKind.track => 10, + _ => 0, +}; - const MetadataEditScreen({super.key, required this.metadata}); +/// Plex-only metadata editor. Calls Plex-specific PUT endpoints; the Jellyfin +/// backend has no analogous surface yet. +class PlexMetadataEditScreen extends StatefulWidget { + final MediaItem metadata; + + const PlexMetadataEditScreen({super.key, required this.metadata}); @override - State createState() => _MetadataEditScreenState(); + State createState() => _PlexMetadataEditScreenState(); } -class _MetadataEditScreenState extends State { +class _PlexMetadataEditScreenState extends State { late PlexClient _client; - PlexMetadata? _fullMetadata; + + /// Full neutral metadata reloaded after save / artwork picker. Metadata + /// editing uses Plex-only update endpoints (Jellyfin has no equivalent in + /// the current scope), so the in-memory model is [MediaItem] but the + /// boundary call to [PlexClient.updateMetadata] is Plex-only. + MediaItem? _fullMetadata; bool _isLoading = true; bool _isSaving = false; @@ -72,12 +97,16 @@ class _MetadataEditScreenState extends State { _summary != _origSummary || _hasTagChanges; - PlexMediaType get _mediaType => widget.metadata.mediaType; + MediaKind get _mediaType => widget.metadata.kind; + + /// Library section id required by the Plex update endpoint. Plex stores it + /// as an int; [MediaItem.libraryId] preserves it as a string. + int? get _librarySectionId => int.tryParse(_fullMetadata?.libraryId ?? widget.metadata.libraryId ?? ''); @override void initState() { super.initState(); - _client = context.getClientWithFallback(widget.metadata.serverId); + _client = context.getPlexClientWithFallback(widget.metadata.serverId); _loadMetadata(); } @@ -85,15 +114,15 @@ class _MetadataEditScreenState extends State { try { // If the passed metadata already has full fields (e.g., from detail screen), // use it directly instead of re-fetching. We check both summary and - // librarySectionID since the edit screen needs both for display and save. - if (widget.metadata.summary != null && widget.metadata.librarySectionID != null) { + // libraryId since the edit screen needs both for display and save. + if (widget.metadata.summary != null && widget.metadata.libraryId != null) { _fullMetadata = widget.metadata; _initFieldsFromMetadata(widget.metadata); setState(() => _isLoading = false); return; } - final meta = await _client.getMetadataWithImages(widget.metadata.ratingKey); + final meta = await _client.fetchItem(widget.metadata.id); if (!mounted) return; if (meta != null) { _fullMetadata = meta; @@ -109,7 +138,7 @@ class _MetadataEditScreenState extends State { } } - void _initFieldsFromMetadata(PlexMetadata meta) { + void _initFieldsFromMetadata(MediaItem meta) { _title = meta.title; _titleSort = meta.titleSort ?? ''; _originalTitle = meta.originalTitle ?? ''; @@ -133,21 +162,21 @@ class _MetadataEditScreenState extends State { _origTags[key] = List.of(values ?? []); } - initTag('genre', meta.genre); - initTag('director', meta.director); - initTag('writer', meta.writer); - initTag('producer', meta.producer); - initTag('country', meta.country); - initTag('collection', meta.collection); - initTag('label', meta.label); - initTag('style', meta.style); - initTag('mood', meta.mood); + initTag('genre', meta.genres); + initTag('director', meta.directors); + initTag('writer', meta.writers); + initTag('producer', meta.producers); + initTag('country', meta.countries); + initTag('collection', meta.collections); + initTag('label', meta.labels); + initTag('style', meta.styles); + initTag('mood', meta.moods); } Future _save() async { if (!_hasChanges || _isSaving) return; - final sectionId = _fullMetadata?.librarySectionID ?? widget.metadata.librarySectionID; + final sectionId = _librarySectionId; if (sectionId == null) { if (mounted) showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); return; @@ -165,20 +194,27 @@ class _MetadataEditScreenState extends State { } } - final success = await _client.updateMetadata( - sectionId: sectionId, - ratingKey: widget.metadata.ratingKey, - typeNumber: _mediaType.typeNumber, - title: _title != _origTitle ? _title : null, - titleSort: _titleSort != _origTitleSort ? _titleSort : null, - originalTitle: _originalTitle != _origOriginalTitle ? _originalTitle : null, - originallyAvailableAt: _originallyAvailableAt != _origOriginallyAvailableAt ? _originallyAvailableAt : null, - contentRating: _contentRating != _origContentRating ? _contentRating : null, - studio: _studio != _origStudio ? _studio : null, - tagline: _tagline != _origTagline ? _tagline : null, - summary: _summary != _origSummary ? _summary : null, - tagChanges: tagChanges, - ); + bool success = false; + try { + success = await _client.updateMetadata( + sectionId: sectionId, + ratingKey: widget.metadata.id, + typeNumber: _plexTypeNumberForKind(_mediaType), + title: _title != _origTitle ? _title : null, + titleSort: _titleSort != _origTitleSort ? _titleSort : null, + originalTitle: _originalTitle != _origOriginalTitle ? _originalTitle : null, + originallyAvailableAt: _originallyAvailableAt != _origOriginallyAvailableAt ? _originallyAvailableAt : null, + contentRating: _contentRating != _origContentRating ? _contentRating : null, + studio: _studio != _origStudio ? _studio : null, + tagline: _tagline != _origTagline ? _tagline : null, + summary: _summary != _origSummary ? _summary : null, + tagChanges: tagChanges, + ); + } catch (e, st) { + // [PlexClient._wrapBoolApiCall] rethrows on HTTP/network errors — + // catch here so `_isSaving` doesn't get stuck `true`. + appLogger.e('Failed to update metadata', error: e, stackTrace: st); + } if (!mounted) return; setState(() => _isSaving = false); @@ -242,8 +278,7 @@ class _MetadataEditScreenState extends State { Future _openArtworkPicker(String element) async { final result = await showDialog( context: context, - builder: (context) => - ArtworkPickerDialog(client: _client, ratingKey: widget.metadata.ratingKey, element: element), + builder: (context) => ArtworkPickerDialog(client: _client, ratingKey: widget.metadata.id, element: element), ); if (result == true && mounted) { @@ -255,7 +290,7 @@ class _MetadataEditScreenState extends State { Future _reloadArtwork() async { try { - final meta = await _client.getMetadataWithImages(widget.metadata.ratingKey); + final meta = await _client.fetchItem(widget.metadata.id); if (!mounted) return; if (meta != null) { setState(() => _fullMetadata = meta); @@ -306,22 +341,48 @@ class _MetadataEditScreenState extends State { ); if (result != null && mounted) { + final previous = _currentPrefs[prefKey]; setState(() => _currentPrefs[prefKey] = result); - await _client.updateMetadataPrefs(widget.metadata.ratingKey, {prefKey: result}); + try { + await _client.updateMetadataPrefs(widget.metadata.id, {prefKey: result}); + } catch (e, st) { + // [PlexClient._wrapBoolApiCall] rethrows — revert the optimistic + // UI change and surface a snackbar so the radio doesn't lie. + appLogger.e('Failed to update metadata prefs', error: e, stackTrace: st); + if (!mounted) return; + setState(() { + if (previous == null) { + _currentPrefs.remove(prefKey); + } else { + _currentPrefs[prefKey] = previous; + } + }); + showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); + } } } String _getMetadataPrefValue(String key) { // These prefs appear as keys on the raw metadata JSON when non-default. - // Since we use typed models, we check known fields. - final meta = _fullMetadata ?? widget.metadata; + // Since we use typed models, we check known fields. Falls back to the + // public [MediaItem] when the Plex-typed cache hasn't loaded yet. + // + // [subtitleLanguage] / [subtitleMode] live on [PlexMediaItem] (Plex-only + // — Jellyfin has no per-item subtitle preference). This screen is + // documented as Plex-only at the class level, so the cast is safe; on + // the off chance a Jellyfin item slips through, fall back to the + // unset/default string. + final fullMeta = _fullMetadata; + final fullPlex = fullMeta is PlexMediaItem ? fullMeta : null; + final widgetMeta = widget.metadata; + final widgetPlex = widgetMeta is PlexMediaItem ? widgetMeta : null; switch (key) { case 'audioLanguage': - return meta.audioLanguage ?? ''; + return fullMeta?.audioLanguage ?? widgetMeta.audioLanguage ?? ''; case 'subtitleLanguage': - return meta.subtitleLanguage ?? ''; + return fullPlex?.subtitleLanguage ?? widgetPlex?.subtitleLanguage ?? ''; case 'subtitleMode': - return meta.subtitleMode?.toString() ?? '-1'; + return (fullPlex?.subtitleMode ?? widgetPlex?.subtitleMode)?.toString() ?? '-1'; default: return ''; } @@ -337,22 +398,22 @@ class _MetadataEditScreenState extends State { // ===== Field visibility ===== - bool get _showSortTitle => _mediaType != PlexMediaType.season; - bool get _showOriginalTitle => _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show; - bool get _showReleaseDate => _mediaType != PlexMediaType.season; - bool get _showContentRating => _mediaType != PlexMediaType.season; - bool get _showStudio => _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show; - bool get _showTagline => _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show; + bool get _showSortTitle => _mediaType != MediaKind.season; + bool get _showOriginalTitle => _mediaType == MediaKind.movie || _mediaType == MediaKind.show; + bool get _showReleaseDate => _mediaType != MediaKind.season; + bool get _showContentRating => _mediaType != MediaKind.season; + bool get _showStudio => _mediaType == MediaKind.movie || _mediaType == MediaKind.show; + bool get _showTagline => _mediaType == MediaKind.movie || _mediaType == MediaKind.show; bool get _showBackground => - _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show || _mediaType == PlexMediaType.episode; + _mediaType == MediaKind.movie || _mediaType == MediaKind.show || _mediaType == MediaKind.episode; bool get _showExtendedArtwork => - _mediaType == PlexMediaType.movie || _mediaType == PlexMediaType.show || _mediaType == PlexMediaType.collection; - bool get _showAdvanced => _mediaType != PlexMediaType.episode; + _mediaType == MediaKind.movie || _mediaType == MediaKind.show || _mediaType == MediaKind.collection; + bool get _showAdvanced => _mediaType != MediaKind.episode; List<({String key, String label})> get _tagFields { switch (_mediaType) { - case PlexMediaType.movie: - case PlexMediaType.show: + case MediaKind.movie: + case MediaKind.show: return [ (key: 'genre', label: t.metadataEdit.genre), (key: 'director', label: t.metadataEdit.director), @@ -362,9 +423,9 @@ class _MetadataEditScreenState extends State { (key: 'collection', label: t.metadataEdit.collection), (key: 'label', label: t.metadataEdit.label), ]; - case PlexMediaType.episode: + case MediaKind.episode: return [(key: 'director', label: t.metadataEdit.director), (key: 'writer', label: t.metadataEdit.writer)]; - case PlexMediaType.artist: + case MediaKind.artist: return [ (key: 'genre', label: t.metadataEdit.genre), (key: 'style', label: t.metadataEdit.style), @@ -372,7 +433,7 @@ class _MetadataEditScreenState extends State { (key: 'country', label: t.metadataEdit.country), (key: 'collection', label: t.metadataEdit.collection), ]; - case PlexMediaType.album: + case MediaKind.album: return [ (key: 'genre', label: t.metadataEdit.genre), (key: 'style', label: t.metadataEdit.style), @@ -407,10 +468,7 @@ class _MetadataEditScreenState extends State { title: Text(t.metadataEdit.screenTitle), actions: [ if (_isSaving) - const Padding( - padding: EdgeInsets.all(12), - child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)), - ) + const Padding(padding: EdgeInsets.all(12), child: LoadingIndicatorBox(size: 24)) else IconButton(onPressed: _hasChanges ? _save : null, icon: const AppIcon(Symbols.check_rounded, fill: 1)), ], @@ -583,7 +641,7 @@ class _MetadataEditScreenState extends State { height: height, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), - child: PlexOptimizedImage(client: _client, imagePath: imagePath, width: width, height: height, fit: fit), + child: OptimizedMediaImage(client: _client, imagePath: imagePath, width: width, height: height, fit: fit), ), ), title: Text(label), @@ -593,7 +651,13 @@ class _MetadataEditScreenState extends State { } Widget _buildArtworkCard() { - final meta = _fullMetadata ?? widget.metadata; + // Prefer the freshly fetched metadata for image paths, falling back to + // the public [MediaItem] before the fetch resolves. + final fullMeta = _fullMetadata; + final thumb = fullMeta?.thumbPath ?? widget.metadata.thumbPath; + final art = fullMeta?.artPath ?? widget.metadata.artPath; + final clearLogo = fullMeta?.clearLogoPath ?? widget.metadata.clearLogoPath; + final backgroundSquare = fullMeta?.backgroundSquarePath ?? widget.metadata.backgroundSquarePath; return Card( child: Column( @@ -606,26 +670,14 @@ class _MetadataEditScreenState extends State { style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), - _buildArtworkTile( - width: 40, - height: 60, - imagePath: meta.thumb, - label: t.metadataEdit.poster, - element: 'posters', - ), + _buildArtworkTile(width: 40, height: 60, imagePath: thumb, label: t.metadataEdit.poster, element: 'posters'), if (_showBackground) - _buildArtworkTile( - width: 80, - height: 45, - imagePath: meta.art, - label: t.metadataEdit.background, - element: 'arts', - ), + _buildArtworkTile(width: 80, height: 45, imagePath: art, label: t.metadataEdit.background, element: 'arts'), if (_showExtendedArtwork) _buildArtworkTile( width: 80, height: 32, - imagePath: meta.clearLogo, + imagePath: clearLogo, label: t.metadataEdit.logo, element: 'clearLogos', fit: BoxFit.contain, @@ -634,7 +686,7 @@ class _MetadataEditScreenState extends State { _buildArtworkTile( width: 50, height: 50, - imagePath: meta.backgroundSquare, + imagePath: backgroundSquare, label: t.metadataEdit.squareArt, element: 'squareArts', ), @@ -655,9 +707,9 @@ class _MetadataEditScreenState extends State { style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), - if (_mediaType == PlexMediaType.show) ..._buildShowAdvancedSettings(), - if (_mediaType == PlexMediaType.movie) ..._buildMovieAdvancedSettings(), - if (_mediaType == PlexMediaType.season) ..._buildSeasonAdvancedSettings(), + if (_mediaType == MediaKind.show) ..._buildShowAdvancedSettings(), + if (_mediaType == MediaKind.movie) ..._buildMovieAdvancedSettings(), + if (_mediaType == MediaKind.season) ..._buildSeasonAdvancedSettings(), ], ), ); diff --git a/lib/screens/profile/add_local_profile_screen.dart b/lib/screens/profile/add_local_profile_screen.dart new file mode 100644 index 00000000..4795b3a3 --- /dev/null +++ b/lib/screens/profile/add_local_profile_screen.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; + +import '../../i18n/strings.g.dart'; +import '../../profiles/profile.dart'; +import '../../profiles/profile_registry.dart'; +import '../../utils/snackbar_helper.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/desktop_app_bar.dart'; +import '../settings/add_connection_screen.dart'; +import 'pin_entry_dialog.dart'; +import 'pin_status_row.dart'; +import 'profile_name_field.dart'; + +/// Create a local "Plezy" profile — name + optional 4-digit PIN. +/// +/// On save, routes into [AddConnectionScreen] so the user can either add +/// a brand-new Plex/Jellyfin connection to this profile or borrow one from +/// an existing profile. Profiles with zero connections are stored but +/// blocked from activation. +class AddLocalProfileScreen extends StatefulWidget { + const AddLocalProfileScreen({super.key}); + + @override + State createState() => _AddLocalProfileScreenState(); +} + +class _AddLocalProfileScreenState extends State { + late final TextEditingController _nameController; + String? _pinHash; + bool _saving = false; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _setPin() async { + final pin = await captureAndConfirmPin( + context, + onMismatch: (ctx) => showErrorSnackBar(ctx, t.profiles.pinsDontMatch), + ); + if (pin == null || !mounted) return; + setState(() => _pinHash = computePinHash(pin)); + } + + void _clearPin() => setState(() => _pinHash = null); + + Future _saveAndContinue() async { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + setState(() => _saving = true); + + final registry = context.read(); + final profile = Profile( + id: 'local-${const Uuid().v4()}', + kind: ProfileKind.local, + displayName: name, + pinHash: _pinHash, + sortOrder: DateTime.now().millisecondsSinceEpoch, + createdAt: DateTime.now(), + ); + await registry.upsert(profile); + + if (!mounted) return; + // Drop the user into the connection picker so they end up with at least + // one connection. The picker offers both new sign-ins and borrowing from + // existing profiles — empty borrow lists no longer trap the user. + final navigator = Navigator.of(context); + await navigator.push(MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: profile))); + if (!mounted) return; + navigator.pop(true); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + body: CustomScrollView( + slivers: [ + ExcludeFocus(child: CustomAppBar(title: Text(t.profiles.newProfile), pinned: true)), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + ProfileNameField( + controller: _nameController, + hintText: t.profiles.profileNameHint, + onChanged: () => setState(() {}), + ), + const SizedBox(height: 24), + Text(t.profiles.pinProtectionOptional, style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + Text( + t.profiles.pinExplain, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 12), + if (_pinHash == null) + OutlinedButton.icon( + onPressed: _setPin, + icon: const AppIcon(Symbols.lock_outline_rounded, fill: 1), + label: Text(t.profiles.setPin), + ) + else + PinStatusRow(onChange: _setPin, onRemove: _clearPin), + const SizedBox(height: 32), + FilledButton( + onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue, + child: Text(t.profiles.continueButton), + ), + const SizedBox(height: 8), + TextButton(onPressed: _saving ? null : () => Navigator.of(context).pop(), child: Text(t.common.cancel)), + ]), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/profile/borrow_connection_screen.dart b/lib/screens/profile/borrow_connection_screen.dart new file mode 100644 index 00000000..d9998861 --- /dev/null +++ b/lib/screens/profile/borrow_connection_screen.dart @@ -0,0 +1,425 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../connection/connection.dart'; +import '../../connection/connection_registry.dart'; +import '../../focus/focusable_wrapper.dart'; +import '../../i18n/strings.g.dart'; +import '../../profiles/active_profile_binder.dart'; +import '../../profiles/plex_home_service.dart'; +import '../../profiles/plex_home_switch.dart'; +import '../../profiles/profile.dart'; +import '../../profiles/profile_activation.dart'; +import '../../profiles/profile_connection.dart'; +import '../../profiles/profile_connection_registry.dart'; +import '../../profiles/profile_merge.dart'; +import '../../profiles/profile_registry.dart'; +import '../../services/plex_auth_service.dart'; +import '../../services/storage_service.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/snackbar_helper.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/backend_badge.dart'; +import '../../widgets/desktop_app_bar.dart'; +import '../libraries/state_messages.dart'; +import 'pin_entry_dialog.dart'; + +/// Pick a connection from another profile and attach it to [targetProfile] +/// as an independent copy. +/// +/// For each candidate (sourceProfile, profileConnection): +/// 1. If the source profile is PIN-protected (local kind), prompt for its +/// PIN and verify locally before revealing the borrow action. +/// 2. For Plex sources: call `/home/users/{uuid}/switch` from the parent +/// account token with the source's `userIdentifier` and (if the target +/// Home user is `protected`) the Home PIN. The borrower gets its own +/// fresh user-token. +/// 3. For Jellyfin sources: copy the existing `userToken` (one user per +/// Jellyfin connection). +class BorrowConnectionScreen extends StatefulWidget { + final Profile targetProfile; + final bool popOnSuccess; + + const BorrowConnectionScreen({super.key, required this.targetProfile, this.popOnSuccess = false}); + + @override + State createState() => _BorrowConnectionScreenState(); +} + +class _BorrowConnectionScreenState extends State { + late Future> _candidatesFuture; + bool _busy = false; + + @override + void initState() { + super.initState(); + _candidatesFuture = _loadCandidates(); + } + + Future> _loadCandidates() async { + final pcRegistry = context.read(); + final connRegistry = context.read(); + final profileRegistry = context.read(); + final plexHome = context.read(); + + await plexHome.start(); + final results = await Future.wait([ + pcRegistry.listAll(), + connRegistry.list(), + profileRegistry.list(), + StorageService.getInstance(), + ]); + final allPcs = results[0] as List; + final allConns = results[1] as List; + final localProfiles = results[2] as List; + final storage = results[3] as StorageService; + final connById = {for (final c in allConns) c.id: c}; + final allProfiles = mergeLocalWithPlexHome( + locals: localProfiles, + plexHomeByConnectionId: plexHome.current, + connectionsById: connById, + storage: storage, + ); + + // What does the target already have? Skip duplicates by connection id. + final targetConnIds = allPcs + .where((pc) => pc.profileId == widget.targetProfile.id) + .map((pc) => pc.connectionId) + .toSet(); + // Plex Home profiles also implicitly own their parent Plex connection. + if (widget.targetProfile.parentConnectionId != null) { + targetConnIds.add(widget.targetProfile.parentConnectionId!); + } + + final out = <_BorrowCandidate>[]; + final seen = {}; + + // Dedup by the *thing being borrowed* — (connection, user) — not the + // source profile. If two profiles already borrowed the same Home user, + // the borrow operation is identical regardless of which one the picker + // points at; showing both is just clutter. + String key(String connId, String userId) => '$connId/$userId'; + + // Pass 1: virtual Plex Home profiles (auto-surfaced from PlexHomeService) + // come first so they win as the canonical source for a given Home user. + // Their parent Plex connection isn't represented as a join row, so + // synthesize a ProfileConnection on the fly with userToken=null — the + // borrow flow re-mints the token via `/home/users/{uuid}/switch` from + // the parent account anyway, so the placeholder never gets persisted. + for (final source in allProfiles) { + if (source.id == widget.targetProfile.id) continue; + if (!source.isPlexHome) continue; + final parentId = source.parentConnectionId; + final homeUuid = source.plexHomeUserUuid; + if (parentId == null || homeUuid == null) continue; + if (targetConnIds.contains(parentId)) continue; + final conn = connById[parentId]; + if (conn == null) continue; + if (!seen.add(key(conn.id, homeUuid))) continue; + out.add( + _BorrowCandidate( + source: source, + pc: ProfileConnection( + profileId: source.id, + connectionId: parentId, + userToken: null, + userIdentifier: homeUuid, + ), + connection: conn, + ), + ); + } + + // Pass 2: persisted ProfileConnection rows from any other profile + // (local profiles' own connections + already-borrowed rows on plex_home + // profiles). Skipped when (conn, user) already surfaced via pass 1. + for (final pc in allPcs) { + if (pc.profileId == widget.targetProfile.id) continue; + if (targetConnIds.contains(pc.connectionId)) continue; + final conn = connById[pc.connectionId]; + if (conn == null) continue; + final source = allProfiles.firstWhere( + (p) => p.id == pc.profileId, + orElse: () => widget.targetProfile, // sentinel — skipped below + ); + if (source.id == widget.targetProfile.id) continue; + if (!seen.add(key(conn.id, pc.userIdentifier))) continue; + out.add(_BorrowCandidate(source: source, pc: pc, connection: conn)); + } + + return out; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: FutureBuilder>( + future: _candidatesFuture, + builder: (context, snapshot) { + final candidates = snapshot.data ?? const <_BorrowCandidate>[]; + return CustomScrollView( + slivers: [ + ExcludeFocus( + child: CustomAppBar( + title: Text(t.profiles.borrowAddTo(displayName: widget.targetProfile.displayName)), + pinned: true, + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + sliver: SliverToBoxAdapter( + child: Text(t.profiles.borrowExplain, style: Theme.of(context).textTheme.bodySmall), + ), + ), + if (snapshot.connectionState != ConnectionState.done) + const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) + else if (candidates.isEmpty) + SliverFillRemaining( + child: EmptyStateWidget( + message: t.profiles.borrowEmpty, + subtitle: t.profiles.borrowEmptySubtitle, + icon: Symbols.share_rounded, + iconSize: 48, + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final cand = candidates[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: FocusableWrapper( + autofocus: index == 0, + disableScale: true, + onSelect: _busy ? null : () => _borrow(cand), + child: Card( + child: _BorrowTile(candidate: cand, onTap: () => _borrow(cand)), + ), + ), + ); + }, childCount: candidates.length), + ), + ], + ); + }, + ), + ); + } + + Future _borrow(_BorrowCandidate cand) async { + if (_busy) return; + setState(() => _busy = true); + try { + if (!await _verifySourcePin(cand)) return; + switch (cand.connection) { + case PlexAccountConnection(): + await _borrowPlex(cand); + case JellyfinConnection(): + await _borrowJellyfin(cand); + } + } finally { + if (mounted) { + setState(() { + _busy = false; + _candidatesFuture = _loadCandidates(); + }); + } + } + } + + /// Verify the source profile's PIN if it has one. Locals check the local + /// hash; Plex Home sources doing a non-Plex borrow do a + /// `/home/users/{uuid}/switch` round-trip against the parent account so + /// Plex validates the PIN server-side (the minted token is discarded — + /// we only need the validation side effect). + /// + /// Plex-source borrows of a Plex Home profile pass through + /// `switchPlexHomeUserWithPin` in [_borrowPlex] anyway (it mints the + /// target's user-token), so re-validating here would prompt twice for + /// the same PIN. Skip in that case and let the inner call handle it. + Future _verifySourcePin(_BorrowCandidate cand) async { + if (!cand.source.isPinProtected) return true; + if (cand.source.isLocal) { + final pin = await showPinEntryDialog(context, cand.source.displayName); + if (pin == null) return false; + if (!verifyProfilePin(cand.source, pin)) { + if (!mounted) return false; + showErrorSnackBar(context, 'Incorrect PIN.'); + return false; + } + return true; + } + // Plex Home source: defer to _borrowPlex's PIN prompt for Plex borrows. + if (cand.connection is PlexAccountConnection) return true; + // Other backends (Jellyfin) — validate via /switch and discard the token. + final parentId = cand.source.parentConnectionId; + final homeUuid = cand.source.plexHomeUserUuid; + if (parentId == null || homeUuid == null) return false; + final parent = await context.read().getPlexAccount(parentId); + if (parent == null) { + if (mounted) showErrorSnackBar(context, 'Source profile is missing its parent account.'); + return false; + } + final auth = await PlexAuthService.create(); + try { + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: parent.accountToken, + homeUserUuid: homeUuid, + requiresPin: true, + promptForPin: ({String? errorMessage}) async { + if (!mounted) return null; + return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); + }, + logLabel: cand.source.displayName, + ); + if (!result.succeeded) { + if (result.status == PlexHomeSwitchStatus.failed && mounted) { + showErrorSnackBar(context, 'Failed to verify PIN.'); + } + return false; + } + return true; + } finally { + auth.dispose(); + } + } + + Future _borrowPlex(_BorrowCandidate cand) async { + final pcRegistry = context.read(); + final auth = await PlexAuthService.create(); + try { + final account = cand.connection as PlexAccountConnection; + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: account.accountToken, + homeUserUuid: cand.pc.userIdentifier, + requiresPin: cand.source.plexProtected, + promptForPin: ({String? errorMessage}) async { + if (!mounted) return null; + return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); + }, + logLabel: cand.source.displayName, + ); + if (!result.succeeded) { + if (result.status == PlexHomeSwitchStatus.failed && mounted) { + showErrorSnackBar(context, 'Failed to borrow connection.'); + } + return; + } + await pcRegistry.upsert( + ProfileConnection( + profileId: widget.targetProfile.id, + connectionId: account.id, + userToken: result.userToken!, + userIdentifier: cand.pc.userIdentifier, + tokenAcquiredAt: DateTime.now(), + ), + ); + if (mounted) { + unawaited(context.read().rebindIfActive(widget.targetProfile.id)); + if (widget.popOnSuccess) { + Navigator.of(context).pop(true); + return; + } + showSuccessSnackBar(context, 'Connection borrowed.'); + } + } catch (e, st) { + appLogger.w('Borrow failed', error: e, stackTrace: st); + if (mounted) { + showErrorSnackBar(context, 'Failed to borrow connection.'); + } + } finally { + auth.dispose(); + } + } + + Future _borrowJellyfin(_BorrowCandidate cand) async { + final jelly = cand.connection as JellyfinConnection; + final pcRegistry = context.read(); + await pcRegistry.upsert( + ProfileConnection( + profileId: widget.targetProfile.id, + connectionId: jelly.id, + userToken: cand.pc.hasToken ? cand.pc.userToken : jelly.accessToken, + userIdentifier: cand.pc.userIdentifier.isNotEmpty ? cand.pc.userIdentifier : jelly.userId, + tokenAcquiredAt: DateTime.now(), + ), + ); + if (mounted) { + unawaited(context.read().rebindIfActive(widget.targetProfile.id)); + if (widget.popOnSuccess) { + Navigator.of(context).pop(true); + return; + } + showSuccessSnackBar(context, 'Connection borrowed.'); + } + } +} + +class _BorrowCandidate { + final Profile source; + final ProfileConnection pc; + final Connection connection; + + const _BorrowCandidate({required this.source, required this.pc, required this.connection}); + + String get connectionLabel => connection.displayLabel; +} + +class _BorrowTile extends StatelessWidget { + final _BorrowCandidate candidate; + final VoidCallback onTap; + + const _BorrowTile({required this.candidate, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BackendBadge(backend: candidate.connection.backend, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(candidate.connectionLabel, style: theme.textTheme.titleMedium, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Row( + children: [ + Text( + 'From ${candidate.source.displayName}', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + if (candidate.source.isPinProtected) ...[ + const SizedBox(width: 6), + AppIcon(Symbols.lock_rounded, fill: 1, size: 12, color: theme.colorScheme.onSurfaceVariant), + ], + ], + ), + if (candidate.connection is PlexAccountConnection) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text('as ${candidate.source.displayName}', style: theme.textTheme.bodySmall), + ), + ], + ), + ), + const Padding(padding: EdgeInsets.only(left: 8, top: 4), child: AppIcon(Symbols.add_rounded, fill: 1)), + ], + ), + ), + ); + } +} diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index 2b74335b..b9dc1628 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -509,3 +509,22 @@ Future showPinEntryDialog(BuildContext context, String userName, {Strin builder: (context) => PinEntryDialog(userName: userName, errorMessage: errorMessage), ); } + +/// Two-step "set + confirm" PIN entry. Returns the matching PIN, or null +/// when the user cancels. On mismatch, surfaces a snackbar via [onMismatch] +/// (or no-op if not provided) and returns null — the helper keeps the UX +/// in one place so multiple call sites don't drift. +Future captureAndConfirmPin( + BuildContext context, { + String setLabel = 'Set PIN', + String confirmLabel = 'Confirm PIN', + void Function(BuildContext)? onMismatch, +}) async { + final pin = await showPinEntryDialog(context, setLabel); + if (pin == null || !context.mounted) return null; + final confirm = await showPinEntryDialog(context, confirmLabel); + if (confirm == null || !context.mounted) return null; + if (pin == confirm) return pin; + onMismatch?.call(context); + return null; +} diff --git a/lib/screens/profile/pin_status_row.dart b/lib/screens/profile/pin_status_row.dart new file mode 100644 index 00000000..7d2a2bee --- /dev/null +++ b/lib/screens/profile/pin_status_row.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../widgets/app_icon.dart'; + +/// "PIN set" pill + Change/Remove text buttons. Shown on profile creation +/// and detail screens after a local PIN has been configured. +class PinStatusRow extends StatelessWidget { + final VoidCallback onChange; + final VoidCallback onRemove; + + const PinStatusRow({super.key, required this.onChange, required this.onRemove}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(Symbols.lock_rounded, fill: 1, color: theme.colorScheme.onPrimaryContainer, size: 18), + const SizedBox(width: 6), + Text( + 'PIN set', + style: theme.textTheme.labelMedium?.copyWith(color: theme.colorScheme.onPrimaryContainer), + ), + ], + ), + ), + const SizedBox(width: 12), + TextButton(onPressed: onChange, child: const Text('Change')), + TextButton(onPressed: onRemove, child: const Text('Remove')), + ], + ); + } +} diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart new file mode 100644 index 00000000..12914db9 --- /dev/null +++ b/lib/screens/profile/profile_detail_screen.dart @@ -0,0 +1,347 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../connection/connection.dart'; +import '../../connection/connection_registry.dart'; +import '../../i18n/strings.g.dart'; +import '../../models/plex/plex_home_user.dart'; +import '../../profiles/active_profile_binder.dart'; +import '../../profiles/active_profile_provider.dart'; +import '../../profiles/plex_home_service.dart'; +import '../../profiles/profile.dart'; +import '../../profiles/profile_avatar.dart'; +import '../../profiles/profile_connection.dart'; +import '../../profiles/profile_connection_registry.dart'; +import '../../profiles/profile_registry.dart'; +import '../../profiles/profiles_view.dart'; +import '../../providers/download_provider.dart'; +import '../../utils/snackbar_helper.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/backend_badge.dart'; +import '../../widgets/desktop_app_bar.dart'; +import '../../utils/dialogs.dart'; +import '../settings/add_connection_screen.dart'; +import 'pin_entry_dialog.dart'; +import 'pin_status_row.dart'; +import 'profile_name_field.dart'; + +/// Manage one [Profile] — rename, change PIN, list/add/remove +/// connections, set the default connection. +/// +/// Plex Home profiles can't be renamed (Plex owns the display name); their +/// PIN lives on Plex too — both fields are read-only here. They can still +/// pick up additional connections via the borrow flow. +class ProfileDetailScreen extends StatefulWidget { + final Profile profile; + + const ProfileDetailScreen({super.key, required this.profile}); + + @override + State createState() => _ProfileDetailScreenState(); +} + +class _ProfileDetailScreenState extends State { + late final TextEditingController _nameController; + late Profile _profile; + + @override + void initState() { + super.initState(); + _profile = widget.profile; + _nameController = TextEditingController(text: widget.profile.displayName); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _saveName() async { + final name = _nameController.text.trim(); + if (name.isEmpty || name == _profile.displayName) return; + final updated = _profile.copyWith(displayName: name); + await context.read().upsert(updated); + if (!mounted) return; + setState(() => _profile = updated); + showSuccessSnackBar(context, 'Profile renamed.'); + } + + Future _setPin() async { + final pin = await captureAndConfirmPin(context, onMismatch: (ctx) => showErrorSnackBar(ctx, "PINs don't match")); + if (pin == null || !mounted) return; + final updated = _profile.copyWith(pinHash: computePinHash(pin)); + await context.read().upsert(updated); + if (!mounted) return; + setState(() => _profile = updated); + } + + Future _clearPin() async { + final updated = _profile.copyWith(clearPin: true); + await context.read().upsert(updated); + if (!mounted) return; + setState(() => _profile = updated); + } + + Future _addConnection() async { + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: _profile))); + } + + Future _removeConnection(ProfileConnection pc, Connection conn) async { + final confirmed = await showConfirmDialog( + context, + title: t.profiles.removeConnectionTitle, + message: t.profiles.removeConnectionMessage( + displayName: _profile.displayName, + connectionLabel: conn.displayLabel, + ), + confirmText: t.profiles.removeConnection, + isDestructive: true, + ); + if (!confirmed || !mounted) return; + await context.read().releaseDownloadsForProfileServers( + _profile.id, + _serverIdsForConnection(conn), + ); + if (!mounted) return; + await context.read().remove(_profile.id, pc.connectionId); + if (!mounted) return; + unawaited(context.read().rebindIfActive(_profile.id)); + } + + Set _serverIdsForConnection(Connection conn) { + return switch (conn) { + PlexAccountConnection(:final servers) => servers.map((s) => s.clientIdentifier).toSet(), + JellyfinConnection(:final serverMachineId) => {serverMachineId}, + }; + } + + Future _deleteProfile() async { + final confirmed = await showDeleteConfirmation( + context, + title: t.profiles.deleteProfileTitle, + message: t.profiles.deleteProfileMessage(displayName: _profile.displayName), + confirmText: t.common.delete, + ); + if (!confirmed || !mounted) return; + final pcRegistry = context.read(); + final profileRegistry = context.read(); + final downloadProvider = context.read(); + final active = context.read(); + final wasActive = active.activeId == _profile.id; + await downloadProvider.deleteDownloadsForProfile(_profile.id); + await pcRegistry.removeAllForProfile(_profile.id); + await profileRegistry.remove(_profile.id); + if (wasActive) { + final remaining = active.profiles.where((p) => p.id != _profile.id).toList(); + if (remaining.isNotEmpty) { + await active.activate(remaining.first); + } else { + await active.clearActiveProfile(); + } + } + if (!mounted) return; + Navigator.of(context).pop(true); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isLocal = _profile.isLocal; + + return Scaffold( + body: CustomScrollView( + slivers: [ + ExcludeFocus(child: CustomAppBar(title: Text(_profile.displayName), pinned: true)), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + Center(child: ProfileAvatar(profile: _profile, size: 96)), + const SizedBox(height: 24), + Text(t.profiles.profileNameLabel, style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + if (isLocal) + ProfileNameField( + controller: _nameController, + onChanged: () => setState(() {}), + trailing: FilledButton( + onPressed: + _nameController.text.trim().isEmpty || _nameController.text.trim() == _profile.displayName + ? null + : _saveName, + child: Text(t.common.save), + ), + ) + else + Text( + _profile.displayName, + style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 24), + Text(t.profiles.pinProtectionLabel, style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + if (!isLocal) + Text( + _profile.plexProtected ? t.profiles.pinManagedByPlex : t.profiles.noPinSetEditOnPlex, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ) + else if (_profile.pinHash == null) + OutlinedButton.icon( + onPressed: _setPin, + icon: const AppIcon(Symbols.lock_outline_rounded, fill: 1), + label: Text(t.profiles.setPin), + ) + else + PinStatusRow(onChange: _setPin, onRemove: _clearPin), + const SizedBox(height: 32), + Row( + children: [ + Expanded(child: Text(t.profiles.connectionsLabel, style: theme.textTheme.labelLarge)), + TextButton.icon( + onPressed: _addConnection, + icon: const AppIcon(Symbols.add_rounded, fill: 1), + label: Text(t.profiles.add), + ), + ], + ), + const SizedBox(height: 8), + _ConnectionsList(profile: _profile, onRemove: _removeConnection), + const SizedBox(height: 24), + if (isLocal) + OutlinedButton.icon( + onPressed: _deleteProfile, + icon: AppIcon(Symbols.delete_outline_rounded, fill: 1, color: theme.colorScheme.error), + label: Text(t.profiles.deleteProfileButton, style: TextStyle(color: theme.colorScheme.error)), + ), + ]), + ), + ), + ], + ), + ); + } +} + +class _ConnectionsList extends StatelessWidget { + final Profile profile; + final Future Function(ProfileConnection pc, Connection conn) onRemove; + + const _ConnectionsList({required this.profile, required this.onRemove}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final pcRegistry = context.read(); + final connRegistry = context.read(); + final plexHome = context.read(); + + return StreamBuilder>( + stream: pcRegistry.watchForProfile(profile.id), + builder: (context, snapshot) { + final pcs = snapshot.data ?? const []; + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center(child: CircularProgressIndicator()), + ); + } + return StreamBuilder>>( + stream: plexHome.stream, + initialData: plexHome.current, + builder: (context, homeSnap) { + final homeCache = homeSnap.data ?? const >{}; + return FutureBuilder>( + future: connRegistry.list(), + builder: (context, snap) { + final all = snap.data ?? const []; + final byId = {for (final c in all) c.id: c}; + // Plex Home profiles have an implicit parent connection that + // isn't in the join table — list it first so the user sees the + // full picture. It can't be removed (the profile *is* a home + // user of that account) and isn't shown for locals. + final parentConn = profile.isPlexHome ? byId[profile.parentConnectionId] : null; + final visiblePcs = visibleProfileConnections(profile, pcs); + if (visiblePcs.isEmpty && parentConn == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + t.profiles.noConnectionsHint, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), + ), + ); + } + return Column( + children: [ + if (parentConn != null) + Card( + child: ListTile( + leading: BackendBadge(backend: parentConn.backend, size: 24), + title: Text(parentConn.displayLabel), + subtitle: Text(t.profiles.plexHomeAccount), + ), + ), + for (final pc in visiblePcs) + if (byId[pc.connectionId] case final conn?) + Card( + child: ListTile( + leading: BackendBadge(backend: conn.backend, size: 24), + title: Text(conn.displayLabel), + subtitle: _ConnectionSubtitle.build(conn: conn, pc: pc, homeCache: homeCache, theme: theme), + trailing: PopupMenuButton( + itemBuilder: (_) => [ + if (!pc.isDefault) + PopupMenuItem( + value: 'default', + onTap: () => WidgetsBinding.instance.addPostFrameCallback( + (_) => pcRegistry.setDefault(profile.id, pc.connectionId), + ), + child: Text(t.profiles.makeDefault), + ), + PopupMenuItem( + value: 'remove', + onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onRemove(pc, conn)), + child: Text(t.profiles.removeConnection), + ), + ], + ), + ), + ), + ], + ); + }, + ); + }, + ); + }, + ); + } +} + +/// Renders the "as {homeUser} · Default" sub-line under each connection +/// row. The home-user lookup turns the bare account label (e.g. the +/// owner's email) into something the user can match to the picker — +/// otherwise borrowed-from-different-home connections look identical. +class _ConnectionSubtitle { + static Widget? build({ + required Connection conn, + required ProfileConnection pc, + required Map> homeCache, + required ThemeData theme, + }) { + final parts = []; + if (conn is PlexAccountConnection) { + final users = homeCache[conn.id]; + if (users != null) { + final user = users.where((u) => u.uuid == pc.userIdentifier).firstOrNull; + if (user != null) parts.add('as ${user.displayName}'); + } + } + if (pc.isDefault) parts.add(t.profiles.connectionDefault); + if (parts.isEmpty) return null; + return Text(parts.join(' · ')); + } +} diff --git a/lib/screens/profile/profile_list_tile.dart b/lib/screens/profile/profile_list_tile.dart deleted file mode 100644 index fc607ec1..00000000 --- a/lib/screens/profile/profile_list_tile.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import '../../i18n/strings.g.dart'; -import '../../models/plex_home_user.dart'; -import '../../theme/mono_tokens.dart'; -import 'user_avatar_widget.dart'; - -enum UserAttribute { admin, restricted, protected } - -class ProfileListTile extends StatelessWidget { - final PlexHomeUser user; - final VoidCallback onTap; - final bool isCurrentUser; - final bool showTrailingIcon; - final bool allowCurrentUserTap; - - const ProfileListTile({ - super.key, - required this.user, - required this.onTap, - this.isCurrentUser = false, - this.showTrailingIcon = true, - this.allowCurrentUserTap = false, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - Widget? trailing; - if (isCurrentUser) { - trailing = Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.primary, - borderRadius: BorderRadius.circular(tokens(context).radiusMd), - ), - child: Text( - t.userStatus.current, - style: TextStyle( - fontSize: 10, - color: theme.colorScheme.onPrimary, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), - ), - ); - } else if (showTrailingIcon) { - trailing = const AppIcon(Symbols.chevron_right_rounded, fill: 1); - } - - return ListTile( - leading: UserAvatarWidget(user: user, size: 40, showIndicators: false), - title: Text(user.displayName), - subtitle: _hasUserAttributes() ? Row(children: _buildUserAttributes(theme)) : null, - trailing: trailing, - onTap: isCurrentUser && !allowCurrentUserTap ? null : onTap, - ); - } - - bool _hasUserAttributes() { - return user.isAdminUser || user.isRestrictedUser || user.requiresPassword; - } - - List _buildUserAttributes(ThemeData theme) { - final attributes = []; - final List userAttributes = []; - - if (user.isAdminUser) { - userAttributes.add(UserAttribute.admin); - } - - if (user.isRestrictedUser && !user.isAdminUser) { - userAttributes.add(UserAttribute.restricted); - } - - if (user.requiresPassword) { - userAttributes.add(UserAttribute.protected); - } - - for (int i = 0; i < userAttributes.length; i++) { - if (i > 0) { - attributes.addAll([ - const SizedBox(width: 8), - Text('•', style: TextStyle(fontSize: 12, color: theme.colorScheme.onSurface.withValues(alpha: 0.5))), - const SizedBox(width: 8), - ]); - } - - final attribute = userAttributes[i]; - - attributes.add( - Text( - _getAttributeLabel(attribute), - style: TextStyle(fontSize: 12, color: _getAttributeColor(attribute, theme), fontWeight: FontWeight.w500), - ), - ); - } - - return attributes; - } - - String _getAttributeLabel(UserAttribute attribute) { - switch (attribute) { - case UserAttribute.admin: - return t.userStatus.admin; - case UserAttribute.restricted: - return t.userStatus.restricted; - case UserAttribute.protected: - return t.userStatus.protected; - } - } - - Color _getAttributeColor(UserAttribute attribute, ThemeData theme) { - switch (attribute) { - case UserAttribute.admin: - return theme.colorScheme.primary; - case UserAttribute.restricted: - return theme.colorScheme.warning ?? Colors.orange; - case UserAttribute.protected: - return theme.colorScheme.secondary; - } - } -} diff --git a/lib/screens/profile/profile_name_field.dart b/lib/screens/profile/profile_name_field.dart new file mode 100644 index 00000000..e3f778c9 --- /dev/null +++ b/lib/screens/profile/profile_name_field.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +/// Bordered "Profile name" text field used by both the new-profile flow and +/// the profile-detail rename row. Optional [trailing] slot for an inline Save +/// button — pass `null` when the screen saves elsewhere (e.g. on Continue). +class ProfileNameField extends StatelessWidget { + const ProfileNameField({super.key, required this.controller, this.hintText, this.trailing, this.onChanged}); + + final TextEditingController controller; + final String? hintText; + final Widget? trailing; + final VoidCallback? onChanged; + + @override + Widget build(BuildContext context) { + final field = TextField( + controller: controller, + textInputAction: TextInputAction.done, + decoration: InputDecoration(hintText: hintText, border: const OutlineInputBorder()), + onChanged: (_) => onChanged?.call(), + ); + if (trailing == null) return field; + return Row( + children: [ + Expanded(child: field), + const SizedBox(width: 12), + trailing!, + ], + ); + } +} diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index a152652a..ca78041d 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -1,17 +1,42 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../models/plex_home_user.dart'; -import '../../providers/user_profile_provider.dart'; -import '../../utils/provider_extensions.dart'; -import '../../utils/snackbar_helper.dart'; -import 'profile_list_tile.dart'; + +import '../../connection/connection_registry.dart'; import '../../focus/focusable_wrapper.dart'; +import '../../i18n/strings.g.dart'; +import '../../media/media_backend.dart'; +import '../../profiles/active_profile_binder.dart'; +import '../../profiles/active_profile_provider.dart'; +import '../../profiles/plex_home_service.dart'; +import '../../profiles/profile.dart'; +import '../../profiles/profile_activation.dart'; +import '../../profiles/profile_avatar.dart'; +import '../../profiles/profile_connection.dart'; +import '../../profiles/profile_connection_registry.dart'; +import '../../profiles/profile_registry.dart'; +import '../../profiles/profiles_view.dart'; +import '../../providers/download_provider.dart'; +import '../../services/storage_service.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/dialogs.dart'; +import '../../utils/snackbar_helper.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/backend_badge.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../libraries/state_messages.dart'; -import '../../i18n/strings.g.dart'; +import 'add_local_profile_screen.dart'; +import 'profile_detail_screen.dart'; +/// Flat picker showing every [Profile] in the system — Plex Home users +/// auto-surfaced from connected accounts, plus user-created locals. +/// +/// Each tile shows avatar, name, an Active badge for the current profile, +/// and one backend chip per connection bound to the profile (parent Plex +/// account + any borrowed connections for Plex Home users). class ProfileSwitchScreen extends StatefulWidget { final bool requireSelection; @@ -25,6 +50,28 @@ class _ProfileSwitchScreenState extends State { bool _allowPop = false; final FocusNode _firstSelectableFocusNode = FocusNode(); bool _focusRequested = false; + bool _switching = false; + Stream? _viewStream; + StorageService? _storage; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _viewStream ??= watchProfilesView( + profiles: context.read(), + profileConnections: context.read(), + connections: context.read(), + plexHome: context.read(), + storage: _storage, + ); + if (_storage == null) { + unawaited( + StorageService.getInstance().then((s) { + if (mounted) setState(() => _storage = s); + }), + ); + } + } @override void dispose() { @@ -34,8 +81,6 @@ class _ProfileSwitchScreenState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return PopScope( canPop: !widget.requireSelection || _allowPop, onPopInvokedWithResult: (didPop, _) { @@ -43,80 +88,75 @@ class _ProfileSwitchScreenState extends State { SystemNavigator.pop(); } }, - child: Consumer( - builder: (context, userProvider, child) { - final users = userProvider.home?.users ?? []; - - return FocusedScrollScaffold( - title: Text(t.screens.switchProfile), - automaticallyImplyLeading: !widget.requireSelection, - onBackPressed: widget.requireSelection ? () => SystemNavigator.pop() : null, - slivers: [ - if (userProvider.isLoading) - const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) - else if (userProvider.error != null) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - userProvider.error!, - style: TextStyle(color: theme.colorScheme.error), - textAlign: TextAlign.center, + child: StreamBuilder( + stream: _viewStream, + initialData: ProfilesView.empty, + builder: (context, snapshot) { + final view = snapshot.data ?? ProfilesView.empty; + // `context.select` only rebuilds when `activeId` actually + // changes. `context.watch` would rebuild on every provider + // notification — combined with the stream, that doubles the + // build cost on each profile-switch. + final activeId = context.select((p) => p.activeId); + return Stack( + children: [ + FocusedScrollScaffold( + title: Text(t.screens.switchProfile), + automaticallyImplyLeading: !widget.requireSelection, + onBackPressed: widget.requireSelection ? () => SystemNavigator.pop() : null, + slivers: [ + if (view.profiles.isEmpty) + SliverFillRemaining( + child: EmptyStateWidget( + message: t.messages.noProfilesAvailable, + subtitle: t.messages.contactAdminForProfiles, + icon: Symbols.person_off_rounded, + ), + ) + else + ..._buildSections(view, activeId), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + sliver: SliverToBoxAdapter( + child: FocusableWrapper( + disableScale: true, + onSelect: _switching ? null : _addLocalProfile, + child: OutlinedButton.icon( + onPressed: _switching ? null : _addLocalProfile, + icon: const AppIcon(Symbols.person_add_rounded, fill: 1), + label: Text(t.profiles.addPlezyProfile), ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - userProvider.refreshCurrentUser(); - }, - child: Text(t.common.retry), - ), - ], + ), ), ), - ) - else if (users.isEmpty) - SliverFillRemaining( - child: EmptyStateWidget( - message: t.messages.noProfilesAvailable, - subtitle: t.messages.contactAdminForProfiles, - icon: Symbols.person_off_rounded, - ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final user = users[index]; - final isCurrentUser = user.uuid == userProvider.currentUser?.uuid; - final isFirstSelectable = - !isCurrentUser && !users.take(index).any((u) => u.uuid != userProvider.currentUser?.uuid); - - if (isFirstSelectable && !_focusRequested) { - _focusRequested = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _firstSelectableFocusNode.requestFocus(); - }); - } - - return Padding( - padding: EdgeInsets.only(left: 16, right: 16, top: index == 0 ? 16 : 0, bottom: 8), - child: FocusableWrapper( - autofocus: isFirstSelectable, - focusNode: isFirstSelectable ? _firstSelectableFocusNode : null, - disableScale: true, - onSelect: isCurrentUser && !widget.requireSelection ? null : () => _switchToUser(context, user), - child: Card( - child: ProfileListTile( - user: user, - isCurrentUser: isCurrentUser, - allowCurrentUserTap: widget.requireSelection, - onTap: () => _switchToUser(context, user), + ], + ), + // Modal busy overlay so users see the switch is in flight. + // Without this the screen visually freezes for a few seconds + // while the binder fetches user tokens and rebuilds servers. + // `Positioned.fill` is required: a non-positioned `ColoredBox` + // sizes itself to its child (just the Card+Center), leaving + // the rest of the screen un-dimmed and tappable. + if (_switching) + Positioned.fill( + child: ColoredBox( + color: Colors.black54, + child: Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 56, height: 56, child: CircularProgressIndicator()), + const SizedBox(height: 16), + Text(t.profiles.switchingProfile), + ], ), ), ), - ); - }, childCount: users.length), + ), + ), ), ], ); @@ -125,19 +165,355 @@ class _ProfileSwitchScreenState extends State { ); } - void _switchToUser(BuildContext context, PlexHomeUser user) async { - final userProvider = context.userProfile; - final navigator = Navigator.of(context); - final success = await userProvider.switchToUser(user, context, verifyPin: widget.requireSelection); + List _buildSections(ProfilesView view, String? activeId) { + return [_profileList(view.profiles, view, activeId, autofocusFirst: true)]; + } - if (success) { + SliverList _profileList(List profiles, ProfilesView view, String? activeId, {required bool autofocusFirst}) { + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final profile = profiles[index]; + final isActive = profile.id == activeId; + final isFirstSelectable = autofocusFirst && index == 0; + + if (isFirstSelectable && !_focusRequested) { + _focusRequested = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _firstSelectableFocusNode.requestFocus(); + }); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: FocusableWrapper( + autofocus: isFirstSelectable, + focusNode: isFirstSelectable ? _firstSelectableFocusNode : null, + disableScale: true, + onSelect: _switching || (isActive && !widget.requireSelection) ? null : () => _switchTo(profile), + child: Card( + child: _ProfileTile( + profile: profile, + isActive: isActive && !widget.requireSelection, + chips: _chipsFor(profile, view), + onTap: () => _switchTo(profile), + // Manage available for any profile — adding/removing + // borrowed connections is supported on plex_home too. Delete + // stays local-only (Plex Home users are owned by Plex). + onManage: !widget.requireSelection ? () => _manageProfile(profile) : null, + onDelete: profile.isLocal && !widget.requireSelection ? () => _deleteProfile(profile) : null, + onSignOut: profile.isPlexHome && profile.parentConnectionId != null && !widget.requireSelection + ? () => _signOutPlexAccount(profile) + : null, + ), + ), + ), + ); + }, childCount: profiles.length), + ); + } + + Future _manageProfile(Profile profile) async { + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => ProfileDetailScreen(profile: profile))); + } + + /// Drop the parent Plex account [profile] hangs off — same effect as + /// "Forget account" elsewhere in Plex apps. The connection's join rows + /// cascade away (FK on connection_id), [PlexHomeService]'s + /// `_onChange` listener evicts the cached home users + shadow profile + /// rows, and a binder rebind clears the runtime client. Plex doesn't + /// expose a single-session revoke endpoint we can rely on, so we don't + /// touch the server side — the user can revoke via plex.tv if they want. + Future _signOutPlexAccount(Profile profile) async { + final parentId = profile.parentConnectionId; + if (parentId == null) return; + final connRegistry = context.read(); + final parent = await connRegistry.getPlexAccount(parentId); + if (parent == null || !mounted) return; + + final confirmed = await showDeleteConfirmation( + context, + title: t.profiles.signOutPlexTitle, + message: t.profiles.signOutPlexMessage(displayName: parent.displayLabel), + confirmText: t.profiles.signOut, + ); + if (!confirmed || !mounted) return; + + final active = context.read(); + final activeProfile = active.active; + final wasActiveAccount = activeProfile?.parentConnectionId == parentId; + final remainingProfiles = active.profiles + .where((p) => p.id != activeProfile?.id && p.parentConnectionId != parentId) + .toList(); + final binder = context.read(); + + try { + await connRegistry.remove(parentId); + if (!mounted) return; + // If the active virtual profile belonged to the removed account, make + // the storage state explicit instead of relying on provider fallback. + if (wasActiveAccount) { + if (remainingProfiles.isNotEmpty) { + await active.activate(remainingProfiles.first); + } else { + await active.clearActiveProfile(); + unawaited(binder.rebindActive()); + } + } else { + // Active profile stayed the same, but borrowed rows for this account + // may have cascaded away. + unawaited(binder.rebindActive()); + } + if (!mounted) return; + showSuccessSnackBar(context, t.profiles.signedOutPlex); + } catch (e, st) { + appLogger.w('Plex sign-out failed for $parentId', error: e, stackTrace: st); + if (mounted) { + showErrorSnackBar(context, t.profiles.signOutFailed); + } + } + } + + Future _deleteProfile(Profile profile) async { + final confirmed = await showDeleteConfirmation( + context, + title: t.profiles.deleteThisProfileTitle, + message: t.profiles.deleteThisProfileMessage(displayName: profile.displayName), + ); + if (!confirmed || !mounted) return; + + final pcRegistry = context.read(); + final registry = context.read(); + final downloadProvider = context.read(); + final active = context.read(); + final wasActive = active.activeId == profile.id; + try { + await downloadProvider.deleteDownloadsForProfile(profile.id); + await pcRegistry.removeAllForProfile(profile.id); + await registry.remove(profile.id); + } catch (e, st) { + appLogger.w('Failed to delete profile ${profile.id}', error: e, stackTrace: st); + if (mounted) { + showErrorSnackBar(context, t.errors.failedToDeleteProfile(displayName: profile.displayName)); + } + return; + } + + // If we just deleted the active profile, hand off to the first + // remaining one — otherwise the binder is left bound to a ghost. + if (wasActive) { + final remaining = active.profiles.where((p) => p.id != profile.id).toList(); + if (remaining.isNotEmpty) { + await active.activate(remaining.first); + } else { + await active.clearActiveProfile(); + } + } + } + + List<_ChipData> _chipsFor(Profile profile, ProfilesView view) { + final chips = <_ChipData>[]; + // Plex Home profiles implicitly own their parent Plex connection (no + // join-table row), so prepend it before any borrowed connections. + if (profile.isPlexHome) { + final parentId = profile.parentConnectionId; + if (parentId != null) { + final conn = view.connectionsById[parentId]; + if (conn != null) chips.add(_ChipData(backend: conn.backend, label: conn.displayLabel)); + } + } + final pcs = visibleProfileConnections( + profile, + view.connectionsByProfile[profile.id] ?? const [], + ); + for (final pc in pcs) { + final conn = view.connectionsById[pc.connectionId]; + if (conn != null) chips.add(_ChipData(backend: conn.backend, label: conn.displayLabel)); + } + return chips; + } + + Future _addLocalProfile() async { + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AddLocalProfileScreen())); + } + + Future _switchTo(Profile profile) async { + if (_switching) return; + setState(() => _switching = true); + try { + final navigator = Navigator.of(context); + final activeProvider = context.read(); + final ok = await activateProfileWithPin(context, profile); + if (!mounted) return; + if (!ok) { + if (context.mounted) { + showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); + } + return; + } + // Stay on the picker while the binder mints the per-user token, + // fetches servers, and pushes them into MultiServerManager. The + // PIN dialog (if any) overlays the picker via the root navigator, + // so popping early would briefly expose the previous profile's + // empty-state screen behind the dialog. + final bound = await activeProvider.awaitBindingSettle(); + if (!mounted) return; + if (!bound) { + if (context.mounted) { + showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); + } + return; + } if (widget.requireSelection) { - if (!mounted) return; setState(() => _allowPop = true); } navigator.pop(true); - } else if (context.mounted) { - showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: user.displayName)); + } finally { + if (mounted) setState(() => _switching = false); } } } + +class _ProfileTile extends StatelessWidget { + final Profile profile; + final bool isActive; + final List<_ChipData> chips; + final VoidCallback onTap; + final VoidCallback? onManage; + final VoidCallback? onDelete; + final VoidCallback? onSignOut; + + const _ProfileTile({ + required this.profile, + required this.isActive, + required this.chips, + required this.onTap, + this.onManage, + this.onDelete, + this.onSignOut, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasMenu = onManage != null || onDelete != null || onSignOut != null; + return InkWell( + onTap: isActive ? null : onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + child: Row( + children: [ + ProfileAvatar(profile: profile, size: 44), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + profile.displayName, + style: theme.textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), + if (isActive) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + t.profiles.active, + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onPrimaryContainer), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + _ConnectionChips(chips: chips), + ], + ), + ), + if (hasMenu) + PopupMenuButton<_TileAction>( + icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), + tooltip: 'Profile actions', + itemBuilder: (_) => [ + if (onManage != null) + PopupMenuItem( + value: _TileAction.manage, + onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onManage?.call()), + child: Text(t.profiles.manage), + ), + if (onDelete != null) + PopupMenuItem( + value: _TileAction.delete, + onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onDelete?.call()), + child: Text(t.profiles.delete), + ), + if (onSignOut != null) + PopupMenuItem( + value: _TileAction.signOut, + onTap: () => WidgetsBinding.instance.addPostFrameCallback((_) => onSignOut?.call()), + child: Text(t.profiles.signOut), + ), + ], + ) + else if (!isActive) + const Padding(padding: EdgeInsets.only(left: 8), child: AppIcon(Symbols.chevron_right_rounded, fill: 1)), + ], + ), + ), + ); + } +} + +enum _TileAction { manage, delete, signOut } + +class _ConnectionChips extends StatelessWidget { + final List<_ChipData> chips; + + const _ConnectionChips({required this.chips}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (chips.isEmpty) { + return Text('No connections', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)); + } + return Wrap( + spacing: 6, + runSpacing: 4, + children: [ + for (final c in chips) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BackendBadge(backend: c.backend, size: 12), + const SizedBox(width: 4), + Text(c.label, style: theme.textTheme.labelSmall), + ], + ), + ), + ], + ); + } +} + +class _ChipData { + final MediaBackend backend; + final String label; + const _ChipData({required this.backend, required this.label}); +} diff --git a/lib/screens/profile/user_avatar_widget.dart b/lib/screens/profile/user_avatar_widget.dart deleted file mode 100644 index 045a5c03..00000000 --- a/lib/screens/profile/user_avatar_widget.dart +++ /dev/null @@ -1,213 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import '../../models/plex_home_user.dart'; -import '../../services/image_cache_service.dart'; -import '../../theme/mono_tokens.dart'; -import '../../i18n/strings.g.dart'; -import '../../widgets/plex_optimized_image.dart' show blurArtwork; - -class UserAvatarWidget extends StatelessWidget { - final PlexHomeUser user; - final double size; - final bool showIndicators; - final bool useTextLabels; - - const UserAvatarWidget({ - super.key, - required this.user, - this.size = 40, - this.showIndicators = true, - this.useTextLabels = false, - }); - - Widget _buildPlaceholderAvatar(ThemeData theme) { - return Container( - width: size, - height: size, - decoration: BoxDecoration(color: theme.colorScheme.surfaceContainerHighest, shape: BoxShape.circle), - child: AppIcon(Symbols.person_rounded, fill: 1, size: size * 0.6, color: theme.colorScheme.onSurfaceVariant), - ); - } - - /// Helper method to build a circular badge with an icon - /// - /// [icon] - The icon to display in the badge - /// [color] - The background color of the badge - /// [iconColor] - The color of the icon - /// [position] - The position of the badge ('topRight' or 'bottomRight') - /// [sizeRatio] - The size ratio relative to the avatar size (default 0.3) - Widget _buildBadge({ - required BuildContext context, - required IconData icon, - required Color color, - required Color iconColor, - required String position, - double sizeRatio = 0.3, - }) { - final badgeSize = size * sizeRatio; - final iconSize = size * (sizeRatio * 0.67); // Approximately 2/3 of badge size - - return Positioned( - top: position == 'topRight' ? 0 : null, - bottom: position == 'bottomRight' ? 0 : null, - right: 0, - child: Container( - width: badgeSize, - height: badgeSize, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Theme.of(context).colorScheme.surface, width: 1), - ), - child: AppIcon(icon, fill: 1, size: iconSize, color: iconColor), - ), - ); - } - - /// Helper method to build a text label chip - /// - /// [text] - The text to display in the chip - /// [backgroundColor] - The background color of the chip - /// [textColor] - The color of the text - Widget _buildLabelChip({ - required BuildContext context, - required String text, - required Color backgroundColor, - required Color textColor, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(tokens(context).radiusSm)), - child: Text( - text, - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: textColor, fontWeight: FontWeight.bold), - ), - ); - } - - List _buildTextLabels(BuildContext context, ThemeData theme) { - if (!useTextLabels || !showIndicators) return []; - - final labels = []; - - if (user.isAdminUser) { - labels.add( - _buildLabelChip( - context: context, - text: t.userStatus.admin, - backgroundColor: theme.colorScheme.primary, - textColor: theme.colorScheme.onPrimary, - ), - ); - } - - if (user.isRestrictedUser && !user.isAdminUser) { - labels.add( - _buildLabelChip( - context: context, - text: t.userStatus.restricted, - backgroundColor: theme.colorScheme.warning ?? Colors.orange, - textColor: theme.colorScheme.onPrimary, - ), - ); - } - - if (user.requiresPassword) { - labels.add( - _buildLabelChip( - context: context, - text: t.userStatus.protected, - backgroundColor: theme.colorScheme.secondary, - textColor: theme.colorScheme.onSecondary, - ), - ); - } - - if (labels.isEmpty) return []; - - return [ - const SizedBox(height: 4), - Wrap(spacing: 4, runSpacing: 2, alignment: WrapAlignment.center, children: labels), - ]; - } - - Widget _buildAvatar(BuildContext context, ThemeData theme) { - return SizedBox( - width: size, - height: size, - child: Stack( - children: [ - // Avatar image - ClipOval( - child: blurArtwork( - CachedNetworkImage( - imageUrl: user.thumb, - cacheManager: PlexImageCacheManager.instance, - width: size, - height: size, - fit: BoxFit.cover, - memCacheHeight: (size * MediaQuery.devicePixelRatioOf(context)).round(), - placeholder: (ctx, url) => _buildPlaceholderAvatar(theme), - errorWidget: (ctx, url, error) => _buildPlaceholderAvatar(theme), - ), - ), - ), - - // Indicators (only show icon indicators when not using text labels) - if (showIndicators && !useTextLabels) ...[ - // Admin badge - if (user.isAdminUser) - _buildBadge( - context: context, - icon: Symbols.admin_panel_settings_rounded, - color: theme.colorScheme.primary, - iconColor: theme.colorScheme.onPrimary, - position: 'topRight', - ), - - // Restricted badge - if (user.isRestrictedUser && !user.isAdminUser) - _buildBadge( - context: context, - icon: Symbols.security_rounded, - color: theme.colorScheme.warning ?? Colors.orange, - iconColor: theme.colorScheme.onPrimary, - position: 'topRight', - ), - - // Password indicator - if (user.requiresPassword) - _buildBadge( - context: context, - icon: Symbols.lock_rounded, - color: theme.colorScheme.secondary, - iconColor: theme.colorScheme.onSecondary, - position: 'bottomRight', - sizeRatio: 0.25, - ), - ], - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - if (useTextLabels) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [_buildAvatar(context, theme), ..._buildTextLabels(context, theme)], - ); - } - return _buildAvatar(context, theme); - } -} - -// Extension to add warning color to ColorScheme if not available -extension ColorSchemeExtension on ColorScheme { - Color? get warning => brightness == Brightness.light ? Colors.orange.shade600 : Colors.orange.shade400; -} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index c4b3ed83..d443dab3 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -6,8 +6,8 @@ import 'package:rate_limiter/rate_limiter.dart'; import '../focus/dpad_navigator.dart'; import '../i18n/strings.g.dart'; +import '../media/media_item.dart'; import '../mixins/refreshable.dart'; -import '../models/plex_metadata.dart'; import '../providers/multi_server_provider.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; @@ -30,7 +30,7 @@ class _SearchScreenState extends State final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(debugLabel: 'SearchInput'); final _firstResultFocusNode = FocusNode(debugLabel: 'SearchFirstResult'); - List _searchResults = []; + List _searchResults = []; bool _isSearching = false; bool _hasSearched = false; late final Debounce _searchDebounce; @@ -99,10 +99,10 @@ class _SearchScreenState extends State } // Search across all connected servers - final results = await multiServerProvider.aggregationService.searchAcrossServers(query); + final neutral = await multiServerProvider.aggregationService.searchAcrossServers(query); if (mounted) { setState(() { - _searchResults = results; + _searchResults = neutral; _isSearching = false; _lastSearchedQuery = query.trim(); }); @@ -213,7 +213,7 @@ class _SearchScreenState extends State forceListMode: true, disableScale: true, focusNode: index == 0 ? _firstResultFocusNode : null, - onListRefresh: () => updateItem(item.ratingKey), + onListRefresh: () => updateItem(item.id), onNavigateLeft: _navigateToSidebar, onNavigateUp: index == 0 ? focusSearchInput : null, showServerName: showServerName, diff --git a/lib/screens/settings/add_connection_screen.dart b/lib/screens/settings/add_connection_screen.dart new file mode 100644 index 00000000..cfba70ab --- /dev/null +++ b/lib/screens/settings/add_connection_screen.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:plezy/widgets/app_icon.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../i18n/strings.g.dart'; +import '../../media/media_backend.dart'; +import '../../profiles/profile.dart'; +import '../../widgets/backend_badge.dart'; +import '../../widgets/desktop_app_bar.dart'; +import '../profile/borrow_connection_screen.dart'; +import 'add_jellyfin_screen.dart'; +import 'add_plex_account_screen.dart'; + +/// Picker shown when the user taps "Add connection". +/// +/// When [targetProfile] is provided, also offers a "Borrow from another +/// profile" option that opens [BorrowConnectionScreen] for the target. The +/// global Connections screen invokes this without a target — Plex auto- +/// surfaces its Home users as new profiles, Jellyfin binds to the active +/// profile via [AddJellyfinScreen]. +/// +/// Pops with `true` after the underlying flow succeeds so the parent list +/// refreshes; pops with `null` (the default) when the user backs out. +class AddConnectionScreen extends StatelessWidget { + final Profile? targetProfile; + + const AddConnectionScreen({super.key, this.targetProfile}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scoped = targetProfile != null; + final options = <_BackendOption>[ + _BackendOption( + backend: MediaBackend.plex, + title: t.addServer.signInWithPlexCard, + subtitle: scoped ? t.addServer.signInWithPlexCardSubtitleScoped : t.addServer.signInWithPlexCardSubtitle, + builder: (_) => AddPlexAccountScreen(targetProfile: targetProfile), + ), + _BackendOption( + backend: MediaBackend.jellyfin, + title: t.addServer.connectToJellyfinCard, + subtitle: scoped + ? t.addServer.connectToJellyfinCardSubtitleScoped(name: targetProfile!.displayName) + : t.addServer.connectToJellyfinCardSubtitle, + builder: (_) => AddJellyfinScreen(targetProfile: targetProfile), + ), + ]; + return Scaffold( + body: CustomScrollView( + slivers: [ + ExcludeFocus( + child: CustomAppBar( + title: Text( + scoped + ? t.addServer.addConnectionTitleScoped(name: targetProfile!.displayName) + : t.addServer.addConnectionTitle, + ), + pinned: true, + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + Text( + scoped ? t.addServer.addConnectionIntroScoped : t.addServer.addConnectionIntroGlobal, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + for (var i = 0; i < options.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + _BackendCard( + leading: BackendBadge(backend: options[i].backend, size: 28), + title: options[i].title, + subtitle: options[i].subtitle, + onTap: () async { + final added = await Navigator.push(context, MaterialPageRoute(builder: options[i].builder)); + if (added == true && context.mounted) { + Navigator.of(context).pop(true); + } + }, + ), + ], + if (scoped) ...[ + const SizedBox(height: 12), + _BackendCard( + leading: const AppIcon(Symbols.share_rounded, fill: 1, size: 28), + title: t.addServer.borrowFromAnotherProfile, + subtitle: t.addServer.borrowFromAnotherProfileSubtitle, + onTap: () async { + final added = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: targetProfile!)), + ); + if (added == true && context.mounted) { + Navigator.of(context).pop(true); + } + }, + ), + ], + ]), + ), + ), + ], + ), + ); + } +} + +class _BackendOption { + final MediaBackend backend; + final String title; + final String subtitle; + final WidgetBuilder builder; + + const _BackendOption({required this.backend, required this.title, required this.subtitle, required this.builder}); +} + +class _BackendCard extends StatelessWidget { + final Widget leading; + final String title; + final String subtitle; + final VoidCallback onTap; + + const _BackendCard({required this.leading, required this.title, required this.subtitle, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + leading, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + const AppIcon(Symbols.chevron_right_rounded, fill: 1), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart new file mode 100644 index 00000000..f9b8e25e --- /dev/null +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -0,0 +1,502 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:plezy/widgets/app_icon.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; + +import '../../connection/connection.dart'; +import '../../exceptions/media_server_exceptions.dart'; +import '../../i18n/strings.g.dart'; +import '../../profiles/active_profile_binder.dart'; +import '../../profiles/active_profile_provider.dart'; +import '../../profiles/profile.dart'; +import '../../profiles/profile_connection.dart'; +import '../../profiles/profile_registry.dart'; +import '../../services/jellyfin_auth_service.dart'; +import '../../services/storage_service.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/platform_detector.dart'; +import '../../widgets/desktop_app_bar.dart'; +import '../profile/profile_switch_screen.dart'; +import 'async_form_state_mixin.dart'; +import 'connection_persistence.dart'; +import '../../widgets/loading_indicator_box.dart'; + +@visibleForTesting +bool shouldCreateLocalJellyfinProfile({ + required Profile? targetProfile, + required Profile? activeProfile, + required bool hasProfiles, +}) { + return targetProfile == null && activeProfile == null && !hasProfiles; +} + +@visibleForTesting +bool shouldPromptForJellyfinProfileSelection({ + required Profile? targetProfile, + required Profile? activeProfile, + required bool hasProfiles, +}) { + return targetProfile == null && activeProfile == null && hasProfiles; +} + +/// Three-step form to add a Jellyfin server: +/// 1. Probe URL (`/System/Info/Public`). +/// 2. Username + password (`/Users/AuthenticateByName`) **or** Quick Connect +/// (`/QuickConnect/Initiate` → poll → `/Users/AuthenticateWithQuickConnect`). +/// 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 + /// [ProfileConnection] row. When null, falls back to the currently active + /// profile (typical for the global Connections screen entry point). + final Profile? targetProfile; + + const AddJellyfinScreen({super.key, this.targetProfile}); + + @override + State createState() => _AddJellyfinScreenState(); +} + +class _AddJellyfinScreenState extends State with AsyncFormStateMixin { + final _urlController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + // Owned so the username field can advance focus on Enter; mobile keyboards + // act on `textInputAction: next` automatically but TV remotes / hardware + // keyboards need the explicit `onFieldSubmitted` handler below. + final _passwordFocus = FocusNode(); + final _formKey = GlobalKey(); + + JellyfinServerInfo? _serverInfo; + bool _quickConnectEnabled = false; + JellyfinQuickConnectInitiation? _qcInitiation; + bool _qcCancelled = false; + int _qcAttemptId = 0; + + @override + void dispose() { + // Short-circuit any in-flight Quick Connect poll so it doesn't try to + // setState after the widget is gone. + _qcCancelled = true; + _qcAttemptId++; + _urlController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + Future _probe() async { + final url = _urlController.text.trim(); + if (url.isEmpty) { + setErrorText(t.addServer.enterJellyfinUrlError); + return; + } + await runAsync( + () async { + final auth = await _buildAuthService(); + // Run the probe and the QC capability check in parallel — the latter + // is independent and just tells the UI whether to surface the button. + final probeFuture = auth.probe(url); + final qcFuture = auth.isQuickConnectEnabled(url); + final info = await probeFuture; + final qcEnabled = await qcFuture; + if (!mounted) return; + setState(() { + _serverInfo = info; + _quickConnectEnabled = qcEnabled; + }); + // On TV, typing a username/password with a remote is misery — auto-jump + // to Quick Connect when the server supports it. Mirrors the + // PlatformDetector.isTV() default in add_plex_account_screen.dart. + if (qcEnabled && PlatformDetector.isTV()) { + unawaited(_startQuickConnect()); + } + }, + errorMapper: (e) => + e is MediaServerUrlException ? e.message : t.addServer.couldNotReachServer(error: e.toString()), + ); + } + + Future _signIn() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + final info = _serverInfo; + if (info == null) { + await _probe(); + return; + } + await runAsync( + () async { + final auth = await _buildAuthService(); + final storage = await StorageService.getInstance(); + final deviceId = await storage.getOrCreateClientIdentifier(); + + final connection = await auth.authenticateByName( + baseUrl: _urlController.text, + username: _usernameController.text, + password: _passwordController.text, + deviceId: deviceId, + serverInfo: info, + ); + + if (!mounted) return; + await _persistAndExit(connection); + }, + errorMapper: (e) { + if (e is MediaServerAuthException) return e.message; + appLogger.e('Add Jellyfin failed', error: e); + return t.addServer.signInFailed(error: e.toString()); + }, + ); + } + + Future _startQuickConnect() async { + final info = _serverInfo; + if (info == null) return; + final attemptId = ++_qcAttemptId; + setState(() => _qcCancelled = false); + await runAsync( + () async { + final auth = await _buildAuthService(); + final storage = await StorageService.getInstance(); + final deviceId = await storage.getOrCreateClientIdentifier(); + + final initiation = await auth.initiateQuickConnect(baseUrl: _urlController.text, deviceId: deviceId); + if (!_isCurrentQuickConnectAttempt(attemptId)) return; + // Show the waiting panel without a spinner — opt-out of busy mid-flow + // so the user-visible state matches "we're polling, nothing for you to do". + setState(() => _qcInitiation = initiation); + setBusy(false); + + final connection = await auth.authenticateByQuickConnect( + baseUrl: _urlController.text, + secret: initiation.secret, + deviceId: deviceId, + serverInfo: info, + shouldCancel: () => _qcCancelled || attemptId != _qcAttemptId, + ); + + if (!_isCurrentQuickConnectAttempt(attemptId)) return; + if (connection == null) { + // Either user cancelled or the secret expired before approval. + // Cancellation is silent; expiry surfaces an error. + setState(() => _qcInitiation = null); + if (!_qcCancelled) setErrorText(t.auth.quickConnectExpired); + return; + } + await _persistAndExit(connection); + }, + errorMapper: (e) { + if (e is MediaServerAuthException) return e.message; + appLogger.e('Jellyfin Quick Connect failed', error: e); + return t.addServer.quickConnectFailed(error: e.toString()); + }, + shouldApplyState: () => attemptId == _qcAttemptId, + ); + // Clear the QC panel after any error so the form re-shows. + if (_isCurrentQuickConnectAttempt(attemptId) && errorText != null && _qcInitiation != null) { + setState(() => _qcInitiation = null); + } + } + + bool _isCurrentQuickConnectAttempt(int attemptId) => mounted && attemptId == _qcAttemptId; + + void _cancelQuickConnect() { + _qcAttemptId++; + setState(() { + _qcCancelled = true; + _qcInitiation = null; + }); + setBusy(false); + } + + /// Shared persistence path for both username/password and Quick Connect: + /// upsert the connection, attach a ProfileConnection to the bound profile, + /// register with the live manager when binding to the active profile, and + /// pop with success. + Future _persistAndExit(JellyfinConnection connection) async { + if (!mounted) return; + // Bind to the target profile (caller's choice) or the active one. On a + // first-run Jellyfin-only sign-in there is no profile yet, so create and + // activate a local profile before registering the server. + final activeProvider = context.read(); + await activeProvider.initialize(); + if (!mounted) return; + final targetProfile = widget.targetProfile; + var boundProfile = targetProfile ?? activeProvider.active; + if (shouldPromptForJellyfinProfileSelection( + targetProfile: targetProfile, + activeProfile: activeProvider.active, + hasProfiles: activeProvider.profiles.isNotEmpty, + )) { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true))); + if (!mounted) return; + boundProfile = activeProvider.active; + if (boundProfile == null) { + setErrorText(t.messages.noProfilesAvailable); + return; + } + } + if (shouldCreateLocalJellyfinProfile( + targetProfile: targetProfile, + activeProfile: boundProfile, + hasProfiles: activeProvider.profiles.isNotEmpty, + )) { + final now = DateTime.now(); + final profile = Profile( + id: 'local-${const Uuid().v4()}', + kind: ProfileKind.local, + displayName: connection.userName.isNotEmpty ? connection.userName : connection.serverName, + sortOrder: now.millisecondsSinceEpoch, + createdAt: now, + ); + await context.read().upsert(profile); + await activeProvider.activate(profile); + if (!mounted) return; + boundProfile = activeProvider.active ?? profile; + } + final bindProfile = boundProfile; + if (bindProfile == null) { + setErrorText(t.messages.noProfilesAvailable); + return; + } + final boundToActive = bindProfile.id == activeProvider.activeId; + + await persistAndBindConnection( + context: context, + connection: connection, + bindToProfile: ProfileConnection( + profileId: bindProfile.id, + connectionId: connection.id, + userToken: connection.accessToken, + userIdentifier: connection.userId, + tokenAcquiredAt: DateTime.now(), + ), + addToManager: null, + ); + + if (!mounted) return; + if (boundToActive) { + await context.read().rebindIfActive(bindProfile.id); + } + + if (!mounted) return; + Navigator.of(context).pop(true); + } + + Future _buildAuthService() async { + final pkg = await PackageInfo.fromPlatform(); + final deviceName = await _resolveDeviceName(); + return JellyfinConnectionAuthService(clientName: 'Plezy', clientVersion: pkg.version, deviceName: deviceName); + } + + Future _resolveDeviceName() async { + // PackageInfo doesn't expose a device name; fall back to a generic label. + // Jellyfin only shows this in the admin "Devices" list — fine to keep + // simple until we add proper device_info_plus integration. + return 'Plezy'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + body: CustomScrollView( + slivers: [ + ExcludeFocus(child: CustomAppBar(title: Text(t.addServer.addJellyfinTitle), pinned: true)), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + sliver: SliverToBoxAdapter( + child: Form( + key: _formKey, + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildBodyChildren(theme)), + ), + ), + ), + ], + ), + ); + } + + List _buildBodyChildren(ThemeData theme) { + if (_qcInitiation != null) { + return [ + ..._buildQuickConnectPanel(theme), + if (errorText != null) ...[ + const SizedBox(height: 12), + Text(errorText!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)), + ], + ]; + } + return [ + Text(t.addServer.jellyfinUrlIntro, style: theme.textTheme.bodyMedium), + const SizedBox(height: 16), + TextFormField( + controller: _urlController, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + enabled: !busy, + textInputAction: TextInputAction.go, + onFieldSubmitted: busy ? null : (_) => _probe(), + decoration: InputDecoration( + labelText: t.addServer.serverUrl, + prefixIcon: const AppIcon(Symbols.link_rounded, fill: 1), + ), + validator: (v) => v == null || v.trim().isEmpty ? t.addServer.required : null, + ), + if (_serverInfo == null) ...[ + const SizedBox(height: 16), + FilledButton.icon( + onPressed: busy ? null : _probe, + icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.travel_explore_rounded, fill: 1), + label: Text(t.addServer.findServer), + ), + ] else ...[ + const SizedBox(height: 16), + _buildServerCard(theme), + const SizedBox(height: 16), + TextFormField( + controller: _usernameController, + autocorrect: false, + enableSuggestions: false, + enabled: !busy, + textInputAction: TextInputAction.next, + onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(), + decoration: InputDecoration( + labelText: t.addServer.username, + prefixIcon: const AppIcon(Symbols.person_rounded, fill: 1), + ), + validator: (v) => v == null || v.trim().isEmpty ? t.addServer.required : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + focusNode: _passwordFocus, + obscureText: true, + enabled: !busy, + textInputAction: TextInputAction.done, + onFieldSubmitted: busy ? null : (_) => _signIn(), + decoration: InputDecoration( + labelText: t.addServer.password, + prefixIcon: const AppIcon(Symbols.lock_rounded, fill: 1), + ), + // Empty password is valid for some Jellyfin setups, so don't + // require a value. + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: busy ? null : _signIn, + icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1), + label: Text(t.addServer.signIn), + ), + if (_quickConnectEnabled) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: busy ? null : _startQuickConnect, + icon: const AppIcon(Symbols.tap_and_play_rounded, fill: 1), + label: Text(t.auth.useQuickConnect), + ), + ], + ], + if (errorText != null) ...[ + const SizedBox(height: 12), + Text(errorText!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)), + ], + ]; + } + + Widget _buildServerCard(ThemeData theme) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const AppIcon(Symbols.cloud_done_rounded, fill: 1), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_serverInfo!.serverName, style: theme.textTheme.titleSmall), + Text( + 'Jellyfin ${_serverInfo!.version}', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)), + ), + ], + ), + ), + TextButton( + onPressed: busy + ? null + : () => setState(() { + _serverInfo = null; + _quickConnectEnabled = false; + }), + child: Text(t.addServer.change), + ), + ], + ), + ); + } + + List _buildQuickConnectPanel(ThemeData theme) { + final code = _qcInitiation!.code; + return [ + Container( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Text( + t.auth.quickConnectCode, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)), + ), + const SizedBox(height: 12), + Text( + code, + textAlign: TextAlign.center, + style: theme.textTheme.displayMedium?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + letterSpacing: 8, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text(t.auth.quickConnectInstructions, style: theme.textTheme.bodyMedium), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const LoadingIndicatorBox(), + const SizedBox(width: 12), + Text(t.auth.quickConnectWaiting, style: theme.textTheme.bodyMedium), + ], + ), + const SizedBox(height: 20), + OutlinedButton.icon( + onPressed: _cancelQuickConnect, + icon: const AppIcon(Symbols.close_rounded, fill: 1), + label: Text(t.auth.quickConnectCancel), + ), + ]; + } +} diff --git a/lib/screens/settings/add_plex_account_screen.dart b/lib/screens/settings/add_plex_account_screen.dart new file mode 100644 index 00000000..8416c4a8 --- /dev/null +++ b/lib/screens/settings/add_plex_account_screen.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:plezy/widgets/app_icon.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../../connection/connection.dart'; +import '../../connection/connection_registry.dart'; +import '../../i18n/strings.g.dart'; +import '../../profiles/active_profile_binder.dart'; +import '../../profiles/active_profile_provider.dart'; +import '../../profiles/plex_home_service.dart'; +import '../../profiles/profile.dart'; +import '../../profiles/profile_connection_registry.dart'; +import '../../services/plex_auth_service.dart'; +import '../../utils/app_logger.dart'; +import '../../media/media_backend.dart'; +import '../../widgets/backend_badge.dart'; +import '../../widgets/desktop_app_bar.dart'; +import '../auth/plex_pin_auth_flow.dart'; +import '../profile/borrow_connection_screen.dart'; +import 'async_form_state_mixin.dart'; +import 'connection_persistence.dart'; + +/// Add a Plex account to the [ConnectionRegistry]. +/// +/// Hands off the PIN/QR/polling UI to [PlexPinAuthFlow]; this screen owns +/// the post-token-received flow: build the [PlexAccountConnection], guard +/// against duplicates, register with the live [MultiServerManager], and +/// either pop with success or route into [BorrowConnectionScreen] for the +/// passed-in profile. +/// +/// When [targetProfile] is provided, after a successful sign-in the user +/// is routed into [BorrowConnectionScreen] for that target so they can +/// pick which Home user from the new account to attach to the profile. +class AddPlexAccountScreen extends StatefulWidget { + /// When set, after sign-in route into the borrow flow for this profile. + /// The new account's Home users surface globally either way; the borrow + /// step is what creates the [ProfileConnection] row that grants this + /// profile access to one of them. + final Profile? targetProfile; + + const AddPlexAccountScreen({super.key, this.targetProfile}); + + @override + State createState() => _AddPlexAccountScreenState(); +} + +class _AddPlexAccountScreenState extends State with AsyncFormStateMixin { + Future _onTokenReceived(String token) async { + final completed = await runAsync( + () async { + // Pull the account label first so the row is human-readable. Falls + // back to "Plex" when the user info call fails (rare; e.g. token + // works but plex.tv is rate-limiting). + String accountLabel = 'Plex'; + // Account UUID from plex.tv — this is what makes multi-account work. + // The clientIdentifier is per-device (same for every Plex account on + // this install), so keying connection.id off it would collapse two + // different Plex accounts into the same row. Falls back to the + // client identifier only if the user-info call fails outright; + // re-signing into the same account will then upsert the legacy row. + String accountUuid = ''; + final auth = await PlexAuthService.create(); + try { + try { + final info = await auth.getUserInfo(token); + accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex'; + final uuid = (info['uuid'] as String?)?.trim(); + if (uuid != null && uuid.isNotEmpty) accountUuid = uuid; + } catch (e) { + appLogger.d('getUserInfo after add-account failed (using fallback): $e'); + } + + final servers = await auth.fetchServers(token); + if (!mounted) return false; + + final connection = PlexAccountConnection( + id: 'plex.${accountUuid.isNotEmpty ? accountUuid : auth.clientIdentifier}', + accountToken: token, + clientIdentifier: auth.clientIdentifier, + accountLabel: accountLabel, + servers: servers, + createdAt: DateTime.now(), + lastAuthenticatedAt: DateTime.now(), + ); + + if (!mounted) return false; + // Persist the registry row. Binding is deliberately left to + // ActiveProfileBinder below (global reauth) or the borrow flow + // (profile-scoped add) so we never put the raw account token into + // the active runtime session. + final target = widget.targetProfile; + await persistAndBindConnection( + context: context, + connection: connection, + bindToProfile: null, + addToManager: null, + ); + + if (!mounted) return false; + // Live-fetch the new account's Home users into [PlexHomeService]'s + // cache so the picker immediately surfaces them as virtual profiles. + // Must be awaited before pushing the borrow screen — that screen + // reads `activeProvider.profiles` once in initState (no reactive + // subscription), so navigating before the home users land yields + // an empty candidate list. Errors are swallowed inside + // `_fetchAndCache`; await is safe. + await context.read().refresh(connection); + + if (!mounted) return false; + if (target != null) { + final borrowed = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: target, popOnSuccess: true)), + ); + if (!mounted) return borrowed == true; + if (borrowed == true) { + Navigator.of(context).pop(true); + return true; + } + throw StateError(t.addServer.failedToRegisterAccount(error: 'Connection was not borrowed')); + } + await _rebindActiveIfUses(connection.id); + if (!mounted) return false; + Navigator.of(context).pop(true); + return true; + } finally { + auth.dispose(); + } + }, + errorMapper: (e) { + appLogger.e('Failed to register Plex account', error: e); + return t.addServer.failedToRegisterAccount(error: e.toString()); + }, + ); + if (mounted && completed != true) { + throw StateError(errorText ?? t.addServer.failedToRegisterAccount(error: 'Unknown error')); + } + } + + Future _rebindActiveIfUses(String connectionId) async { + final activeProvider = context.read(); + await activeProvider.initialize(); + if (!mounted) return; + + final active = activeProvider.active; + if (active == null) return; + var usesConnection = active.parentConnectionId == connectionId; + if (!usesConnection) { + final pcs = await context.read().listForProfile(active.id); + usesConnection = pcs.any((pc) => pc.connectionId == connectionId); + } + if (!mounted || !usesConnection) return; + await context.read().rebindActive(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + body: CustomScrollView( + slivers: [ + ExcludeFocus(child: CustomAppBar(title: Text(t.addServer.addPlexTitle), pinned: true)), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(t.addServer.plexAuthIntro, style: theme.textTheme.bodyMedium), + const SizedBox(height: 24), + PlexPinAuthFlow( + onTokenReceived: _onTokenReceived, + initialButtonsBuilder: (context, browser, qr, busy) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + onPressed: busy || this.busy ? null : browser, + icon: const BackendBadge(backend: MediaBackend.plex, size: 18), + label: Text(t.auth.signInWithPlex), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: busy || this.busy ? null : qr, + icon: const AppIcon(Symbols.qr_code_rounded, fill: 1), + label: Text(t.auth.showQRCode), + ), + ], + ), + ), + if (errorText != null) ...[ + const SizedBox(height: 16), + Text( + errorText!, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 8f0abd05..fe840f70 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -8,7 +8,7 @@ import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; import '../../providers/settings_provider.dart'; import '../../providers/theme_provider.dart'; -import '../../providers/user_profile_provider.dart'; +import '../../profiles/active_profile_provider.dart'; import '../../services/settings_service.dart' as settings; import '../../focus/focusable_slider.dart'; import '../../utils/platform_detector.dart'; @@ -338,9 +338,9 @@ class _AppearanceSettingsScreenState extends State { ); Widget _buildRequireProfileSelection() { - return Consumer( - builder: (context, userProfileProvider, child) { - if (!userProfileProvider.hasMultipleUsers) return const SizedBox.shrink(); + return Consumer( + builder: (context, activeProvider, child) { + if (!activeProvider.hasMultipleProfiles) return const SizedBox.shrink(); return _buildListenableSwitch( icon: Symbols.person_rounded, title: t.settings.requireProfileSelectionOnOpen, diff --git a/lib/screens/settings/async_form_state_mixin.dart b/lib/screens/settings/async_form_state_mixin.dart new file mode 100644 index 00000000..88fb00ed --- /dev/null +++ b/lib/screens/settings/async_form_state_mixin.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; + +/// Mixin for stateful screens that wrap their async work in a busy + error +/// scaffolding. Exposes [busy] and [errorText] state plus a [runAsync] helper +/// that clears the prior error, sets busy, runs the body, captures any +/// exception via an optional [errorMapper], and clears busy in `finally` — +/// all mounted-guarded. +/// +/// Mid-flow state changes (e.g. clearing busy *before* the body finishes so +/// the UI can swap into a "waiting" panel) are still possible via [setBusy] +/// from inside the [runAsync] body — the `finally` clears busy idempotently. +mixin AsyncFormStateMixin on State { + bool _busy = false; + String? _errorText; + + bool get busy => _busy; + String? get errorText => _errorText; + + /// Set busy without forcing a setState when the value didn't change. + void setBusy(bool value) { + if (!mounted || _busy == value) return; + setState(() => _busy = value); + } + + /// Set the error text directly (e.g. for synchronous validation failures + /// or post-success rejections like a duplicate-account guard). + void setErrorText(String? value) { + if (!mounted || _errorText == value) return; + setState(() => _errorText = value); + } + + /// Run [body] surrounded by busy/error scaffolding. Returns the body's + /// value, or `null` if the widget unmounted, the body threw, or the + /// errorMapper translated the exception. + Future runAsync( + Future Function() body, { + String Function(Object error)? errorMapper, + bool Function()? shouldApplyState, + }) async { + bool canApplyState() => mounted && (shouldApplyState?.call() ?? true); + if (!canApplyState()) return null; + setState(() { + _busy = true; + _errorText = null; + }); + try { + return await body(); + } catch (e) { + if (canApplyState()) { + setState(() => _errorText = errorMapper?.call(e) ?? e.toString()); + } + return null; + } finally { + if (canApplyState()) setState(() => _busy = false); + } + } +} diff --git a/lib/screens/settings/connection_persistence.dart b/lib/screens/settings/connection_persistence.dart new file mode 100644 index 00000000..01cba3d9 --- /dev/null +++ b/lib/screens/settings/connection_persistence.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../connection/connection.dart'; +import '../../connection/connection_registry.dart'; +import '../../profiles/profile_connection.dart'; +import '../../profiles/profile_connection_registry.dart'; +import '../../providers/libraries_provider.dart'; +import '../../providers/multi_server_provider.dart'; + +/// Persist a freshly-authenticated [connection] and (optionally) wire it into +/// the active session. +/// +/// Steps, all guarded by `context.mounted`: +/// +/// 1. Upsert [connection] into [ConnectionRegistry] — always. +/// 2. If [bindToProfile] is non-null, upsert a [ProfileConnection] join row +/// so the target profile owns the connection on next activation. +/// 3. If [addToManager] is non-null, invoke it to register the runtime client +/// with [MultiServerProvider]. When the manager reports success and +/// [visibleServerId] is set, extend the visibility filter so the new +/// server shows up immediately. On success the helper kicks off +/// [LibrariesProvider.loadLibraries] (fire-and-forget). +/// +/// Returns whether the manager accepted the connection — callers use this to +/// branch their follow-up navigation. The helper itself does not navigate. +Future persistAndBindConnection({ + required BuildContext context, + required Connection connection, + required ProfileConnection? bindToProfile, + required Future Function()? addToManager, + String? visibleServerId, +}) async { + await context.read().upsert(connection); + + if (!context.mounted) return false; + if (bindToProfile != null) { + await context.read().upsert(bindToProfile); + } + + if (!context.mounted || addToManager == null) return false; + final added = await addToManager(); + if (!context.mounted || !added) return added; + + final mp = context.read(); + if (visibleServerId != null) { + mp.addToVisibleServerIds(visibleServerId); + } + unawaited(context.read().loadLibraries()); + return true; +} diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index ae6c170c..dda9fc11 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; -import 'package:plezy/utils/plex_http_client.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 81e15a14..066f6b63 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -34,12 +34,18 @@ import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/dialog_action_button.dart'; import '../../widgets/settings_section.dart'; +import '../../profiles/active_profile_provider.dart'; +import '../../profiles/profile.dart'; +import '../../profiles/profile_registry.dart'; import 'about_screen.dart'; +import 'add_connection_screen.dart'; import 'appearance_settings_screen.dart'; import 'keyboard_shortcuts_screen.dart'; import 'logs_screen.dart'; import 'playback_settings_screen.dart'; +import '../profile/profile_switch_screen.dart'; import 'trackers_settings_screen.dart'; +import '../../widgets/loading_indicator_box.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -174,6 +180,12 @@ class _SettingsScreenState extends State with FocusableTab { // --- Trackers (unified hub: Trakt + MAL + AniList + Simkl) --- _buildTrackersTile(), + // --- Connections (Jellyfin servers) --- + _buildConnectionsSection(), + + // --- Profiles (kids mode / multi-user) --- + _buildProfilesSection(), + // --- Downloads (inline) --- if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(), @@ -281,6 +293,67 @@ class _SettingsScreenState extends State with FocusableTab { ); } + Widget _buildConnectionsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsSectionHeader(t.connections.sectionTitle), + // Connections are managed per-profile (via the Profiles section + // and each profile's detail screen). The shortcut here just opens + // the picker scoped to the active profile so users can add a Plex + // account, Jellyfin server, or borrow from another profile. + ListTile( + leading: const AppIcon(Symbols.add_link_rounded, fill: 1), + title: Text(t.connections.addConnection), + subtitle: Builder( + builder: (context) { + // Select only the active profile slice — `context.watch` + // would rebuild on every provider notification. + final active = context.select((p) => p.active); + return Text( + active == null + ? t.connections.addConnectionSubtitleNoProfile + : t.connections.addConnectionSubtitleScoped(displayName: active.displayName), + ); + }, + ), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + final active = context.read().active; + Navigator.push(context, MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: active))); + }, + ), + ], + ); + } + + Widget _buildProfilesSection() { + return StreamBuilder>( + stream: context.read().watchProfiles(), + builder: (context, snapshot) { + final count = snapshot.data?.length ?? 0; + // `context.select` so this StreamBuilder doesn't rebuild on every + // ActiveProfileProvider notification — only when the active + // profile's display name actually changes. + final activeName = context.select((p) => p.active?.displayName); + final subtitle = count <= 1 + ? t.profiles.summarySingle + : (activeName != null + ? t.profiles.summaryMultipleWithActive(count: count, activeName: activeName) + : t.profiles.summaryMultiple(count: count)); + return ListTile( + leading: const AppIcon(Symbols.group_rounded, fill: 1), + title: Text(t.profiles.sectionTitle), + subtitle: Text(subtitle), + trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const ProfileSwitchScreen())); + }, + ); + }, + ); + } + Widget _buildDownloadsSection() { final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); @@ -525,7 +598,7 @@ class _SettingsScreenState extends State with FocusableTab { title: Text(hasUpdate ? t.settings.updateAvailable : t.settings.checkForUpdates), subtitle: hasUpdate ? Text(t.update.versionAvailable(version: _updateInfo!['latestVersion'])) : null, trailing: _isCheckingForUpdate - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + ? const LoadingIndicatorBox(size: 24) : const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: _isCheckingForUpdate ? null @@ -548,48 +621,49 @@ class _SettingsScreenState extends State with FocusableTab { final storageService = DownloadStorageService.instance; final isCustom = storageService.isUsingCustomPath(); - unawaited( - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(t.settings.downloads), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t.settings.downloadLocationDescription), - const SizedBox(height: 16), - FutureBuilder( - future: storageService.getCurrentDownloadPathDisplay(), - builder: (context, snapshot) { - return Text( - t.settings.currentPath(path: snapshot.data ?? '...'), - style: Theme.of(context).textTheme.bodySmall, - ); - }, - ), - ], - ), - actions: [ - if (isCustom) - DialogActionButton( - onPressed: () async { - Navigator.pop(dialogContext); - await _resetDownloadLocation(); - }, - label: t.settings.resetToDefault, - ), - DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), - DialogActionButton( - onPressed: () async { - Navigator.pop(dialogContext); - await _selectDownloadLocation(); + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(t.settings.downloads), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.settings.downloadLocationDescription), + const SizedBox(height: 16), + FutureBuilder( + future: storageService.getCurrentDownloadPathDisplay(), + builder: (context, snapshot) { + return Text( + t.settings.currentPath(path: snapshot.data ?? '...'), + style: Theme.of(context).textTheme.bodySmall, + ); }, - label: t.settings.selectFolder, - isPrimary: true, ), ], ), + actions: [ + if (isCustom) + DialogActionButton( + onPressed: () async { + // Run the async work first, then pop — popping first leaves + // setState inside _resetDownloadLocation racing against the + // already-dismissed dialog (and any re-opened instance). + await _resetDownloadLocation(); + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + label: t.settings.resetToDefault, + ), + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), + DialogActionButton( + onPressed: () async { + await _selectDownloadLocation(); + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + label: t.settings.selectFolder, + isPrimary: true, + ), + ], ), ); } @@ -656,50 +730,53 @@ class _SettingsScreenState extends State with FocusableTab { } } - void _showRelayUrlDialog() { + Future _showRelayUrlDialog() async { final controller = TextEditingController(text: _customRelayUrl ?? ''); final saveFocusNode = FocusNode(); - - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: Text(t.settings.watchTogetherRelay), - content: TextField( - controller: controller, - decoration: InputDecoration(labelText: 'URL', hintText: t.settings.watchTogetherRelayHint), - autofocus: true, - textInputAction: TextInputAction.done, - onEditingComplete: () => saveFocusNode.requestFocus(), - ), - actions: [ - DialogActionButton( - onPressed: () async { - controller.clear(); - await _settingsService.write(settings.SettingsService.customRelayUrl, null); - if (mounted) setState(() => _customRelayUrl = null); - if (dialogContext.mounted) Navigator.pop(dialogContext); - }, - label: t.settings.resetToDefault, + // try/finally guarantees disposal on every dismissal path (button, + // back, tap-outside) without depending on `.then` chaining. + try { + await showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text(t.settings.watchTogetherRelay), + content: TextField( + controller: controller, + decoration: InputDecoration(labelText: 'URL', hintText: t.settings.watchTogetherRelayHint), + autofocus: true, + textInputAction: TextInputAction.done, + onEditingComplete: () => saveFocusNode.requestFocus(), ), - DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), - DialogActionButton( - focusNode: saveFocusNode, - onPressed: () async { - final url = controller.text.trim().isEmpty ? null : controller.text.trim(); - await _settingsService.write(settings.SettingsService.customRelayUrl, url); - if (mounted) setState(() => _customRelayUrl = url); - if (dialogContext.mounted) Navigator.pop(dialogContext); - }, - label: t.common.save, - ), - ], - ); - }, - ).then((_) { + actions: [ + DialogActionButton( + onPressed: () async { + controller.clear(); + await _settingsService.write(settings.SettingsService.customRelayUrl, null); + if (mounted) setState(() => _customRelayUrl = null); + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + label: t.settings.resetToDefault, + ), + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), + DialogActionButton( + focusNode: saveFocusNode, + onPressed: () async { + final url = controller.text.trim().isEmpty ? null : controller.text.trim(); + await _settingsService.write(settings.SettingsService.customRelayUrl, url); + if (mounted) setState(() => _customRelayUrl = url); + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + label: t.common.save, + ), + ], + ); + }, + ); + } finally { controller.dispose(); saveFocusNode.dispose(); - }); + } } Future _showClearCacheDialog() async { diff --git a/lib/screens/settings/tracker_library_filter_screen.dart b/lib/screens/settings/tracker_library_filter_screen.dart index a18ec48c..007ac634 100644 --- a/lib/screens/settings/tracker_library_filter_screen.dart +++ b/lib/screens/settings/tracker_library_filter_screen.dart @@ -3,7 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; -import '../../models/plex_library.dart'; +import '../../media/media_library.dart'; import '../../providers/libraries_provider.dart'; import '../../services/settings_service.dart'; import '../../services/trackers/tracker_constants.dart'; @@ -160,12 +160,12 @@ class _TrackerLibraryFilterScreenState extends State ); } - static Map> _groupByServer(List libs) { - final out = >{}; + static Map> _groupByServer(List libs) { + final out = >{}; for (final lib in libs) { final serverId = lib.serverId; if (serverId == null) continue; - out.putIfAbsent(serverId, () => []).add(lib); + out.putIfAbsent(serverId, () => []).add(lib); } return out; } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index d5f32fa0..fd43e7a4 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -2,8 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; -import 'package:path/path.dart' as p; - import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -16,18 +14,21 @@ import 'package:wakelock_plus/wakelock_plus.dart'; import '../mpv/mpv.dart'; import '../mpv/player/platform/player_android.dart'; -import '../../services/bif_thumbnail_service.dart'; -import '../../services/plex_client.dart'; +import '../services/scrub_preview_source.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_server_client.dart'; +import '../services/jellyfin_client.dart'; +import '../services/live_session_tracker.dart'; +import '../services/plex_client.dart'; +import '../utils/session_identifier.dart'; +import '../database/app_database.dart'; +import '../media/media_version.dart'; import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; -import '../services/plex_api_cache.dart'; -import '../models/plex_media_version.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_video_playback_data.dart'; import '../models/transcode_quality_preset.dart'; -import '../utils/content_utils.dart'; -import '../utils/plex_cache_parser.dart'; -import '../models/plex_media_info.dart'; +import '../media/media_source_info.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; @@ -58,12 +59,13 @@ import '../providers/shader_provider.dart'; import '../providers/user_profile_provider.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; +import '../utils/log_redaction_manager.dart'; +import '../utils/live_tv_player_navigation.dart'; import '../utils/player_utils.dart'; import '../utils/orientation_helper.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; -import '../utils/plex_url_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/video_controls/video_controls.dart'; @@ -93,14 +95,47 @@ Future _setWakelock(bool enabled) async { } } +/// Builds a [TrackPreferencePersister] that fans the language-preference + +/// stream-selection writes out to a [PlexClient] resolved lazily on each +/// call. Returns a no-op-on-null persister so the [TrackManager] doesn't +/// have to import [PlexClient] itself; the resolver returning null (e.g. +/// when the active server is Jellyfin) makes the call short-circuit. +TrackPreferencePersister _plexTrackPersister(PlexClient? Function() resolve) { + return ({ + required String id, + required int partId, + required String trackType, + String? languageCode, + int? streamID, + }) async { + final client = resolve(); + if (client == null) return; + final futures = []; + if (languageCode != null && (trackType == 'subtitle' || languageCode.isNotEmpty)) { + futures.add( + trackType == 'audio' + ? client.setMetadataPreferences(id, audioLanguage: languageCode) + : client.setMetadataPreferences(id, subtitleLanguage: languageCode), + ); + } + if (streamID != null) { + futures.add( + trackType == 'audio' + ? client.selectStreams(partId, audioStreamID: streamID, allParts: true) + : client.selectStreams(partId, subtitleStreamID: streamID, allParts: true), + ); + } + await Future.wait(futures); + }; +} + class VideoPlayerScreen extends StatefulWidget { - final PlexMetadata metadata; + final MediaItem metadata; final AudioTrack? preferredAudioTrack; final SubtitleTrack? preferredSubtitleTrack; final SubtitleTrack? preferredSecondarySubtitleTrack; final int selectedMediaIndex; final bool isOffline; - final PlexVideoPlaybackData? playbackData; /// Quality preset override for this playback. When `null`, the screen uses /// the user's default from [SettingsProvider]. @@ -123,7 +158,15 @@ class VideoPlayerScreen extends StatefulWidget { final List? liveChannels; final int? liveCurrentChannelIndex; final String? liveDvrKey; - final PlexClient? liveClient; + + /// Backend-neutral client typing. The four in-player live ops branch on + /// `client is PlexClient` / `client is JellyfinClient` at their use sites: + /// Plex tunes a transcode session and gets capture-buffer updates; + /// Jellyfin uses its `/Sessions/Playing*` endpoints for progress reporting + /// and re-opens [liveStreamUrl] for retry. Tune (Plex-only by protocol) + /// and seek (Plex-only — Jellyfin live channels aren't seekable) gate + /// explicitly on `client is PlexClient`. + final MediaServerClient? liveClient; final String? liveSessionIdentifier; final String? liveSessionPath; @@ -135,7 +178,6 @@ class VideoPlayerScreen extends StatefulWidget { this.preferredSecondarySubtitleTrack, this.selectedMediaIndex = 0, this.isOffline = false, - this.playbackData, this.selectedQualityPreset, this.selectedAudioStreamId, this.reusedSessionIdentifier, @@ -159,36 +201,42 @@ class VideoPlayerScreenState extends State with WidgetsBindin static const int _liveEdgeThresholdSeconds = 5; // Track the currently active video to guard against duplicate navigation - static String? _activeRatingKey; + static String? _activeId; static int? _activeMediaIndex; - static String? get activeRatingKey => _activeRatingKey; + static String? get activeId => _activeId; static int? get activeMediaIndex => _activeMediaIndex; Player? player; bool _isPlayerInitialized = false; - late PlexMetadata _currentMetadata; - PlexMetadata? _nextEpisode; - PlexMetadata? _previousEpisode; + String? _playerInitializationError; + late MediaItem _currentMetadata; + MediaItem? _nextEpisode; + MediaItem? _previousEpisode; bool _isLoadingNext = false; bool _isLoadingPrevious = false; bool _isSwappingEpisode = false; bool _showPlayNextDialog = false; bool _isPhone = false; - List _availableVersions = []; - PlexMediaInfo? _currentMediaInfo; + List _availableVersions = []; + MediaSourceInfo? _currentMediaInfo; // Transcode / quality state late TranscodeQualityPreset _selectedQualityPreset; int? _selectedAudioStreamId; bool _isTranscoding = false; + bool _effectiveIsOffline = false; bool _serverSupportsTranscoding = false; // Kicked off early in `_initializePlayer` for online non-live playback so // the metadata fetch (and transcode-decision HTTP, if non-original preset) // overlaps with MPV property configuration. Awaited inside `_startPlayback` // immediately before `player.open()` needs the video URL. Future? _playbackDataFuture; - Map? _plexHeaders; + // HTTP headers attached to the player's `Media` request — `X-Plex-Token` + // for Plex, empty for Jellyfin (token rides in the URL there). Sourced + // from `MediaServerClient.streamHeaders` so the player code path stays + // backend-neutral. + Map? _streamHeaders; // Fired in parallel with MPV setup so the OS audio-focus negotiation // (~90ms on Android) doesn't sit on the critical path. Awaited before // `player.open()` so the semantics are unchanged — we just eat the cost @@ -196,6 +244,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future? _audioFocusFuture; late final String _playbackSessionIdentifier; late final String _playbackTranscodeSessionId; + String? _playbackPlaySessionId; + String? _playbackPlayMethod; StreamSubscription? _errorSubscription; StreamSubscription? _playingSubscription; StreamSubscription? _completedSubscription; @@ -215,18 +265,28 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isDisposingForNavigation = false; bool _isHandlingBack = false; - BifThumbnailService? _bifService; + ScrubPreviewSource? _scrubPreviewSource; // Live TV channel navigation int _liveChannelIndex = -1; String? _liveChannelName; + MediaServerClient? _liveClient; + String? _liveDvrKey; + String? _liveStreamUrl; + String? _liveItemId; String? _liveSessionIdentifier; String? _liveSessionPath; Timer? _liveTimelineTimer; + int _liveTimelineGeneration = 0; DateTime? _livePlaybackStartTime; - String? _liveRatingKey; + String? _liveProgramId; int? _liveDurationMs; + // Jellyfin live TV heartbeat state machine. The Plex live branch keeps + // its bespoke capture-buffer flow inline; this tracker only collapses + // the Jellyfin started/progress/stopped transition. + JellyfinLiveSessionTracker _jellyfinLiveSession = JellyfinLiveSessionTracker(); + // Live TV time-shift CaptureBuffer? _captureBuffer; int? _programBeginsAt; @@ -301,12 +361,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin CompanionRemoteProvider? _companionRemoteProvider; VoidCallback? _savedOnHome; - /// Get the correct PlexClient for this metadata's server - PlexClient _getClientForMetadata(BuildContext context) { - return context.getClientForServer(_currentMetadata.serverId!); + /// Backend-neutral lookup. Returns whichever client (Plex or Jellyfin) + /// owns this item. Used by the playback-init path in [_initializePlayer]. + MediaServerClient? _getMediaServerClient(BuildContext context) { + final id = _currentMetadata.serverId; + if (id == null) return null; + return context.read().serverManager.getClient(id); } - Uint8List? _getThumbnailData(Duration time) => _bifService?.getThumbnail(time); + bool get _isOfflinePlayback => widget.isOffline || _effectiveIsOffline; + + ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time); final ValueNotifier _isBuffering = ValueNotifier(false); // Track if video is currently buffering final ValueNotifier _hasFirstFrame = ValueNotifier(false); // Track if first video frame has rendered @@ -320,14 +385,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin super.initState(); _currentMetadata = widget.metadata; - _activeRatingKey = widget.metadata.ratingKey; + _activeId = widget.metadata.id; _activeMediaIndex = widget.selectedMediaIndex; // Transcode session identifiers — reused across quality/version/audio // switches so the server-side transcode session is preserved. - _playbackSessionIdentifier = widget.reusedSessionIdentifier ?? PlexClient.generateSessionIdentifier(); - _playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? PlexClient.generateSessionIdentifier(); + _playbackSessionIdentifier = widget.reusedSessionIdentifier ?? generateSessionIdentifier(); + _playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? generateSessionIdentifier(); _selectedAudioStreamId = widget.selectedAudioStreamId; + _effectiveIsOffline = widget.isOffline; // Quality preset is resolved later when the SettingsProvider is available; // see _resolveQualityPreset() called from _initializePlayer. _selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original; @@ -335,8 +401,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Initialize live TV channel tracking _liveChannelIndex = widget.liveCurrentChannelIndex ?? -1; _liveChannelName = widget.liveChannelName; + _liveClient = widget.liveClient; + _liveDvrKey = widget.liveDvrKey; + _liveStreamUrl = widget.liveStreamUrl; + _liveItemId = widget.metadata.id; _liveSessionIdentifier = widget.liveSessionIdentifier; _liveSessionPath = widget.liveSessionPath; + if (widget.liveClient is JellyfinClient && widget.liveSessionIdentifier != null) { + _jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: widget.liveSessionIdentifier); + } // Initialize Play Next dialog focus nodes _playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel'); @@ -371,12 +444,18 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Defer both operations until after the first frame to avoid calling // notifyListeners() during build WidgetsBinding.instance.addPostFrameCallback((_) { - // If this item doesn't have a playQueueItemID, it's a standalone item - // Clear any existing queue so next/previous work correctly for this content - if (widget.metadata.playQueueItemID == null) { - playbackState.clearShuffle(); + // Keep the queue when this item belongs to it — that covers both + // server-side queues (Plex `playQueueItemId`) and client-side + // launcher-seeded queues (Jellyfin playlist/collection, with + // synthetic ids tracked in the provider). For genuine standalone + // playback (continue-watching, direct episode tap with no queue + // launcher) clear any stale queue so prev/next stays consistent. + final meta = widget.metadata; + final inActiveQueue = playbackState.isQueueActive && playbackState.playQueueItemIdFor(meta) != null; + if (inActiveQueue) { + playbackState.setCurrentItem(meta); } else { - playbackState.setCurrentItem(widget.metadata); + playbackState.clearShuffle(); } }); } catch (e) { @@ -600,6 +679,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future _initializePlayer() async { try { + if (mounted) { + setState(() => _playerInitializationError = null); + } // Load buffer size from settings final settingsService = await SettingsService.getInstance(); _videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled); @@ -635,8 +717,22 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Skipped for live TV (has its own tune path) and offline (its own // branch in _startPlayback). if (!widget.isLive && !widget.isOffline && mounted) { - final client = _getClientForMetadata(context); - _plexHeaders = client.config.headers; + // Backend-neutral lookup so Jellyfin items also flow through here. + // Plex-specific transcoder caching is gated on capabilities below; + // Jellyfin's `streamHeaders` is empty because it embeds api_key in + // the query string, while Plex returns the X-Plex-* identity headers. + final genericClient = _getMediaServerClient(context); + if (genericClient == null) { + throw StateError('No client registered for ${_currentMetadata.serverId}'); + } + _streamHeaders = genericClient.streamHeaders; + // Single source of truth — `capabilities.videoTranscoding` reflects + // the per-Plex-server probe (false on Plex installs without a working + // transcoder) and is hard-false on Jellyfin. The long-press context + // menu's quality picker reads the same flag. Alternate-version + // selection still works regardless because it's gated on + // `availableVersions.length`, not transcoding capability. + _serverSupportsTranscoding = genericClient.capabilities.videoTranscoding; if (widget.selectedQualityPreset == null) { try { final settingsProvider = context.read(); @@ -647,13 +743,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin } else { _selectedQualityPreset = widget.selectedQualityPreset!; } - _serverSupportsTranscoding = client.serverSupportsVideoTranscodingCached; - final playbackService = PlaybackInitializationService(client: client, database: PlexApiCache.instance.database); + final playbackService = PlaybackInitializationService( + client: genericClient, + database: context.read(), + ); _playbackDataFuture = playbackService.getPlaybackData( metadata: _currentMetadata, selectedMediaIndex: widget.selectedMediaIndex, preferOffline: _selectedQualityPreset.isOriginal, - playbackData: widget.playbackData, qualityPreset: _selectedQualityPreset, selectedAudioStreamId: _selectedAudioStreamId, sessionIdentifier: _playbackSessionIdentifier, @@ -879,7 +976,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // When server comes back online while buffering, force mpv to reconnect // immediately instead of waiting for ffmpeg's exponential backoff - if (!widget.isOffline && !widget.isLive) { + if (!_isOfflinePlayback && !widget.isLive) { final serverId = widget.metadata.serverId; if (serverId != null) { if (!mounted) return; @@ -968,11 +1065,21 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (mounted) { setState(() { _isPlayerInitialized = false; + _playerInitializationError = _safePlaybackErrorMessage(e); }); } } } + String _safePlaybackErrorMessage(Object error) { + final raw = error.toString(); + final redacted = LogRedactionManager.redact(raw); + if (raw.contains('No client registered')) { + return t.messages.errorLoading(error: 'Server is unavailable for the active profile'); + } + return t.messages.errorLoading(error: redacted); + } + /// Windows display mode matching service. DisplayModeService? _displayModeService; @@ -1124,6 +1231,70 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } + /// Wire the per-item playback services that need to (re)bind whenever + /// the active media item changes: [PlaybackProgressTracker], + /// [MediaControlsManager.updateMetadata], and the + /// Discord/Trakt/Tracker scrobblers. Both [_initializeServices] and + /// [_swapEpisodeInPip] call this so the two flows can't drift. + /// + /// The caller is responsible for ensuring `player != null` and (if the + /// media-controls metadata refresh should run) for having created + /// [_mediaControlsManager] before the first call. + void _wirePerItemPlaybackServices({ + required MediaItem metadata, + required MediaServerClient? mediaClient, + required OfflineWatchSyncService? offlineWatchService, + String? playSessionId, + String? playMethod, + MediaSourceInfo? mediaInfo, + }) { + if (player == null) return; + + // Progress tracker — offline mode queues for later sync; online mode + // dispatches to the right backend through the neutral client. + if (_isOfflinePlayback) { + _progressTracker = PlaybackProgressTracker( + client: null, + metadata: metadata, + player: player!, + isOffline: true, + offlineWatchService: offlineWatchService, + ); + _progressTracker!.startTracking(); + } else if (mediaClient != null) { + _progressTracker = PlaybackProgressTracker( + client: mediaClient, + metadata: metadata, + player: player!, + playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'), + playSessionId: playSessionId, + mediaInfo: mediaInfo, + ); + _progressTracker!.startTracking(); + } + + // Media controls metadata. Fire-and-forget — the OS plugin downloads + // the poster synchronously inside `setMetadata` (~270 ms); the + // controls populate a beat after first frame which is fine. + if (_mediaControlsManager != null) { + unawaited( + _mediaControlsManager!.updateMetadata( + metadata: metadata, + client: mediaClient, + duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null, + ), + ); + } + + // Scrobblers — Discord RPC, Trakt, unified tracker. All accept the + // neutral [MediaServerClient]; null short-circuits cleanly. + if (mediaClient != null) { + unawaited(DiscordRPCService.instance.startPlayback(metadata, mediaClient)); + unawaited(TraktScrobbleService.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive)); + unawaited(TrackerCoordinator.instance.startPlayback(metadata, mediaClient, isLive: widget.isLive)); + } + } + /// Initialize the service layer Future _initializeServices() async { if (!mounted || player == null) return; @@ -1134,28 +1305,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin return; } - // Get client (null in offline mode) - final client = widget.isOffline ? null : _getClientForMetadata(context); + // Get client (null in offline mode). Backend-neutral lookup so Jellyfin + // items also wire a [PlaybackProgressTracker]; the tracker dispatches + // to the right backend's reporting endpoints internally. + final mediaClient = _isOfflinePlayback ? null : _getMediaServerClient(context); + final offlineWatchService = context.read(); - // Initialize progress tracker - if (widget.isOffline) { - // Offline mode: queue progress updates for later sync - final offlineWatchService = context.read(); - _progressTracker = PlaybackProgressTracker( - client: null, - metadata: _currentMetadata, - player: player!, - isOffline: true, - offlineWatchService: offlineWatchService, - ); - _progressTracker!.startTracking(); - } else if (client != null) { - // Online mode: send progress to server - _progressTracker = PlaybackProgressTracker(client: client, metadata: _currentMetadata, player: player!); - _progressTracker!.startTracking(); - } - - // Initialize media controls manager + // Initialize media controls manager (must exist before the per-item + // helper wires its metadata update). _mediaControlsManager = MediaControlsManager(); // Set up media control event handling @@ -1199,16 +1356,16 @@ class VideoPlayerScreenState extends State with WidgetsBindin } }); - // Update media metadata (client can be null in offline mode - artwork won't - // be shown). Fire-and-forget: the OS media-controls plugin downloads the - // poster synchronously inside `setMetadata` (~270ms). The controls populate - // a beat after first frame which is fine; it's not visible during loading. - unawaited( - _mediaControlsManager!.updateMetadata( - metadata: _currentMetadata, - client: client, - duration: _currentMetadata.duration != null ? Duration(milliseconds: _currentMetadata.duration!) : null, - ), + // Wire progress tracker, media-controls metadata, and the + // Discord/Trakt/Tracker scrobblers. Shared with [_swapEpisodeInPip] + // so the two flows can't drift. + _wirePerItemPlaybackServices( + metadata: _currentMetadata, + mediaClient: mediaClient, + offlineWatchService: offlineWatchService, + playSessionId: _playbackPlaySessionId, + playMethod: _playbackPlayMethod, + mediaInfo: _currentMediaInfo, ); if (!mounted) return; @@ -1244,13 +1401,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin _mediaControlsSeekableSubscription = player!.streams.seekable.listen((_) { unawaited(_syncMediaControlsAvailability()); }); - - // Start Discord Rich Presence for current media - if (client != null) { - unawaited(DiscordRPCService.instance.startPlayback(_currentMetadata, client)); - unawaited(TraktScrobbleService.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive)); - unawaited(TrackerCoordinator.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive)); - } } /// Ensure a play queue exists for sequential episode playback @@ -1258,7 +1408,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!mounted) return; // Skip play queue in offline mode (requires server connection) - if (widget.isOffline) return; + if (_isOfflinePlayback) return; // Skip play queue for live TV (would interfere with tuner session) if (widget.isLive) return; @@ -1268,37 +1418,47 @@ class VideoPlayerScreenState extends State with WidgetsBindin return; } + // Plex-only — Jellyfin's local queue is published by + // EpisodeNavigationService._ensureLocalEpisodeQueue from + // _loadAdjacentEpisodes, so this method is a no-op for it. + if (_currentMetadata.backend != MediaBackend.plex) return; + try { - final client = _getClientForMetadata(context); + final client = context.getPlexClientForServer(_currentMetadata.serverId!); final playbackState = context.read(); // Determine the show's rating key - // For episodes, grandparentRatingKey points to the show - final showRatingKey = _currentMetadata.grandparentRatingKey; + // For episodes, grandparentId points to the show + final showRatingKey = _currentMetadata.grandparentId; if (showRatingKey == null) { - appLogger.d('Episode missing grandparentRatingKey, skipping play queue creation'); + appLogger.d('Episode missing grandparentId, skipping play queue creation'); return; } - // Check if there's already an active queue + // Check if there's already an active queue for THIS show. + // A leftover queue from a different show or — more importantly — + // from a different backend (Jellyfin's local queue is published + // here too) would otherwise mask the new show's navigation. final existingContextKey = playbackState.shuffleContextKey; final isQueueActive = playbackState.isQueueActive; - if (isQueueActive) { - // A queue already exists (could be shuffle, playlist, or sequential) - // Just update the current item, don't create a new queue + if (isQueueActive && existingContextKey == showRatingKey) { playbackState.setCurrentItem(_currentMetadata); appLogger.d('Using existing play queue (context: $existingContextKey)'); return; } + if (isQueueActive) { + appLogger.d('Resetting stale play queue (was: $existingContextKey, now: $showRatingKey)'); + playbackState.clearShuffle(); + } // Create a new sequential play queue for the show appLogger.d('Creating sequential play queue for show $showRatingKey'); final playQueue = await client.createShowPlayQueue( showRatingKey: showRatingKey, shuffle: 0, // Sequential order - startingEpisodeKey: _currentMetadata.ratingKey, + startingEpisodeKey: _currentMetadata.id, ); if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) { @@ -1306,7 +1466,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey); // Set the client for loading more items - playbackState.setClient(client); + playbackState.setPlayQueueWindowFetcher(client.getPlayQueue); appLogger.d('Sequential play queue created with ${playQueue.items!.length} items'); } @@ -1319,7 +1479,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin Future _loadAdjacentEpisodes() async { if (!mounted || widget.isLive) return; - if (widget.isOffline) { + if (_isOfflinePlayback) { // Offline mode: find next/previous from downloaded episodes _loadAdjacentEpisodesOffline(); return; @@ -1348,7 +1508,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin void _loadAdjacentEpisodesOffline() { if (!_currentMetadata.isEpisode) return; - final showKey = _currentMetadata.grandparentRatingKey; + final showKey = _currentMetadata.grandparentId; if (showKey == null) return; try { @@ -1358,7 +1518,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (episodes.isEmpty) return; // Sort by aired date, falling back to season/episode number - final sorted = List.from(episodes) + final sorted = List.from(episodes) ..sort((a, b) { final aDate = a.originallyAvailableAt ?? ''; final bDate = b.originallyAvailableAt ?? ''; @@ -1373,7 +1533,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin }); // Find current episode in the sorted list - final currentIdx = sorted.indexWhere((ep) => ep.ratingKey == _currentMetadata.ratingKey); + final currentIdx = sorted.indexWhere((ep) => ep.id == _currentMetadata.id); if (currentIdx == -1) return; @@ -1399,8 +1559,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin await _setLiveStreamOptions(); String streamUrl; - if (widget.liveStreamUrl != null) { - streamUrl = widget.liveStreamUrl!; + if (_liveStreamUrl != null) { + streamUrl = _liveStreamUrl!; + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; } else { // Tune channel inside the player (shows loading spinner while tuning) final channels = widget.liveChannels; @@ -1409,18 +1571,26 @@ class VideoPlayerScreenState extends State with WidgetsBindin throw Exception('No channel to tune'); } final channel = channels[channelIndex]; - appLogger.d('Tune: dvrKey=${widget.liveDvrKey} channelKey=${channel.key}'); - final client = widget.liveClient!; - final tuneResult = await client.tuneChannel(widget.liveDvrKey!, channel.key); + appLogger.d('Tune: dvrKey=$_liveDvrKey channelKey=${channel.key}'); + final client = _liveClient; + if (client is! PlexClient) { + throw StateError( + 'In-player live tuning is Plex-only; got ${client?.runtimeType ?? 'null'}. ' + 'Jellyfin live TV must pass a pre-resolved liveStreamUrl via LiveTvSupport.resolveStreamUrl.', + ); + } + final dvrKey = _liveDvrKey; + if (dvrKey == null) throw Exception('No DVR to tune'); + final tuneResult = await client.tuneChannel(dvrKey, channel.key); if (tuneResult == null) throw Exception('Failed to tune channel'); _liveSessionIdentifier = tuneResult.sessionIdentifier; _liveSessionPath = tuneResult.sessionPath; - _liveRatingKey = tuneResult.metadata.ratingKey; + _liveProgramId = tuneResult.metadata.ratingKey; _liveDurationMs = tuneResult.metadata.duration; _captureBuffer = tuneResult.captureBuffer; _programBeginsAt = tuneResult.beginsAt; - _transcodeSessionId = PlexClient.generateSessionIdentifier(); + _transcodeSessionId = generateSessionIdentifier(); // Show "Watch from Start" dialog when an existing capture session has >60s of history. // On a fresh tune (no active recording), the buffer is empty so this won't trigger. @@ -1456,7 +1626,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); if (streamPath == null || !mounted) throw Exception('Failed to build stream path'); - streamUrl = '${client.config.baseUrl}$streamPath'.withPlexToken(client.config.token); + streamUrl = client.buildLiveStreamUrl(streamPath); + _liveStreamUrl = streamUrl; // Track stream start epoch for position calculations if (offsetSeconds != null) { @@ -1495,23 +1666,41 @@ class VideoPlayerScreenState extends State with WidgetsBindin } // Capture providers before async gaps - final offlineWatchService = widget.isOffline ? context.read() : null; + final offlineWatchService = context.read(); try { PlaybackInitializationResult result; - Map? plexHeaders; + Map? streamHeaders; if (widget.isOffline) { - // Offline mode: get video path from downloads without requiring server - result = await _startOfflinePlayback(); + // Offline mode: route through PlaybackInitializationService with a + // (possibly null) cached client. The service reads cached media + // info via the client when available, falls back to local file + + // sidecar subtitles otherwise. + final cachedSourceClient = _getMediaServerClient(context); + final offlineService = PlaybackInitializationService( + client: cachedSourceClient, + database: context.read(), + ); + result = await offlineService.getPlaybackData( + metadata: _currentMetadata, + selectedMediaIndex: widget.selectedMediaIndex, + preferOffline: true, + ); + if (result.videoUrl == null) { + throw PlaybackException(t.messages.fileInfoNotAvailable); + } } else { // Online path: `_playbackDataFuture` was kicked off in `_initializePlayer` // in parallel with MPV setup. Quality preset + server capabilities + // headers were resolved there too. Just await the result. - plexHeaders = _plexHeaders; + streamHeaders = _streamHeaders; result = await _playbackDataFuture!; _isTranscoding = result.isTranscoding; + _effectiveIsOffline = result.isOffline; + _playbackPlaySessionId = result.playSessionId; + _playbackPlayMethod = result.playMethod; if (result.activeAudioStreamId != null) { _selectedAudioStreamId = result.activeAudioStreamId; } @@ -1558,23 +1747,23 @@ class VideoPlayerScreenState extends State with WidgetsBindin // In offline mode, prefer locally tracked progress over the cached server value // since the user may have watched further since downloading. Duration? resumePosition; - if (widget.isOffline) { + if (_isOfflinePlayback) { final globalKey = _currentMetadata.globalKey; - final localOffset = await offlineWatchService!.getLocalViewOffset(globalKey); + final localOffset = await offlineWatchService.getLocalViewOffset(globalKey); if (localOffset != null && localOffset > 0) { resumePosition = Duration(milliseconds: localOffset); appLogger.d('Resuming offline playback from local progress: ${localOffset}ms'); } } - resumePosition ??= _currentMetadata.viewOffset != null - ? Duration(milliseconds: _currentMetadata.viewOffset!) + resumePosition ??= _currentMetadata.viewOffsetMs != null + ? Duration(milliseconds: _currentMetadata.viewOffsetMs!) : null; // Enable FFmpeg auto-reconnect for VOD streams (covers network drops // up to 10 min). Forwarded to the Kotlin layer on Android so MPV // inherits it on the ExoPlayer→MPV fallback path (see // _onBackendSwitched), so keep it unconditional. - if (!widget.isOffline && !widget.isLive) { + if (!_isOfflinePlayback && !widget.isLive) { await player!.setProperty( 'stream-lavf-o', 'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600', @@ -1588,7 +1777,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // them in a single prepare() — no media reload needed for selection. // MPV (all platforms including Android): external subs added after open via sub-add. await player!.open( - Media(result.videoUrl!, start: resumePosition, headers: plexHeaders), + Media(result.videoUrl!, start: resumePosition, headers: streamHeaders), play: !willAutoSwitch && (isExoPlayer || !hasExternalSubs), externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, ); @@ -1610,7 +1799,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } // Attach player to Watch Together session for sync (if in session) - if (mounted && !widget.isOffline) { + if (mounted && !_isOfflinePlayback) { _attachToWatchTogetherSession(); _notifyWatchTogetherMediaChange(); } @@ -1619,31 +1808,32 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Update available versions from the playback data if (mounted) { setState(() { - _availableVersions = result.availableVersions.cast(); + _availableVersions = result.availableVersions; _currentMediaInfo = result.mediaInfo; - _bifService?.dispose(); - _bifService = null; + _scrubPreviewSource?.dispose(); + _scrubPreviewSource = null; }); - // Download and cache BIF thumbnail file - if (_currentMediaInfo?.partId != null && !widget.isOffline) { - final partId = _currentMediaInfo!.partId!; - final client = _getClientForMetadata(context); - final service = BifThumbnailService(); + // Backend-neutral scrub-thumbnail load. The factory dispatches to + // BIF (Plex) or trickplay sprite sheets (Jellyfin) and returns null + // when the inputs aren't sufficient. Guard against media-change + // races during the async load. + final mediaClient = context.tryGetMediaClientForServer(_currentMetadata.serverId); + final mediaInfoAtStart = _currentMediaInfo; + if (mediaInfoAtStart != null && !_isOfflinePlayback && mediaClient != null) { unawaited( - service - .load(client, partId) - .then((_) { - // Guard against media having changed while the download was in flight - if (mounted && _currentMediaInfo?.partId == partId) { - setState(() => _bifService = service); + mediaClient + .createScrubPreviewSource(item: _currentMetadata, mediaSource: mediaInfoAtStart) + .then((service) { + if (service == null) return; + if (mounted && identical(_currentMediaInfo, mediaInfoAtStart)) { + setState(() => _scrubPreviewSource = service); } else { service.dispose(); } }) .catchError((e, st) { - appLogger.w('BIF thumbnail load failed for part $partId', error: e, stackTrace: st); - service.dispose(); + appLogger.w('Scrub preview load failed', error: e, stackTrace: st); }), ); } @@ -1675,11 +1865,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - // Track manager: owns track selection, external subtitle loading, and server sync + // Track manager: owns track selection, external subtitle loading, and Plex + // immediate stream writes. Jellyfin persists selected stream indexes through + // playback progress reports instead. + final plexTrackClient = mediaClient is PlexClient ? mediaClient : null; _trackManager = TrackManager( player: player!, isActive: () => mounted && player != null, - getClient: () => _getClientForMetadata(context), + persistTrackPreference: plexTrackClient != null ? _plexTrackPersister(() => plexTrackClient) : null, getProfileSettings: () => context.read().profileSettings, waitForProfileSettings: _waitForProfileSettingsIfNeeded, metadata: _currentMetadata, @@ -1724,7 +1917,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (willAutoSwitch && mounted && player != null) { _frameRateMatchingApplied = true; final delaySec = settingsService.read(SettingsService.displaySwitchDelay); - final durationMs = _currentMetadata.duration ?? player!.state.duration.inMilliseconds; + final durationMs = _currentMetadata.durationMs ?? player!.state.duration.inMilliseconds; _suppressMediaPauseDuringFrameRateSwitch = true; Future.delayed(Duration(seconds: 2 + delaySec + 1), () { _suppressMediaPauseDuringFrameRateSwitch = false; @@ -1776,86 +1969,6 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - /// Start playback for offline/downloaded content - Future _startOfflinePlayback() async { - final downloadProvider = context.read(); - - // Debug: log metadata info - appLogger.d('Offline playback - serverId: ${_currentMetadata.serverId}, ratingKey: ${_currentMetadata.ratingKey}'); - - final globalKey = _currentMetadata.globalKey; - appLogger.d('Looking up video with globalKey: $globalKey'); - - final videoPath = await downloadProvider.getVideoFilePath(globalKey); - if (videoPath == null) { - appLogger.e('Video file path not found for globalKey: $globalKey'); - throw PlaybackException(t.messages.fileInfoNotAvailable); - } - - appLogger.d('Starting offline playback: $videoPath'); - - // Load cached media info so track selection (audio language) works offline - PlexMediaInfo? mediaInfo; - try { - final serverId = _currentMetadata.serverId; - if (serverId != null) { - final cached = await PlexApiCache.instance.get(serverId, '/library/metadata/${_currentMetadata.ratingKey}'); - final metadataJson = PlexCacheParser.extractFirstMetadata(cached); - if (metadataJson != null) { - mediaInfo = PlexMediaInfo.fromMetadataJson(metadataJson); - } - appLogger.d( - 'Offline media info: cached=${cached != null}, hasMedia=${metadataJson?['Media'] != null}, ' - 'audioTracks=${mediaInfo?.audioTracks.length ?? 0}, subtitleTracks=${mediaInfo?.subtitleTracks.length ?? 0}', - ); - } - } catch (e) { - appLogger.d('Could not load cached media info for offline playback', error: e); - } - - // Discover downloaded subtitle files for offline playback - final offlineSubtitles = []; - if (!videoPath.startsWith('content://')) { - final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs'); - var subsDir = Directory(subsPath); - - // Fallback: legacy structure uses 'subtitles/' in parent dir - if (!await subsDir.exists()) { - final legacyDir = Directory(p.join(File(videoPath).parent.path, 'subtitles')); - if (await legacyDir.exists()) subsDir = legacyDir; - } - - if (await subsDir.exists()) { - final entities = await subsDir.list().toList(); - for (final entity in entities) { - if (entity is! File) continue; - final fileName = p.basenameWithoutExtension(entity.path); - final trackId = int.tryParse(fileName); - - final plexTrack = trackId != null - ? mediaInfo?.subtitleTracks.where((t) => t.id == trackId).firstOrNull - : null; - - offlineSubtitles.add( - SubtitleTrack.uri( - 'file://${entity.path}', - title: plexTrack?.displayTitle ?? plexTrack?.language ?? 'Subtitle $fileName', - language: plexTrack?.languageCode, - ), - ); - } - } - } - - return PlaybackInitializationResult( - availableVersions: [], - videoUrl: videoPath.contains('://') ? videoPath : 'file://$videoPath', - mediaInfo: mediaInfo, - externalSubtitles: offlineSubtitles, - isOffline: true, - ); - } - /// Initialize VideoFilterManager and VideoPIPManager if not already set up. /// Called from both live TV and VOD playback paths. Future _initVideoFilterAndPip() async { @@ -2073,13 +2186,13 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Notify watch together session of current media change (host only) /// If [metadata] is provided, uses that instead of _currentMetadata (for episode navigation) - void _notifyWatchTogetherMediaChange({PlexMetadata? metadata}) { + void _notifyWatchTogetherMediaChange({MediaItem? metadata}) { final targetMetadata = metadata ?? _currentMetadata; try { final watchTogether = context.read(); if (watchTogether.isHost && watchTogether.isInSession) { watchTogether.setCurrentMedia( - ratingKey: targetMetadata.ratingKey, + ratingKey: targetMetadata.id, serverId: targetMetadata.serverId!, mediaTitle: targetMetadata.displayTitle, ); @@ -2097,17 +2210,23 @@ class VideoPlayerScreenState extends State with WidgetsBindin appLogger.d('WatchTogether: Guest handling media switch to $title'); - // Fetch metadata for the new episode + // Fetch metadata for the new episode. WatchTogether's sync transport is + // backend-neutral (sync_message.dart carries `ratingKey` + `serverId` + // over WebRTC); resolving the item is just a `fetchItem` on whichever + // backend the guest has registered for [serverId]. final multiServer = context.read(); final client = multiServer.getClientForServer(serverId); if (client == null) { appLogger.w('WatchTogether: Server $serverId not found for media switch'); + if (mounted) showAppSnackBar(context, t.watchTogether.guestSwitchUnavailable); return; } - final metadata = await client.getMetadataWithImages(ratingKey); - if (metadata == null || !mounted) { + final metadata = await client.fetchItem(ratingKey); + if (!mounted) return; + if (metadata == null) { appLogger.w('WatchTogether: Could not fetch metadata for $ratingKey'); + showAppSnackBar(context, t.watchTogether.guestSwitchFailed); return; } @@ -2310,8 +2429,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin PipService.onAutoPipEntering = null; _videoFilterManager?.dispose(); - // Release cached BIF thumbnail data - _bifService?.dispose(); + // Release cached scrub-thumbnail data (BIF or trickplay) + _scrubPreviewSource?.dispose(); // Mark sleep timer for restart if truly exiting (not episode transition) if (!_isReplacingWithVideo) { @@ -2416,8 +2535,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (playerToDispose != null) { unawaited(playerToDispose.dispose()); } - if (_activeRatingKey == _currentMetadata.ratingKey) { - _activeRatingKey = null; + if (_activeId == _currentMetadata.id) { + _activeId = null; _activeMediaIndex = null; } super.dispose(); @@ -2549,7 +2668,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin return; } - showGlobalErrorSnackBar(_lastLogError ?? err.message); + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message)); _handleBackButton(); } @@ -2564,10 +2683,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin } if (log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal) { appLogger.e('[Player LOG ERROR] [${log.prefix}] ${log.text}'); - _lastLogError = log.text.trim(); + _lastLogError = _redactPlayerError(log.text.trim()); } } + String _redactPlayerError(String message) => LogRedactionManager.redact(message); + Future _showServerLimitDialog() async { if (!mounted) return; await showServerLimitDialog(context); @@ -2622,11 +2743,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin final manager = _mediaControlsManager; final currentPlayer = player; if (manager != null && currentPlayer != null) { - final client = widget.isOffline ? null : _getClientForMetadata(context); + final client = _isOfflinePlayback ? null : _getMediaServerClient(context); await manager.updateMetadata( metadata: _currentMetadata, client: client, - duration: _currentMetadata.duration != null ? Duration(milliseconds: _currentMetadata.duration!) : null, + duration: _currentMetadata.durationMs != null ? Duration(milliseconds: _currentMetadata.durationMs!) : null, ); await _syncMediaControlsAvailability(); } @@ -2692,7 +2813,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } /// Navigate to a specific queue item (called from QueueSheet) - Future navigateToQueueItem(PlexMetadata metadata) async { + Future navigateToQueueItem(MediaItem metadata) async { _notifyWatchTogetherMediaChange(metadata: metadata); await _navigateToEpisode(metadata); } @@ -2702,8 +2823,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous) /// Start periodic timeline heartbeats for live TV transcode session. void _startLiveTimelineUpdates() { + final generation = ++_liveTimelineGeneration; _liveTimelineTimer?.cancel(); _liveTimelineTimer = Timer.periodic(const Duration(seconds: 10), (_) { + if (generation != _liveTimelineGeneration) return; final state = player?.state.playing == true ? 'playing' : 'paused'; _sendLiveTimeline(state); }); @@ -2711,7 +2834,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Sending time=0 immediately after player.open() causes the server // to spawn a duplicate transcode job with offset=-1 that 404s. Future.delayed(const Duration(seconds: 3), () { - if (_liveTimelineTimer != null) { + if (_liveTimelineTimer != null && generation == _liveTimelineGeneration) { final state = player?.state.playing == true ? 'playing' : 'paused'; _sendLiveTimeline(state); } @@ -2719,109 +2842,137 @@ class VideoPlayerScreenState extends State with WidgetsBindin } void _stopLiveTimelineUpdates() { + _liveTimelineGeneration++; _liveTimelineTimer?.cancel(); _liveTimelineTimer = null; } Future _sendLiveTimeline(String state) async { - final sessionId = _liveSessionIdentifier; - final sessionPath = _liveSessionPath; - if (sessionId == null || sessionPath == null) return; + final client = _liveClient; + final playbackTime = _livePlaybackStartTime != null + ? DateTime.now().difference(_livePlaybackStartTime!).inMilliseconds + : 0; - final client = widget.liveClient; - if (client == null) return; - - try { - // Use the program ratingKey from tune metadata, not the channel key - final ratingKey = _liveRatingKey ?? widget.metadata.ratingKey; - - // playbackTime: wall-clock ms since playback started - final playbackTime = _livePlaybackStartTime != null - ? DateTime.now().difference(_livePlaybackStartTime!).inMilliseconds - : 0; - - // For live TV, player position/duration are unreliable (often 0). - // Use playbackTime as time, and program duration from tune metadata. - // Plex rejects timeline pings where time > duration; grow duration to - // match — otherwise Tunarr-style short synthetic programs 400 mid-stream. - final time = playbackTime; - final duration = max(_liveDurationMs ?? 0, time); - - final updatedBuffer = await client.updateLiveTimeline( - ratingKey: ratingKey, - sessionPath: sessionPath, - sessionIdentifier: sessionId, - state: state, - time: time, - duration: duration, - playbackTime: playbackTime, - ); - if (updatedBuffer != null && mounted) { - setState(() { - _captureBuffer = updatedBuffer; - _isAtLiveEdge = (_currentPositionEpoch >= updatedBuffer.seekableEndEpoch - _liveEdgeThresholdSeconds); - }); + if (client is PlexClient) { + final sessionId = _liveSessionIdentifier; + final sessionPath = _liveSessionPath; + if (sessionId == null || sessionPath == null) return; + try { + // Use the program ratingKey from tune metadata, not the channel key + final ratingKey = _liveProgramId ?? _liveItemId ?? widget.metadata.id; + // For live TV, player position/duration are unreliable (often 0). + // Use playbackTime as time, and program duration from tune metadata. + // Plex rejects timeline pings where time > duration; grow duration to + // match — otherwise Tunarr-style short synthetic programs 400 mid-stream. + final time = playbackTime; + final duration = max(_liveDurationMs ?? 0, time); + final updatedBuffer = await client.updateLiveTimeline( + ratingKey: ratingKey, + sessionPath: sessionPath, + sessionIdentifier: sessionId, + state: state, + time: time, + duration: duration, + playbackTime: playbackTime, + ); + if (updatedBuffer != null && mounted) { + setState(() { + _captureBuffer = updatedBuffer; + _isAtLiveEdge = (_currentPositionEpoch >= updatedBuffer.seekableEndEpoch - _liveEdgeThresholdSeconds); + }); + } + } catch (e) { + appLogger.d('Plex live timeline update failed', error: e); } - } catch (e) { - appLogger.d('Live timeline update failed', error: e); + return; + } + + if (client is JellyfinClient) { + await _jellyfinLiveSession.report( + client: client, + itemId: _liveItemId ?? widget.metadata.id, + state: state, + position: Duration(milliseconds: playbackTime), + duration: Duration(milliseconds: _liveDurationMs ?? 0), + ); + return; } } /// Retry the live stream with degraded direct-stream settings. - /// Re-tunes the channel for a fresh server session (the old one expires - /// while MPV exhausts its reconnect attempts). + /// + /// Plex re-tunes the channel for a fresh capture session (the previous one + /// expires while MPV exhausts its reconnect attempts). Jellyfin streams the + /// channel directly with a session-less URL, so retry is just re-opening + /// that URL — degradation knobs apply only to the Plex transcoder branch. Future _retryLiveStream() async { - final client = widget.liveClient; - final channels = widget.liveChannels; - final channelIndex = _liveChannelIndex; - if (client == null || - channels == null || - channelIndex < 0 || - channelIndex >= channels.length || - widget.liveDvrKey == null) { - appLogger.w('Cannot retry live stream — missing session info'); - showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed'); - unawaited(_handleBackButton()); - return; - } - + final client = _liveClient; final ds = _liveStreamFallbackLevel < 1; final dsa = _liveStreamFallbackLevel < 2; - final channel = channels[channelIndex]; - appLogger.i('Retrying live stream (re-tune ${channel.key}): directStream=$ds directStreamAudio=$dsa'); - // Re-tune to get a fresh capture session — the previous one is dead. - final tuneResult = await client.tuneChannel(widget.liveDvrKey!, channel.key); - if (tuneResult == null || !mounted) { - showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed'); - unawaited(_handleBackButton()); + if (client is PlexClient) { + final channels = widget.liveChannels; + final channelIndex = _liveChannelIndex; + final dvrKey = _liveDvrKey; + if (channels == null || channelIndex < 0 || channelIndex >= channels.length || dvrKey == null) { + appLogger.w('Cannot retry live stream — missing session info'); + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); + unawaited(_handleBackButton()); + return; + } + final channel = channels[channelIndex]; + appLogger.i('Retrying live stream (re-tune ${channel.key}): directStream=$ds directStreamAudio=$dsa'); + + // Re-tune to get a fresh capture session — the previous one is dead. + final tuneResult = await client.tuneChannel(dvrKey, channel.key); + if (tuneResult == null || !mounted) { + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); + unawaited(_handleBackButton()); + return; + } + + _liveSessionIdentifier = tuneResult.sessionIdentifier; + _liveSessionPath = tuneResult.sessionPath; + _transcodeSessionId = generateSessionIdentifier(); + + final streamPath = await client.buildLiveStreamPath( + sessionPath: tuneResult.sessionPath, + sessionIdentifier: tuneResult.sessionIdentifier, + transcodeSessionId: _transcodeSessionId!, + directStream: ds, + directStreamAudio: dsa, + ); + if (streamPath == null || !mounted) { + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); + unawaited(_handleBackButton()); + return; + } + + final streamUrl = client.buildLiveStreamUrl(streamPath); + _liveStreamUrl = streamUrl; + _livePlaybackStartTime = DateTime.now(); + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; + + await _setLiveStreamOptions(); + await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); return; } - _liveSessionIdentifier = tuneResult.sessionIdentifier; - _liveSessionPath = tuneResult.sessionPath; - _transcodeSessionId = PlexClient.generateSessionIdentifier(); - - final streamPath = await client.buildLiveStreamPath( - sessionPath: tuneResult.sessionPath, - sessionIdentifier: tuneResult.sessionIdentifier, - transcodeSessionId: _transcodeSessionId!, - directStream: ds, - directStreamAudio: dsa, - ); - if (streamPath == null || !mounted) { - showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed'); - unawaited(_handleBackButton()); + final liveStreamUrl = _liveStreamUrl; + if (client is JellyfinClient && liveStreamUrl != null) { + appLogger.i('Retrying Jellyfin live stream by re-opening URL'); + _livePlaybackStartTime = DateTime.now(); + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; + await _setLiveStreamOptions(); + await player!.open(Media(liveStreamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); return; } - final streamUrl = '${client.config.baseUrl}$streamPath'.withPlexToken(client.config.token); - _livePlaybackStartTime = DateTime.now(); - _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; - _isAtLiveEdge = true; - - await _setLiveStreamOptions(); - await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + appLogger.w('Cannot retry live stream — no compatible client/URL available'); + showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? 'Live stream failed')); + unawaited(_handleBackButton()); } /// Force mpv to reconnect its HTTP stream by seeking to the current position. @@ -2878,8 +3029,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin final offsetSeconds = clamped - _captureBuffer!.startedAt.round(); - final client = widget.liveClient; - if (client == null) return; + // Live seek requires a transcode session — Plex-only by protocol. The + // Plex path populates _captureBuffer; the Jellyfin path never does, so + // the early-return above already covers Jellyfin in practice. This + // explicit guard keeps the contract obvious. + final client = _liveClient; + if (client is! PlexClient) return; final streamPath = await client.buildLiveStreamPath( sessionPath: _liveSessionPath!, @@ -2889,7 +3044,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); if (streamPath == null || !mounted) return; - final streamUrl = '${client.config.baseUrl}$streamPath'.withPlexToken(client.config.token); + final streamUrl = client.buildLiveStreamUrl(streamPath); _streamStartEpoch = _captureBuffer!.startedAt + offsetSeconds; _isAtLiveEdge = (clamped >= _captureBuffer!.seekableEndEpoch - _liveEdgeThresholdSeconds); @@ -2929,19 +3084,46 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { // Look up the correct client/DVR for this channel's server final multiServer = context.read(); - final serverInfo = - multiServer.liveTvServers.where((s) => s.serverId == channel.serverId).firstOrNull ?? - multiServer.liveTvServers.firstOrNull; + final serverInfo = liveTvServerInfoForChannel(multiServer, channel); if (serverInfo == null) return; - final client = multiServer.getClientForServer(serverInfo.serverId); + final genericClient = multiServer.getClientForServer(serverInfo.serverId); + final resolution = await genericClient?.liveTv.resolveStreamUrl(channel.key, dvrKey: serverInfo.dvrKey); + if (resolution != null) { + // Jellyfin: pre-resolved negotiated URL. + await _setLiveStreamOptions(); + await player!.open(Media(resolution.url, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + _liveClient = genericClient; + _liveDvrKey = serverInfo.dvrKey; + _liveStreamUrl = resolution.url; + _liveItemId = channel.key; + _liveSessionIdentifier = resolution.playSessionId; + _jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId); + _livePlaybackStartTime = DateTime.now(); + _captureBuffer = null; + _programBeginsAt = null; + _liveProgramId = null; + _liveDurationMs = null; + _streamStartEpoch = DateTime.now().millisecondsSinceEpoch / 1000.0; + _isAtLiveEdge = true; + if (!mounted) return; + setState(() { + _liveChannelIndex = newIndex; + _liveChannelName = channel.displayName; + }); + _startLiveTimelineUpdates(); + return; + } + + // Plex-only: DVR tune flow (Jellyfin Live TV uses pre-resolved URLs). + final client = multiServer.getPlexClientForServer(serverInfo.serverId); if (client == null) return; final tuneResult = await client.tuneChannel(serverInfo.dvrKey, channel.key); if (tuneResult == null || !mounted) return; - _transcodeSessionId = PlexClient.generateSessionIdentifier(); + _transcodeSessionId = generateSessionIdentifier(); _liveStreamFallbackLevel = 0; final streamPath = await client.buildLiveStreamPath( @@ -2951,13 +3133,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); if (streamPath == null || !mounted) return; - final streamUrl = '${client.config.baseUrl}$streamPath'.withPlexToken(client.config.token); + final streamUrl = client.buildLiveStreamUrl(streamPath); await _setLiveStreamOptions(); await player!.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); + _liveClient = client; + _liveDvrKey = serverInfo.dvrKey; + _liveStreamUrl = streamUrl; + _liveItemId = channel.key; _livePlaybackStartTime = DateTime.now(); - _liveRatingKey = tuneResult.metadata.ratingKey; + _liveProgramId = tuneResult.metadata.ratingKey; _liveDurationMs = tuneResult.metadata.duration; // Reset time-shift state for new channel @@ -3088,7 +3274,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// This prevents default-track fallback when playback starts before /// UserProfileProvider finishes initialization. Future _waitForProfileSettingsIfNeeded() async { - if (!widget.isOffline || !mounted) return; + if (!_isOfflinePlayback || !mounted) return; final provider = context.read(); if (provider.profileSettings != null) return; @@ -3128,8 +3314,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Navigates to a new episode, preserving playback state and track selections. /// When PiP is active, swaps the media source in-place to keep the PiP window alive. - Future _navigateToEpisode(PlexMetadata episodeMetadata) async { - // PiP active: swap media in-place to keep PiP window alive + Future _navigateToEpisode(MediaItem episodeMetadata) async { + // PiP active: swap media in-place to keep the PiP window alive. The + // swap path threads the neutral [MediaServerClient] through + // [PlaybackInitializationService] and the lifecycle services, so it + // works for both Plex and Jellyfin sessions. if (PipService().isPipActive.value && player != null) { await _swapEpisodeInPip(episodeMetadata); return; @@ -3151,7 +3340,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin context, metadata: episodeMetadata, usePushReplacement: true, - isOffline: widget.isOffline, + isOffline: _isOfflinePlayback, ), ); } @@ -3168,7 +3357,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin context, metadata: episodeMetadata, usePushReplacement: true, - isOffline: widget.isOffline, + isOffline: _isOfflinePlayback, ), ); } @@ -3197,7 +3386,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin preferredSubtitleTrack: currentSubtitleTrack, preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, usePushReplacement: true, - isOffline: widget.isOffline, + isOffline: _isOfflinePlayback, ), ); } @@ -3206,7 +3395,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Swap to a new episode while keeping the player alive for PiP continuity. /// Reuses the existing mpv instance (and its Metal layer in PiP) and only /// reloads the media source + resets Dart-side services. - Future _swapEpisodeInPip(PlexMetadata episodeMetadata) async { + Future _swapEpisodeInPip(MediaItem episodeMetadata) async { _isSwappingEpisode = true; final currentPlayer = player!; final previousMetadata = _currentMetadata; @@ -3215,12 +3404,18 @@ class VideoPlayerScreenState extends State with WidgetsBindin final currentSubtitleTrack = currentPlayer.state.track.subtitle; final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle; - // Capture context-dependent values before async gaps - final client = widget.isOffline ? null : _getClientForMetadata(context); - final plexHeaders = client?.config.headers; - final offlineWatchService = widget.isOffline ? context.read() : null; + // Capture context-dependent values before async gaps. The neutral + // [PlaybackInitializationService] consumes [mediaClient] regardless of + // backend. We still narrow to [plexClient] for [TrackManager]'s + // server-side track persistence, which is Plex-only — Jellyfin + // sessions get a null `getPlexClient` and skip that path. + final mediaClient = _isOfflinePlayback ? null : _getMediaServerClient(context); + final plexClient = mediaClient is PlexClient ? mediaClient : null; + final streamHeaders = mediaClient?.streamHeaders ?? const {}; + final offlineWatchService = context.read(); final userProfileProvider = context.read(); final playbackState = context.read(); + final database = context.read(); await _progressTracker?.sendProgress('stopped'); _progressTracker?.stopTracking(); @@ -3231,47 +3426,59 @@ class VideoPlayerScreenState extends State with WidgetsBindin unawaited(TrackerCoordinator.instance.stopPlayback()); _currentMetadata = episodeMetadata; - _activeRatingKey = episodeMetadata.ratingKey; + _activeId = episodeMetadata.id; _showPlayNextDialog = false; _autoPlayTimer?.cancel(); _hasFirstFrame.value = false; try { - PlaybackInitializationResult result; - - if (widget.isOffline) { - result = await _startOfflinePlayback(); - } else { - final playbackService = PlaybackInitializationService( - client: client!, - database: PlexApiCache.instance.database, - ); - result = await playbackService.getPlaybackData( - metadata: episodeMetadata, - selectedMediaIndex: widget.selectedMediaIndex, - preferOffline: true, - ); - } + // Same service shape works for both online (mediaClient non-null, + // bundled video URL + media info) and pure-offline (mediaClient null, + // local file + cached media info if available). + final playbackService = PlaybackInitializationService(client: mediaClient, database: database); + final result = await playbackService.getPlaybackData( + metadata: episodeMetadata, + selectedMediaIndex: widget.selectedMediaIndex, + preferOffline: _isOfflinePlayback || _selectedQualityPreset.isOriginal, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ); if (result.videoUrl == null) { throw PlaybackException('No video URL available'); } Duration? resumePosition; - if (widget.isOffline) { - final localOffset = await offlineWatchService!.getLocalViewOffset(episodeMetadata.globalKey); + _isTranscoding = result.isTranscoding; + _effectiveIsOffline = result.isOffline; + _playbackPlaySessionId = result.playSessionId; + _playbackPlayMethod = result.playMethod; + if (result.activeAudioStreamId != null) { + _selectedAudioStreamId = result.activeAudioStreamId; + } + if (result.fallbackReason != null && !_selectedQualityPreset.isOriginal) { + if (mounted) { + showErrorSnackBar(context, t.videoControls.transcodeUnavailableFallback); + } + _selectedQualityPreset = TranscodeQualityPreset.original; + } + + if (_isOfflinePlayback) { + final localOffset = await offlineWatchService.getLocalViewOffset(episodeMetadata.globalKey); if (localOffset != null && localOffset > 0) { resumePosition = Duration(milliseconds: localOffset); } } - resumePosition ??= episodeMetadata.viewOffset != null - ? Duration(milliseconds: episodeMetadata.viewOffset!) + resumePosition ??= episodeMetadata.viewOffsetMs != null + ? Duration(milliseconds: episodeMetadata.viewOffsetMs!) : null; final hasExternalSubs = result.externalSubtitles.isNotEmpty; final isExoPlayer = player is PlayerAndroid; await currentPlayer.open( - Media(result.videoUrl!, start: resumePosition, headers: plexHeaders), + Media(result.videoUrl!, start: resumePosition, headers: streamHeaders), play: isExoPlayer || !hasExternalSubs, externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, ); @@ -3281,11 +3488,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!mounted) return; - _bifService?.dispose(); + _scrubPreviewSource?.dispose(); setState(() { - _availableVersions = result.availableVersions.cast(); + _availableVersions = result.availableVersions; _currentMediaInfo = result.mediaInfo; - _bifService = null; + _scrubPreviewSource = null; _isLoadingNext = false; }); @@ -3293,7 +3500,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin _trackManager = TrackManager( player: currentPlayer, isActive: () => mounted && player != null, - getClient: () => client!, + // Plex writes track changes immediately. Jellyfin persists selected + // indexes through playback progress reports. + persistTrackPreference: plexClient != null ? _plexTrackPersister(() => plexClient) : null, getProfileSettings: () => userProfileProvider.profileSettings, waitForProfileSettings: _waitForProfileSettingsIfNeeded, metadata: episodeMetadata, @@ -3318,32 +3527,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin _trackManager!.applyTrackSelectionWhenReady(); } - if (widget.isOffline) { - _progressTracker = PlaybackProgressTracker( - client: null, - metadata: episodeMetadata, - player: currentPlayer, - isOffline: true, - offlineWatchService: offlineWatchService, - ); - } else { - _progressTracker = PlaybackProgressTracker(client: client, metadata: episodeMetadata, player: currentPlayer); - } - _progressTracker!.startTracking(); - - if (_mediaControlsManager != null) { - await _mediaControlsManager!.updateMetadata( - metadata: episodeMetadata, - client: client, - duration: episodeMetadata.duration != null ? Duration(milliseconds: episodeMetadata.duration!) : null, - ); - } - - if (client != null) { - unawaited(DiscordRPCService.instance.startPlayback(episodeMetadata, client)); - unawaited(TraktScrobbleService.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive)); - unawaited(TrackerCoordinator.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive)); - } + // Wire progress tracker, media-controls metadata, and the + // Discord/Trakt/Tracker scrobblers — same helper as the initial + // start flow, so any future change lands in both paths together. + _wirePerItemPlaybackServices( + metadata: episodeMetadata, + mediaClient: mediaClient, + offlineWatchService: offlineWatchService, + playSessionId: _playbackPlaySessionId, + playMethod: _playbackPlayMethod, + mediaInfo: _currentMediaInfo, + ); try { playbackState.setCurrentItem(episodeMetadata); @@ -3360,7 +3554,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _isSwappingEpisode = false; _completionTriggered = false; _currentMetadata = previousMetadata; - _activeRatingKey = previousMetadata.ratingKey; + _activeId = previousMetadata.id; appLogger.e('Failed to swap episode in PiP', error: e); } } @@ -3397,6 +3591,54 @@ class VideoPlayerScreenState extends State with WidgetsBindin ); } + Widget _buildInitializationError(String message) { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppIcon(Symbols.error_rounded, color: Colors.white70, size: 44, fill: 1), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton( + autofocus: true, + onPressed: () { + final playerToDispose = player; + player = null; + if (playerToDispose != null) unawaited(playerToDispose.dispose()); + setState(() { + _playerInitializationError = null; + _isPlayerInitialized = false; + }); + unawaited(_initializePlayer()); + }, + child: Text(t.common.retry), + ), + const SizedBox(width: 12), + OutlinedButton(onPressed: () => unawaited(_handleBackButton()), child: Text(t.common.back)), + ], + ), + ], + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true; @@ -3438,8 +3680,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin }, child: OverlaySheetHost( child: Builder( - builder: (sheetContext) => - _isPlayerInitialized && player != null ? _buildVideoPlayer(sheetContext) : _buildLoadingSpinner(), + builder: (sheetContext) => _isPlayerInitialized && player != null + ? _buildVideoPlayer(sheetContext) + : (_playerInitializationError != null + ? _buildInitializationError(_playerInitializationError!) + : _buildLoadingSpinner()), ), ), ); @@ -3582,6 +3827,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin selectedQualityPreset: _selectedQualityPreset, serverSupportsTranscoding: _serverSupportsTranscoding, isTranscoding: _isTranscoding, + isOfflinePlayback: _isOfflinePlayback, sourceAudioTracks: _currentMediaInfo?.audioTracks ?? const [], selectedAudioStreamId: _selectedAudioStreamId, onTogglePIPMode: _togglePIPMode, @@ -3614,7 +3860,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin shaderService: _shaderService, // ignore: no-empty-block - setState triggers rebuild to reflect shader change onShaderChanged: () => setState(() {}), - thumbnailDataBuilder: _bifService?.isAvailable == true ? _getThumbnailData : null, + thumbnailDataBuilder: _scrubPreviewSource?.isAvailable == true ? _getThumbnailData : null, isLive: widget.isLive, liveChannelName: _liveChannelName, captureBuffer: _captureBuffer, diff --git a/lib/services/api_cache.dart b/lib/services/api_cache.dart new file mode 100644 index 00000000..de99c545 --- /dev/null +++ b/lib/services/api_cache.dart @@ -0,0 +1,234 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +import '../database/app_database.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../utils/isolate_helper.dart'; + +/// Backend-agnostic key-value cache for API responses. +/// +/// Stores raw JSON keyed by `serverId:endpoint` in the shared `ApiCache` +/// Drift table. `serverId` values are globally unique across connected +/// backends, so Plex and Jellyfin entries never collide despite sharing +/// the same table. +/// +/// Plex- and Jellyfin-specific helpers (item-id pinning, metadata parsing) +/// live on subclasses [PlexApiCache] / [JellyfinApiCache], which also +/// implement the abstract [getMetadata] / [pinForOffline] / [deleteForItem] +/// methods so callers can dispatch via [forBackend] instead of switching on +/// the backend type at every call site. +abstract class ApiCache { + static ApiCache? _instance; + + /// Returns the most recently registered cache instance — used by callers + /// that don't care which backend's helpers they're hitting (e.g. plain + /// `get`/`put` from `JellyfinClient`). Backend-specific operations should + /// route through [forBackend] instead. + static ApiCache get instance { + if (_instance == null) { + throw StateError('ApiCache not initialized. Call initialize() on a backend cache first.'); + } + return _instance!; + } + + static final Map _byBackend = {}; + + /// Subclasses call this from their own `initialize` to register themselves + /// for backend dispatch. Also seeds [instance] so the legacy singleton + /// surface keeps working. + static void registerInstance(MediaBackend backend, ApiCache cache) { + _byBackend[backend] = cache; + _instance = cache; + } + + /// Pick the cache for [backend]. Plex is the legacy default — covers items + /// predating the Connections table where the backend can't be resolved. + static ApiCache forBackend(MediaBackend? backend) { + final picked = _byBackend[backend ?? MediaBackend.plex] ?? _byBackend[MediaBackend.plex]; + if (picked == null) { + throw StateError('No ApiCache registered for backend $backend'); + } + return picked; + } + + final AppDatabase _db; + + ApiCache(this._db); + + /// Direct database access for services that need to query the cache table + /// outside the standard get/put surface (e.g. playback initialisation that + /// joins on adjacent tables). + AppDatabase get database => _db; + + String _buildKey(String serverId, String endpoint) { + return '$serverId:$endpoint'; + } + + /// Get cached response for an endpoint. + Future?> get(String serverId, String endpoint) async { + final key = _buildKey(serverId, endpoint); + final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(key))).getSingleOrNull(); + if (result != null) { + return await tryIsolateRun(() => jsonDecode(result.data) as Map); + } + return null; + } + + /// Cache a response for an endpoint. + Future put(String serverId, String endpoint, Map data) async { + final key = _buildKey(serverId, endpoint); + final encoded = await tryIsolateRun(() => jsonEncode(data)); + await _db + .into(_db.apiCache) + .insertOnConflictUpdate( + ApiCacheCompanion(cacheKey: Value(key), data: Value(encoded), cachedAt: Value(DateTime.now())), + ); + } + + /// Delete all cached data for a server. + Future deleteForServer(String serverId) async { + await (_db.delete(_db.apiCache)..where((t) => t.cacheKey.like('$serverId:%'))).go(); + } + + /// Pin an endpoint's response so the row survives cache eviction. + Future pin(String serverId, String endpoint) async { + final key = _buildKey(serverId, endpoint); + await (_db.update( + _db.apiCache, + )..where((t) => t.cacheKey.equals(key))).write(const ApiCacheCompanion(pinned: Value(true))); + } + + /// Unpin a previously pinned endpoint. + Future unpin(String serverId, String endpoint) async { + final key = _buildKey(serverId, endpoint); + await (_db.update( + _db.apiCache, + )..where((t) => t.cacheKey.equals(key))).write(const ApiCacheCompanion(pinned: Value(false))); + } + + /// Whether the endpoint is pinned for offline. + Future isPinned(String serverId, String endpoint) async { + final key = _buildKey(serverId, endpoint); + final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(key))).getSingleOrNull(); + return result?.pinned ?? false; + } + + /// Clear every cached row (debugging / sign-out). + Future clearAll() async { + await _db.delete(_db.apiCache).go(); + } + + /// Clear volatile cached rows while preserving pinned offline metadata. + Future clearVolatile() async { + await (_db.delete(_db.apiCache)..where((t) => t.pinned.equals(false))).go(); + } + + /// Pin every row whose `cacheKey` matches the SQL `LIKE` [pattern]. Used by + /// backend subclasses that pin by item-shape rather than a single endpoint + /// (e.g. Jellyfin's per-user item rows where the user segment is a + /// wildcard). + Future pinByKeyPattern(String pattern) async { + await (_db.update( + _db.apiCache, + )..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(true))); + } + + /// Inverse of [pinByKeyPattern]. + Future unpinByKeyPattern(String pattern) async { + await (_db.update( + _db.apiCache, + )..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(false))); + } + + /// True when at least one pinned row matches [pattern]. + Future hasPinnedMatching(String pattern) async { + final rows = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.like(pattern) & t.pinned.equals(true))).get(); + return rows.isNotEmpty; + } + + /// 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). + Future> extractPinnedIds(String serverId, RegExp keyPattern) async { + final rows = await (_db.select( + _db.apiCache, + )..where((t) => t.cacheKey.like('$serverId:%') & t.pinned.equals(true))).get(); + final ids = {}; + for (final row in rows) { + final match = keyPattern.firstMatch(row.cacheKey); + if (match != null) ids.add(match.group(1)!); + } + return ids; + } + + /// Walk every pinned row, extract `(serverId, capturedId, rawData)` tuples + /// from rows whose `cacheKey` matches [keyPattern]. The serverId is parsed + /// from the prefix before the first colon. Backend subclasses use this to + /// batch-load all pinned metadata into their own model type without + /// re-implementing the row walker. + Future> listPinnedRowsByPattern(RegExp keyPattern) async { + final rows = await (_db.select(_db.apiCache)..where((t) => t.pinned.equals(true))).get(); + final out = <({String serverId, String id, String data})>[]; + for (final row in rows) { + final colon = row.cacheKey.indexOf(':'); + if (colon < 0) continue; + final match = keyPattern.firstMatch(row.cacheKey); + if (match == null) continue; + out.add((serverId: row.cacheKey.substring(0, colon), id: match.group(1)!, data: row.data)); + } + return out; + } + + // ── Backend-shape operations ────────────────────────────────────── + // These are implemented by the per-backend subclasses so callers can + // dispatch via [forBackend(backend).getMetadata(...)] without an outer + // `switch (backend)` at every call site. + + /// Fetch and parse cached [MediaItem] for [itemId] on [serverId]. Returns + /// `null` when the item isn't cached. + Future getMetadata(String serverId, String itemId); + + /// Pin the cached metadata row(s) for [itemId] so they survive cache + /// eviction (used by the offline-download pipeline). + Future pinForOffline(String serverId, String itemId); + + /// Delete cached metadata for [itemId] (used when removing a download). + Future deleteForItem(String serverId, String itemId); + + /// Persist a watched/unwatched flip into the cached metadata JSON for + /// [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`). + /// + /// Optional positional progress fields ([viewOffsetMs], [lastViewedAt], + /// [viewedLeafCount]) let the offline-watch-sync service mirror richer + /// snapshots from the server's episode-list response without having to + /// fall back to a per-backend mutation. When omitted, the watched flip + /// uses the same defaults as before (zero-out `viewOffset`, stamp + /// `lastViewedAt` only when transitioning to watched). + /// + /// **Drift discipline:** the inputs are backend-neutral but the JSON + /// 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`. + /// 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({ + required String serverId, + required String itemId, + required bool isWatched, + int? viewOffsetMs, + int? lastViewedAt, + int? viewedLeafCount, + }); + + /// Bulk-load every pinned metadata row into a [MediaItem] map keyed by + /// `buildGlobalKey(serverId, itemId)`. Used by [DownloadManagerService] on + /// cold start to hydrate offline state in a single query per backend. + Future> getAllPinnedMetadata(); +} diff --git a/lib/services/bif_thumbnail_service.dart b/lib/services/bif_thumbnail_service.dart index 630cfeec..f80720b0 100644 --- a/lib/services/bif_thumbnail_service.dart +++ b/lib/services/bif_thumbnail_service.dart @@ -2,6 +2,7 @@ import '../utils/isolate_helper.dart'; import 'dart:typed_data'; import 'plex_client.dart'; +import 'scrub_preview_source.dart'; import '../utils/app_logger.dart'; /// A single BIF thumbnail entry: timestamp in milliseconds + JPEG bytes. @@ -62,7 +63,7 @@ List _parseBifBytes(Uint8List bytes) { } /// Caches a full BIF file in memory and serves thumbnails by timestamp. -class BifThumbnailService { +class BifThumbnailService implements ScrubPreviewSource { List? _entries; /// Download and parse the BIF file for [partId]. @@ -83,8 +84,16 @@ class BifThumbnailService { } /// Whether thumbnails have been loaded successfully. + @override bool get isAvailable => _entries != null && _entries!.isNotEmpty; + @override + ScrubFrame? getFrame(Duration time) { + final bytes = getThumbnail(time); + if (bytes == null) return null; + return BytesScrubFrame(bytes); + } + /// Return the JPEG bytes for the thumbnail nearest to [time]. /// Uses binary search for O(log n) lookup. Uint8List? getThumbnail(Duration time) { @@ -109,6 +118,7 @@ class BifThumbnailService { } /// Release cached data. + @override void dispose() { _entries = null; } diff --git a/lib/services/cached_playback_metadata_service.dart b/lib/services/cached_playback_metadata_service.dart new file mode 100644 index 00000000..29d758d2 --- /dev/null +++ b/lib/services/cached_playback_metadata_service.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +import '../media/media_backend.dart'; +import '../media/media_source_info.dart'; +import '../utils/app_logger.dart'; +import '../utils/plex_cache_parser.dart'; +import 'api_cache.dart'; +import 'jellyfin_media_info_mapper.dart'; +import 'plex_mappers.dart'; + +class CachedPlaybackMetadataService { + const CachedPlaybackMetadataService._(); + + static Future fetchMediaSourceInfo({ + required MediaBackend backend, + required String cacheServerId, + required String itemId, + int mediaIndex = 0, + }) async { + try { + return switch (backend) { + MediaBackend.plex => _fetchPlexMediaSourceInfo(cacheServerId, itemId, mediaIndex: mediaIndex), + MediaBackend.jellyfin => _fetchJellyfinMediaSourceInfo(cacheServerId, itemId, mediaIndex: mediaIndex), + }; + } catch (e) { + appLogger.d('Cached media source info unavailable for $cacheServerId:$itemId', error: e); + return null; + } + } + + static Future fetchPlaybackExtras({ + required MediaBackend backend, + required String cacheServerId, + required String itemId, + String? introPattern, + String? creditsPattern, + }) async { + try { + return switch (backend) { + MediaBackend.plex => _fetchPlexPlaybackExtras( + cacheServerId, + itemId, + introPattern: introPattern, + creditsPattern: creditsPattern, + ), + MediaBackend.jellyfin => _fetchJellyfinPlaybackExtras(cacheServerId, itemId), + }; + } catch (e) { + appLogger.d('Cached playback extras unavailable for $cacheServerId:$itemId', error: e); + return null; + } + } + + static Future _fetchPlexMediaSourceInfo( + String serverId, + String itemId, { + required int mediaIndex, + }) async { + final metadata = await _plexMetadata(serverId, itemId); + return metadata == null ? null : plexMediaSourceInfoFromCacheJson(metadata, mediaIndex: mediaIndex); + } + + static Future _fetchPlexPlaybackExtras( + String serverId, + String itemId, { + String? introPattern, + String? creditsPattern, + }) async { + final metadata = await _plexMetadata(serverId, itemId); + if (metadata == null) return null; + return plexPlaybackExtrasFromCacheJson(metadata, introPattern: introPattern, creditsPattern: creditsPattern); + } + + static Future?> _plexMetadata(String serverId, String itemId) async { + final cached = await ApiCache.forBackend(MediaBackend.plex).get(serverId, '/library/metadata/$itemId'); + return PlexCacheParser.extractFirstMetadata(cached); + } + + static Future _fetchJellyfinMediaSourceInfo( + String cacheServerId, + String itemId, { + required int mediaIndex, + }) async { + final raw = await _jellyfinRawItem(cacheServerId, itemId); + final sources = raw['MediaSources']; + if (sources is! List || sources.isEmpty) return null; + final selected = mediaIndex >= 0 && mediaIndex < sources.length ? sources[mediaIndex] : sources.first; + if (selected is! Map) return null; + return jellyfinMediaSourceToMediaSourceInfo(selected, chapters: raw['Chapters'], trickplay: raw['Trickplay']); + } + + static Future _fetchJellyfinPlaybackExtras(String cacheServerId, String itemId) async { + final raw = await _jellyfinRawItem(cacheServerId, itemId); + return jellyfinPlaybackExtrasFromRaw(raw, itemId); + } + + static Future> _jellyfinRawItem(String cacheServerId, String itemId) async { + final cache = ApiCache.forBackend(MediaBackend.jellyfin); + final scopedPrefix = cacheServerId.contains('/') ? null : '$cacheServerId/%:/Users/%/Items/$itemId'; + final rows = + await (cache.database.select(cache.database.apiCache)..where( + (t) => + t.cacheKey.like('$cacheServerId:/Users/%/Items/$itemId') | + (scopedPrefix == null ? const Constant(false) : t.cacheKey.like(scopedPrefix)), + )) + .get(); + if (rows.isEmpty) throw StateError('No Jellyfin cache row for $cacheServerId:$itemId'); + return jsonDecode(rows.first.data) as Map; + } +} diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart index 68bf22d4..e8b3356e 100644 --- a/lib/services/companion_remote/companion_remote_peer_service.dart +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -10,6 +10,7 @@ import '../../models/companion_remote/remote_command.dart'; import '../../models/companion_remote/remote_session.dart'; import '../../utils/app_logger.dart'; import '../base_peer_service.dart'; +import 'remote_auth_context.dart'; import 'remote_auth_service.dart'; // Re-export so callers that import from here get the types. @@ -31,6 +32,8 @@ class CompanionRemotePeerService with KeepaliveMixin { String? _myPeerId; String? _hostAddress; // Format: "ip:port" RemoteSessionRole? _role; + String? _selectedAuthContextId; + String? _selectedHostClientId; // Encrypted channel state List? _sessionEncKey; @@ -65,6 +68,8 @@ class CompanionRemotePeerService with KeepaliveMixin { String? get myPeerId => _myPeerId; String? get hostAddress => _hostAddress; RemoteSessionRole? get role => _role; + String? get selectedAuthContextId => _selectedAuthContextId; + String? get selectedHostClientId => _selectedHostClientId; bool get isHost => _role == RemoteSessionRole.host; bool get isConnected => _clientSocket != null || (_channel != null && _channel?.closeCode == null); @@ -109,6 +114,31 @@ class CompanionRemotePeerService with KeepaliveMixin { String clientIdentifier, List homeUserUUIDs, ) async { + final auth = RemoteAuthService.instance; + return createSessionForContexts(deviceName, platform, [ + RemoteAuthContext( + id: auth.computeAuthContextId(homeSecret), + backend: 'legacy', + connectionId: '', + homeSecret: homeSecret, + discoveryKey: const [], + clientIdentifier: clientIdentifier, + userUuid: homeUserUUIDs.isEmpty ? '' : homeUserUUIDs.first, + allowedUserUuids: homeUserUUIDs, + ), + ]); + } + + /// Create a host session that accepts any of the provided remote identities. + Future<({List addresses, int port})> createSessionForContexts( + String deviceName, + String platform, + List authContexts, + ) async { + if (authContexts.isEmpty) { + throw const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'No auth contexts available'); + } + if (_server != null) { await disconnect(); } @@ -139,15 +169,7 @@ class CompanionRemotePeerService with KeepaliveMixin { try { final socket = await WebSocketTransformer.upgrade(request); final sourceIp = request.connectionInfo?.remoteAddress.address ?? 'unknown'; - _handleNewWebSocketConnection( - socket, - deviceName, - platform, - homeSecret, - clientIdentifier, - homeUserUUIDs, - sourceIp, - ); + _handleNewWebSocketConnection(socket, deviceName, platform, authContexts, sourceIp); } catch (e) { appLogger.e('CompanionRemote: Failed to upgrade WebSocket', error: e); } @@ -177,9 +199,7 @@ class CompanionRemotePeerService with KeepaliveMixin { WebSocket socket, String hostDeviceName, String hostPlatform, - List homeSecret, - String hostClientId, - List homeUserUUIDs, + List authContexts, String sourceIp, ) { appLogger.d('CompanionRemote: New WebSocket connection from $sourceIp'); @@ -197,8 +217,19 @@ class CompanionRemotePeerService with KeepaliveMixin { return; } - // Send challenge: hostNonce + hostClientId - socket.add(jsonEncode({'type': 'challenge', 'nonce': base64Encode(hostNonce), 'hostClientId': hostClientId})); + final primaryContext = authContexts.first; + + // Send challenge: legacy hostClientId plus all selectable auth contexts. + socket.add( + jsonEncode({ + 'type': 'challenge', + 'nonce': base64Encode(hostNonce), + 'hostClientId': primaryContext.clientIdentifier, + 'authContexts': [ + for (final context in authContexts) {'id': context.id, 'hostClientId': context.clientIdentifier}, + ], + }), + ); // Authentication timeout authTimeout = Timer(const Duration(seconds: 10), () { @@ -221,6 +252,7 @@ class CompanionRemotePeerService with KeepaliveMixin { final clientIdentifier = json['clientIdentifier'] as String?; final deviceName = json['deviceName'] as String?; final platform = json['platform'] as String?; + final authContextId = json['authContextId'] as String?; if (authTag == null || clientNonceB64 == null || @@ -234,9 +266,28 @@ class CompanionRemotePeerService with KeepaliveMixin { } final clientNonce = base64Decode(clientNonceB64); + RemoteAuthContext? selectedContext; + if (authContextId != null && authContextId.isNotEmpty) { + for (final context in authContexts) { + if (context.id == authContextId) { + selectedContext = context; + break; + } + } + } else if (authContexts.length == 1) { + selectedContext = primaryContext; + } - // Verify userUUID is in home users list - if (!homeUserUUIDs.contains(userUUID)) { + if (selectedContext == null) { + _recordFailedAuth(sourceIp); + appLogger.w('CompanionRemote: Auth failed — unknown auth context'); + socket.add(jsonEncode({'type': 'authFailed'})); + unawaited(socket.close(4003, 'Authentication failed')); + return; + } + + // Verify userUUID is allowed for the selected profile connection. + if (selectedContext.allowedUserUuids.isNotEmpty && !selectedContext.allowedUserUuids.contains(userUUID)) { _recordFailedAuth(sourceIp); appLogger.w('CompanionRemote: Auth failed — unknown user'); socket.add(jsonEncode({'type': 'authFailed'})); @@ -247,10 +298,10 @@ class CompanionRemotePeerService with KeepaliveMixin { // Verify auth tag final valid = auth.verifyAuthTag( authTag: authTag, - homeSecret: homeSecret, + homeSecret: selectedContext.homeSecret, hostNonce: hostNonce, clientNonce: clientNonce, - hostClientId: hostClientId, + hostClientId: selectedContext.clientIdentifier, userUUID: userUUID, clientIdentifier: clientIdentifier, deviceName: deviceName, @@ -270,7 +321,7 @@ class CompanionRemotePeerService with KeepaliveMixin { isAuthenticated = true; authTimeout?.cancel(); - final sessionEncKey = await auth.deriveSessionEncKey(homeSecret, hostNonce, clientNonce); + final sessionEncKey = await auth.deriveSessionEncKey(selectedContext.homeSecret, hostNonce, clientNonce); // Close existing client if present if (_clientSocket != null) { @@ -283,6 +334,8 @@ class CompanionRemotePeerService with KeepaliveMixin { _sendCounter = 0; _recvCounter = 0; _isAuthenticated = true; + _selectedAuthContextId = selectedContext.id; + _selectedHostClientId = selectedContext.clientIdentifier; appLogger.d('CompanionRemote: Client authenticated: $deviceName ($platform)'); @@ -330,6 +383,8 @@ class CompanionRemotePeerService with KeepaliveMixin { _clientSocket = null; _sessionEncKey = null; _isAuthenticated = false; + _selectedAuthContextId = null; + _selectedHostClientId = null; _deviceDisconnectedController.add(null); _connectionStateController.add(RemoteSessionStatus.disconnected); stopKeepalive(); @@ -368,6 +423,40 @@ class CompanionRemotePeerService with KeepaliveMixin { String userUUID, String clientIdentifier, ) async { + final auth = RemoteAuthService.instance; + final context = RemoteAuthContext( + id: auth.computeAuthContextId(homeSecret), + backend: 'legacy', + connectionId: '', + homeSecret: homeSecret, + discoveryKey: const [], + clientIdentifier: clientIdentifier, + userUuid: userUUID, + allowedUserUuids: [userUUID], + ); + return joinSessionWithContexts( + deviceName, + platform, + hostAddress, + [context], + authContextId: context.id, + expectedHostClientId: hostClientId, + ); + } + + /// Join a host session with any local auth context that the host also supports. + Future joinSessionWithContexts( + String deviceName, + String platform, + String hostAddress, + List authContexts, { + String? authContextId, + String expectedHostClientId = '', + }) async { + if (authContexts.isEmpty) { + throw const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'No auth contexts available'); + } + if (_channel != null) { await disconnect(); } @@ -447,26 +536,70 @@ class CompanionRemotePeerService with KeepaliveMixin { if (messageType == 'challenge') { hostNonce = base64Decode(json['nonce'] as String); - receivedHostClientId = json['hostClientId'] as String; + final legacyHostClientId = json['hostClientId'] as String? ?? ''; + final challengeContextHostIds = {}; + final challengeContexts = json['authContexts'] as List? ?? const []; + for (final item in challengeContexts) { + if (item is! Map) continue; + final id = item['id'] as String?; + final hostClientId = item['hostClientId'] as String?; + if (id != null && id.isNotEmpty && hostClientId != null && hostClientId.isNotEmpty) { + challengeContextHostIds[id] = hostClientId; + } + } + + RemoteAuthContext? selectedContext; + if (authContextId != null && authContextId.isNotEmpty) { + for (final context in authContexts) { + if (context.id == authContextId) { + selectedContext = context; + break; + } + } + } else if (challengeContextHostIds.isNotEmpty) { + for (final context in authContexts) { + if (challengeContextHostIds.containsKey(context.id)) { + selectedContext = context; + break; + } + } + } else { + selectedContext = authContexts.first; + } + + if (selectedContext == null || + (challengeContextHostIds.isNotEmpty && !challengeContextHostIds.containsKey(selectedContext.id))) { + appLogger.w('CompanionRemote: No shared auth context with host'); + if (!completer.isCompleted) { + completer.completeError( + const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'No shared identity'), + ); + } + unawaited(_channel?.sink.close(4003, 'Authentication failed')); + return; + } + + receivedHostClientId = challengeContextHostIds[selectedContext.id] ?? legacyHostClientId; clientNonce = auth.generateNonce(); - if (hostClientId.isNotEmpty && receivedHostClientId != hostClientId) { + if (expectedHostClientId.isNotEmpty && receivedHostClientId != expectedHostClientId) { appLogger.w('CompanionRemote: Host client ID mismatch'); if (!completer.isCompleted) { completer.completeError( const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'Host identity mismatch'), ); } + unawaited(_channel?.sink.close(4003, 'Authentication failed')); return; } final authTag = auth.computeAuthTag( - homeSecret: homeSecret, + homeSecret: selectedContext.homeSecret, hostNonce: hostNonce!, clientNonce: clientNonce!, hostClientId: receivedHostClientId!, - userUUID: userUUID, - clientIdentifier: clientIdentifier, + userUUID: selectedContext.userUuid, + clientIdentifier: selectedContext.clientIdentifier, deviceName: deviceName, platform: platform, ); @@ -474,18 +607,21 @@ class CompanionRemotePeerService with KeepaliveMixin { _channel!.sink.add( jsonEncode({ 'type': 'auth', + 'authContextId': selectedContext.id, 'clientNonce': base64Encode(clientNonce!), - 'userUUID': userUUID, - 'clientIdentifier': clientIdentifier, + 'userUUID': selectedContext.userUuid, + 'clientIdentifier': selectedContext.clientIdentifier, 'deviceName': deviceName, 'platform': platform, 'authTag': authTag, }), ); - _sessionEncKey = await auth.deriveSessionEncKey(homeSecret, hostNonce!, clientNonce!); + _sessionEncKey = await auth.deriveSessionEncKey(selectedContext.homeSecret, hostNonce!, clientNonce!); _sendCounter = 0; _recvCounter = 0; + _selectedAuthContextId = selectedContext.id; + _selectedHostClientId = receivedHostClientId; } else if (messageType == 'authFailed') { appLogger.w('CompanionRemote: Authentication failed'); if (!completer.isCompleted) { @@ -509,6 +645,8 @@ class CompanionRemotePeerService with KeepaliveMixin { _connectionStateController.add(RemoteSessionStatus.disconnected); _isAuthenticated = false; _sessionEncKey = null; + _selectedAuthContextId = null; + _selectedHostClientId = null; stopKeepalive(); }, onError: (error) { @@ -566,15 +704,48 @@ class CompanionRemotePeerService with KeepaliveMixin { String userUUID, String clientIdentifier, ) async { + final auth = RemoteAuthService.instance; + final context = RemoteAuthContext( + id: auth.computeAuthContextId(homeSecret), + backend: 'legacy', + connectionId: '', + homeSecret: homeSecret, + discoveryKey: const [], + clientIdentifier: clientIdentifier, + userUuid: userUUID, + allowedUserUuids: [userUUID], + ); + return joinSessionRacingWithContexts( + deviceName, + platform, + hostAddresses, + [context], + authContextId: context.id, + expectedHostClientId: hostClientId, + ); + } + + /// Race WebSocket connections and authenticate with the selected shared identity. + Future joinSessionRacingWithContexts( + String deviceName, + String platform, + List hostAddresses, + List authContexts, { + String? authContextId, + String expectedHostClientId = '', + }) async { + if (authContexts.isEmpty) { + throw const RemotePeerError(type: RemotePeerErrorType.authFailed, message: 'No auth contexts available'); + } + if (hostAddresses.length == 1) { - await joinSession( + await joinSessionWithContexts( deviceName, platform, hostAddresses.first, - homeSecret, - hostClientId, - userUUID, - clientIdentifier, + authContexts, + authContextId: authContextId, + expectedHostClientId: expectedHostClientId, ); return hostAddresses.first; } @@ -642,7 +813,14 @@ class CompanionRemotePeerService with KeepaliveMixin { cleanup(); // Set up the proper managed connection on the winning address - await joinSession(deviceName, platform, winner, homeSecret, hostClientId, userUUID, clientIdentifier); + await joinSessionWithContexts( + deviceName, + platform, + winner, + authContexts, + authContextId: authContextId, + expectedHostClientId: expectedHostClientId, + ); return winner; } on TimeoutException { cleanup(); @@ -791,6 +969,8 @@ class CompanionRemotePeerService with KeepaliveMixin { _myPeerId = null; _hostAddress = null; _role = null; + _selectedAuthContextId = null; + _selectedHostClientId = null; _sessionEncKey = null; _sendCounter = 0; _recvCounter = 0; diff --git a/lib/services/companion_remote/lan_discovery_service.dart b/lib/services/companion_remote/lan_discovery_service.dart index b89e2d90..4c2de4d2 100644 --- a/lib/services/companion_remote/lan_discovery_service.dart +++ b/lib/services/companion_remote/lan_discovery_service.dart @@ -3,10 +3,12 @@ import 'dart:convert'; import 'dart:io'; import '../../utils/app_logger.dart'; +import 'remote_auth_context.dart'; import 'remote_auth_service.dart'; /// A host discovered on the LAN via UDP broadcast. class DiscoveredHost { + final String authContextId; final String clientId; final String name; final String platform; @@ -15,6 +17,7 @@ class DiscoveredHost { DateTime lastSeen; DiscoveredHost({ + required this.authContextId, required this.clientId, required this.name, required this.platform, @@ -62,8 +65,36 @@ class LanDiscoveryService { required String clientId, required int wsPort, required List ips, + }) async { + return startBroadcastingForContexts( + contexts: [ + RemoteAuthContext( + id: clientId, + backend: 'legacy', + connectionId: clientId, + homeSecret: const [], + discoveryKey: discoveryKey, + clientIdentifier: clientId, + userUuid: '', + allowedUserUuids: const [], + ), + ], + deviceName: deviceName, + platform: platform, + wsPort: wsPort, + ips: ips, + ); + } + + Future startBroadcastingForContexts({ + required List contexts, + required String deviceName, + required String platform, + required int wsPort, + required List ips, }) async { await stopBroadcasting(); + if (contexts.isEmpty) return; try { _broadcastSocket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); @@ -72,29 +103,27 @@ class LanDiscoveryService { appLogger.d('LanDiscovery: Broadcasting started on port $discoveryPort'); // Send immediately, then periodically - _sendBeacon(discoveryKey, deviceName, platform, clientId, wsPort, ips); - _broadcastTimer = Timer.periodic( - const Duration(seconds: _broadcastIntervalSeconds), - (_) => _sendBeacon(discoveryKey, deviceName, platform, clientId, wsPort, ips), - ); + for (final context in contexts) { + _sendBeacon(context, deviceName, platform, wsPort, ips); + } + _broadcastTimer = Timer.periodic(const Duration(seconds: _broadcastIntervalSeconds), (_) { + for (final context in contexts) { + _sendBeacon(context, deviceName, platform, wsPort, ips); + } + }); } catch (e) { appLogger.e('LanDiscovery: Failed to start broadcasting', error: e); await stopBroadcasting(); } } - void _sendBeacon( - List discoveryKey, - String deviceName, - String platform, - String clientId, - int wsPort, - List ips, - ) { + void _sendBeacon(RemoteAuthContext context, String deviceName, String platform, int wsPort, List ips) { if (_broadcastSocket == null) return; try { final auth = RemoteAuthService.instance; + final discoveryKey = context.discoveryKey; + final clientId = context.clientIdentifier; final homeHash = auth.computeDiscoveryTag(discoveryKey); final beaconHmac = auth.computeBeaconHmac( @@ -140,10 +169,25 @@ class LanDiscoveryService { /// Start listening for host beacons. /// Returns a stream of currently-visible hosts, updated on each beacon or stale cleanup. Stream> startListening({required List discoveryKey}) { + return startListeningForContexts([ + RemoteAuthContext( + id: '', + backend: 'legacy', + connectionId: '', + homeSecret: const [], + discoveryKey: discoveryKey, + clientIdentifier: '', + userUuid: '', + allowedUserUuids: const [], + ), + ]); + } + + Stream> startListeningForContexts(List contexts) { _stopListeningInternal(); _discoveredHosts.clear(); - _bindListener(discoveryKey); + _bindListener(contexts); // Periodically remove stale hosts _staleCleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) { @@ -165,7 +209,7 @@ class LanDiscoveryService { return _hostsController.stream; } - Future _bindListener(List discoveryKey) async { + Future _bindListener(List contexts) async { try { _listenSocket = await RawDatagramSocket.bind( InternetAddress.anyIPv4, @@ -180,7 +224,7 @@ class LanDiscoveryService { if (event == RawSocketEvent.read) { final datagram = _listenSocket?.receive(); if (datagram != null) { - _handleDatagram(datagram, discoveryKey); + _handleDatagram(datagram, contexts); } } }); @@ -189,7 +233,7 @@ class LanDiscoveryService { } } - void _handleDatagram(Datagram datagram, List discoveryKey) { + void _handleDatagram(Datagram datagram, List contexts) { try { final packet = utf8.decode(datagram.data); final json = jsonDecode(packet) as Map; @@ -205,34 +249,42 @@ class LanDiscoveryService { final ips = (json['ips'] as List?)?.cast() ?? []; final hmac = json['hmac'] as String? ?? ''; - // Verify beacon HMAC final auth = RemoteAuthService.instance; - if (!auth.verifyBeaconHmac( - receivedHmac: hmac, - discoveryKey: discoveryKey, - version: version, - homeHash: homeHash, - name: name, - platform: platform, - clientId: clientId, - port: port, - ips: ips, - )) { - return; // Invalid HMAC — not from same home or tampered + RemoteAuthContext? matchedContext; + for (final context in contexts) { + final discoveryKey = context.discoveryKey; + if (!auth.verifyBeaconHmac( + receivedHmac: hmac, + discoveryKey: discoveryKey, + version: version, + homeHash: homeHash, + name: name, + platform: platform, + clientId: clientId, + port: port, + ips: ips, + )) { + continue; + } + if (!auth.matchesDiscoveryTag(homeHash, discoveryKey)) { + continue; + } + matchedContext = context; + break; } - - // Verify homeHash matches (check ±1 epoch window) - if (!auth.matchesDiscoveryTag(homeHash, discoveryKey)) { + if (matchedContext == null) { return; // Different home } // Valid beacon from same home - if (_discoveredHosts.containsKey(clientId)) { - final existing = _discoveredHosts[clientId]!; + final hostKey = clientId; + if (_discoveredHosts.containsKey(hostKey)) { + final existing = _discoveredHosts[hostKey]!; existing.lastSeen = DateTime.now(); // Only emit if fields actually changed if (existing.name != name || existing.port != port) { - _discoveredHosts[clientId] = DiscoveredHost( + _discoveredHosts[hostKey] = DiscoveredHost( + authContextId: existing.authContextId, clientId: clientId, name: name, platform: platform, @@ -242,7 +294,8 @@ class LanDiscoveryService { _emitHosts(); } } else { - _discoveredHosts[clientId] = DiscoveredHost( + _discoveredHosts[hostKey] = DiscoveredHost( + authContextId: matchedContext.id, clientId: clientId, name: name, platform: platform, diff --git a/lib/services/companion_remote/remote_auth_context.dart b/lib/services/companion_remote/remote_auth_context.dart new file mode 100644 index 00000000..2ac110cf --- /dev/null +++ b/lib/services/companion_remote/remote_auth_context.dart @@ -0,0 +1,21 @@ +class RemoteAuthContext { + const RemoteAuthContext({ + required this.id, + required this.backend, + required this.connectionId, + required this.homeSecret, + required this.discoveryKey, + required this.clientIdentifier, + required this.userUuid, + required this.allowedUserUuids, + }); + + final String id; + final String backend; + final String connectionId; + final List homeSecret; + final List discoveryKey; + final String clientIdentifier; + final String userUuid; + final List allowedUserUuids; +} diff --git a/lib/services/companion_remote/remote_auth_service.dart b/lib/services/companion_remote/remote_auth_service.dart index bfc041f0..507f60a2 100644 --- a/lib/services/companion_remote/remote_auth_service.dart +++ b/lib/services/companion_remote/remote_auth_service.dart @@ -5,14 +5,17 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import 'package:cryptography/cryptography.dart'; -import '../../models/plex_home.dart'; +import '../../models/plex/plex_home.dart'; import '../../utils/app_logger.dart'; /// Cryptographic authentication service for companion remote. /// -/// Proves same-home membership via a shared secret derived from Plex home data. -/// This authenticates **group membership**, not individual user identity — -/// any device in the same Plex home can connect. +/// 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. class RemoteAuthService { RemoteAuthService._(); static final instance = RemoteAuthService._(); @@ -21,10 +24,9 @@ class RemoteAuthService { static final _hkdf = Hkdf(hmac: Hmac(Sha256()), outputLength: 32); static final _aesGcm = AesGcm.with256bits(); - // Cached home secret — derived in memory, never persisted - List? _cachedHomeSecret; - int? _cachedHomeId; - String? _cachedAdminUUID; + // Cached account secret — derived in memory, never persisted. + List? _cachedSecret; + String? _cachedSecretKey; /// Build canonical IKM bytes from home data. /// Format: [4-byte BE len][utf8 bytes] for each field, in fixed order. @@ -44,8 +46,9 @@ class RemoteAuthService { /// This is derived in memory from cached Plex data — never persisted. Future> deriveHomeSecret(int homeId, String adminUUID) async { // Return cached if inputs unchanged - if (_cachedHomeSecret != null && _cachedHomeId == homeId && _cachedAdminUUID == adminUUID) { - return _cachedHomeSecret!; + final cacheKey = 'plex:$homeId:${adminUUID.toLowerCase()}'; + if (_cachedSecret != null && _cachedSecretKey == cacheKey) { + return _cachedSecret!; } final hkdf = _hkdf; @@ -57,12 +60,11 @@ class RemoteAuthService { info: utf8.encode('home-secret'), ); - _cachedHomeSecret = await secretKey.extractBytes(); - _cachedHomeId = homeId; - _cachedAdminUUID = adminUUID; + _cachedSecret = await secretKey.extractBytes(); + _cachedSecretKey = cacheKey; appLogger.d('RemoteAuth: Derived home secret'); - return _cachedHomeSecret!; + return _cachedSecret!; } /// Derive the long-term home secret from a PlexHome object. @@ -74,6 +76,32 @@ class RemoteAuthService { return deriveHomeSecret(home.id, admin.uuid); } + /// Derive a companion remote secret from a Jellyfin server/user identity. + Future> deriveJellyfinSecret({required String serverMachineId, required String userId}) async { + final normalizedServerId = serverMachineId.toLowerCase(); + final normalizedUserId = userId.toLowerCase(); + final cacheKey = 'jellyfin:$normalizedServerId:$normalizedUserId'; + if (_cachedSecret != null && _cachedSecretKey == cacheKey) { + return _cachedSecret!; + } + + final buf = BytesWriter(); + _writeLengthPrefixed(buf, utf8.encode(normalizedServerId)); + _writeLengthPrefixed(buf, utf8.encode(normalizedUserId)); + + final secretKey = await _hkdf.deriveKey( + secretKey: SecretKey(buf.toBytes()), + nonce: utf8.encode('plezy-remote-v1'), + info: utf8.encode('jellyfin-secret'), + ); + + _cachedSecret = await secretKey.extractBytes(); + _cachedSecretKey = cacheKey; + + appLogger.d('RemoteAuth: Derived Jellyfin secret'); + return _cachedSecret!; + } + /// Derive per-session encryption key from homeSecret + both nonces. Future> deriveSessionEncKey(List homeSecret, List hostNonce, List clientNonce) async { final hkdf = _hkdf; @@ -111,6 +139,14 @@ class RemoteAuthService { return key.extractBytes(); } + /// Stable, non-secret identifier used to select the matching auth context + /// during the WebSocket handshake without broadcasting it over UDP. + String computeAuthContextId(List homeSecret) { + final hmac = crypto.Hmac(crypto.sha256, homeSecret); + final bytes = hmac.convert(utf8.encode('auth-context-id')).bytes.take(16).toList(); + return 'v1.${base64UrlEncode(bytes).replaceAll('=', '')}'; + } + /// Compute rotating discovery tag for beacon filtering. /// Uses 5-minute epoch windows to reduce cross-network tracking. String computeDiscoveryTag(List discoveryKey, {DateTime? now}) { @@ -349,9 +385,8 @@ class RemoteAuthService { /// Clear cached home secret (e.g. on logout). void clearCache() { - _cachedHomeSecret = null; - _cachedHomeId = null; - _cachedAdminUUID = null; + _cachedSecret = null; + _cachedSecretKey = null; } // Static direction constants for external use diff --git a/lib/services/credential_vault.dart b/lib/services/credential_vault.dart new file mode 100644 index 00000000..3cddb42c --- /dev/null +++ b/lib/services/credential_vault.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:cryptography/cryptography.dart'; + +import 'base_shared_preferences_service.dart'; + +/// Encrypts credentials before they are persisted in Drift config/token +/// columns. The database no longer stores raw server tokens; registries +/// decrypt at their boundaries and rewrite legacy plaintext values on read. +/// +/// Security model: the key is stored in SharedPreferences, so this is +/// obfuscation-at-rest against casual database inspection/export rather than +/// OS-backed Keychain/Keystore protection. Anyone with full access to both app +/// prefs and the database can recover the tokens. +class CredentialVault { + CredentialVault._(); + + static const String _keyPref = 'credential_vault_key_v1'; + static const String _prefix = 'enc:v1:'; + static final AesGcm _algorithm = AesGcm.with256bits(); + static Future? _secretKey; + + static bool isProtected(String? value) => value != null && value.startsWith(_prefix); + + static Future protect(String value) async { + if (value.isEmpty || isProtected(value)) return value; + final key = await _getSecretKey(); + final box = await _algorithm.encrypt(utf8.encode(value), secretKey: key); + return '$_prefix${jsonEncode({'n': base64Encode(box.nonce), 'c': base64Encode(box.cipherText), 'm': base64Encode(box.mac.bytes)})}'; + } + + static Future reveal(String value) async { + if (!isProtected(value)) return value; + final payload = jsonDecode(value.substring(_prefix.length)) as Map; + final box = SecretBox( + base64Decode(payload['c'] as String), + nonce: base64Decode(payload['n'] as String), + mac: Mac(base64Decode(payload['m'] as String)), + ); + final clear = await _algorithm.decrypt(box, secretKey: await _getSecretKey()); + return utf8.decode(clear); + } + + static Future> protectConnectionConfig(String kind, Map config) async { + final copy = Map.from(config); + final tokenKey = switch (kind) { + 'plex' => 'accountToken', + 'jellyfin' => 'accessToken', + _ => null, + }; + final token = tokenKey == null ? null : copy[tokenKey]; + if (token is String) copy[tokenKey!] = await protect(token); + if (kind == 'plex') { + copy['servers'] = await _protectPlexServers(copy['servers']); + } + return copy; + } + + static Future<({Map config, bool migrated})> revealConnectionConfig( + String kind, + Map config, + ) async { + final copy = Map.from(config); + final tokenKey = switch (kind) { + 'plex' => 'accountToken', + 'jellyfin' => 'accessToken', + _ => null, + }; + var migrated = false; + final token = tokenKey == null ? null : copy[tokenKey]; + if (token is String && token.isNotEmpty) { + migrated = !isProtected(token); + copy[tokenKey!] = await reveal(token); + } + if (kind == 'plex') { + final result = await _revealPlexServers(copy['servers']); + copy['servers'] = result.servers; + migrated = migrated || result.migrated; + } + return (config: copy, migrated: migrated); + } + + static Future _protectPlexServers(Object? rawServers) async { + if (rawServers is! List) return rawServers; + final servers = []; + for (final raw in rawServers) { + if (raw is! Map) { + servers.add(raw); + continue; + } + final server = Map.from(raw); + final token = server['accessToken']; + if (token is String) server['accessToken'] = await protect(token); + servers.add(server); + } + return servers; + } + + static Future<({Object? servers, bool migrated})> _revealPlexServers(Object? rawServers) async { + if (rawServers is! List) return (servers: rawServers, migrated: false); + var migrated = false; + final servers = []; + for (final raw in rawServers) { + if (raw is! Map) { + servers.add(raw); + continue; + } + final server = Map.from(raw); + final token = server['accessToken']; + if (token is String && token.isNotEmpty) { + migrated = migrated || !isProtected(token); + server['accessToken'] = await reveal(token); + } + servers.add(server); + } + return (servers: servers, migrated: migrated); + } + + static Future _getSecretKey() { + return _secretKey ??= () async { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final stored = prefs.getString(_keyPref); + if (stored != null && stored.isNotEmpty) { + return SecretKey(base64Decode(stored)); + } + final bytes = List.generate(32, (_) => Random.secure().nextInt(256)); + await prefs.setString(_keyPref, base64Encode(bytes)); + return SecretKey(bytes); + }(); + } +} diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 9bdfe7ff..28765370 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -1,48 +1,71 @@ import 'dart:async'; -import 'plex_client.dart'; -import '../models/plex_hub.dart'; -import '../models/plex_library.dart'; -import '../models/plex_metadata.dart'; +import '../media/media_hub.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_library.dart'; +import '../media/media_server_client.dart'; import '../utils/app_logger.dart'; import '../utils/global_key_utils.dart'; import 'multi_server_manager.dart'; -import 'plex_auth_service.dart'; -/// Service for aggregating data from multiple Plex servers +/// Cross-server aggregation: fans calls out to every online client and +/// merges the results. Single-server operations now go through the +/// [MediaServerClient] interface directly (resolved via +/// [ProviderExtensions.tryGetMediaClientForServer] etc.), so this service +/// only owns the genuinely multi-server flows: home/discover hubs, on-deck, +/// search, and the global library list. class DataAggregationService { final MultiServerManager _serverManager; DataAggregationService(this._serverManager); - /// Fetch libraries from all online servers - /// Libraries are automatically tagged with server info by PlexClient - Future> getLibrariesFromAllServers() async { - return _perServer( - operationName: 'fetching libraries', - operation: (serverId, client, server) async { - return await client.getLibraries(); - }, - ); + /// Fetch libraries from all online clients regardless of backend, returning + /// neutral [MediaLibrary]s. + Future> getMediaLibrariesFromAllServers() async { + final clients = _serverManager.onlineClients; + if (clients.isEmpty) { + appLogger.w('No online servers available for fetching libraries (neutral)'); + return []; + } + final futures = clients.entries.map((entry) async { + try { + return await entry.value.fetchLibraries(); + } catch (e, stackTrace) { + appLogger.e('Failed neutral library fetch from ${entry.key}', error: e, stackTrace: stackTrace); + return []; + } + }); + final results = await Future.wait(futures); + return [for (final list in results) ...list]; } - /// Fetch "On Deck" (Continue Watching) from all servers and merge by recency - /// Items are automatically tagged with server info by PlexClient - Future> getOnDeckFromAllServers({int? limit, Set? hiddenLibraryKeys}) async { - final allOnDeck = await _perServer( - operationName: 'fetching on deck', - operation: (serverId, client, server) async { - return await client.getContinueWatching(); - }, - ); + /// Fetch "On Deck" (Continue Watching) from all servers and merge by recency. + /// Items are tagged with server info by the underlying client. Returns + /// neutral [MediaItem]s. + Future> getOnDeckFromAllServers({int? limit, Set? hiddenLibraryKeys}) async { + final clients = _serverManager.onlineClients; + if (clients.isEmpty) { + appLogger.w('No online servers available for fetching on deck'); + return []; + } + final futures = clients.entries.map((entry) async { + final client = entry.value; + try { + return await client.fetchContinueWatching(); + } catch (e, st) { + appLogger.e('Failed on-deck fetch from ${entry.key}', error: e, stackTrace: st); + return []; + } + }); + final allOnDeck = (await Future.wait(futures)).expand((l) => l).toList(); // Filter out items from hidden libraries - List filteredOnDeck = allOnDeck; + List filteredOnDeck = allOnDeck; if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) { filteredOnDeck = allOnDeck.where((item) { - final librarySectionId = item.librarySectionID; - if (librarySectionId == null) return true; // Keep if no section ID - final globalKey = buildGlobalKey(item.serverId!, librarySectionId.toString()); + if (item.libraryId == null || item.serverId == null) return true; + final globalKey = buildGlobalKey(item.serverId!, item.libraryId!); return !hiddenLibraryKeys.contains(globalKey); }).toList(); } @@ -62,194 +85,127 @@ class DataAggregationService { return result; } - /// Fetch recommendation hubs from all servers + /// Fetch recommendation hubs from all servers as neutral [MediaHub]s. /// When useGlobalHubs is true (default), uses the global /hubs endpoint - /// to get the true home page hubs like "Recently Added Movies", "Recently Added TV" - /// When false, uses per-library hubs from /hubs/sections/{sectionId} - Future> getHubsFromAllServers({ + /// to get the true home page hubs like "Recently Added Movies", "Recently + /// Added TV"; when false, uses per-library hubs from + /// /hubs/sections/{sectionId}. + Future> getHubsFromAllServers({ int? limit, Set? hiddenLibraryKeys, - Map>? librariesByServer, bool useGlobalHubs = true, }) async { final clients = _serverManager.onlineClients; - if (clients.isEmpty) { appLogger.w('No online servers available for fetching hubs'); return []; } - // For global hubs, fetch libraries to split "Recently Added" hubs by library - final libraries = useGlobalHubs - ? (librariesByServer ?? groupLibrariesByServer(await getLibrariesFromAllServers())) - : librariesByServer; + // For global hubs, pre-fetch libraries to split "Recently Added" hubs + // by library and resolve human-readable names. + final libraries = useGlobalHubs ? _groupLibrariesByServer(await getMediaLibrariesFromAllServers()) : null; - return useGlobalHubs - ? _fetchGlobalHubs(clients, limit: limit, hiddenLibraryKeys: hiddenLibraryKeys, librariesByServer: libraries) - : _fetchLibraryHubs( - clients, - limit: limit, - hiddenLibraryKeys: hiddenLibraryKeys, - librariesByServer: librariesByServer, - ); - } - - /// Fetch global hubs using /hubs endpoint (matches official Plex client) - Future> _fetchGlobalHubs( - Map clients, { - int? limit, - Set? hiddenLibraryKeys, - Map>? librariesByServer, - }) async { - appLogger.d('Fetching global hubs from ${clients.length} servers'); - - // Fetch global hubs from all servers in parallel - final hubFutures = clients.entries.map((entry) async { + final futures = clients.entries.map((entry) async { final serverId = entry.key; final client = entry.value; - try { - final hubs = await client.getGlobalHubs(limit: limit ?? 10); - appLogger.d('Fetched ${hubs.length} global hubs from server $serverId'); - - // Filter out items from hidden libraries if specified - if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) { - return hubs - .map((hub) { - final filteredItems = hub.items.where((item) { - // Build the global key for the item's library section - final librarySectionId = item.librarySectionID; - if (librarySectionId == null) return true; // Keep if no section ID - final globalKey = buildGlobalKey(serverId, librarySectionId.toString()); - return !hiddenLibraryKeys.contains(globalKey); - }).toList(); - - if (filteredItems.isEmpty) return null; - - return PlexHub( - hubKey: hub.hubKey, - title: hub.title, - type: hub.type, - hubIdentifier: hub.hubIdentifier, - size: filteredItems.length, - more: hub.more, - items: filteredItems, - serverId: hub.serverId, - serverName: hub.serverName, - ); - }) - .whereType() - .toList(); - } - - return hubs; + final hubs = useGlobalHubs + ? await client.fetchGlobalHubs(limit: limit ?? 10) + : await _fetchLibraryHubsForClient(client, limit: limit ?? 10, hiddenLibraryKeys: hiddenLibraryKeys); + return _postProcessHubs( + hubs, + serverId: serverId, + hiddenLibraryKeys: hiddenLibraryKeys, + libraries: libraries?[serverId], + splitRecentlyAdded: useGlobalHubs, + ); } catch (e, stackTrace) { appLogger.e('Failed to fetch hubs from server $serverId', error: e, stackTrace: stackTrace); - return []; + return []; } }); - final results = await Future.wait(hubFutures); - // Split "Recently Added" hubs that combine items from multiple libraries - final splitResults = results.map((hubs) => _splitRecentlyAddedHubs(hubs, librariesByServer)).toList(); - final result = _collectAndLimitResults(splitResults, limit); - - appLogger.i('Fetched ${result.length} global hubs from all servers'); - - return result; + final results = await Future.wait(futures); + final all = []; + for (final list in results) { + all.addAll(list); + } + return limit != null && limit < all.length ? all.sublist(0, limit) : all; } - /// Fetch per-library hubs using /hubs/sections/{sectionId} endpoint - Future> _fetchLibraryHubs( - Map clients, { - int? limit, + /// Per-library hub fetch for a single client. Filters to visible + /// movie/show libraries (Plex hides music libraries from this surface) and + /// concatenates the results. + Future> _fetchLibraryHubsForClient( + MediaServerClient client, { + required int limit, Set? hiddenLibraryKeys, - Map>? librariesByServer, }) async { - // Use pre-fetched libraries or fetch and group them - final libraries = librariesByServer ?? groupLibrariesByServer(await getLibrariesFromAllServers()); - - appLogger.d('Fetching per-library hubs from ${clients.length} servers'); - - // Fetch from all servers in parallel using cached libraries - final hubFutures = clients.entries.map((entry) async { - final serverId = entry.key; - final client = entry.value; - - try { - // Use pre-fetched libraries for this server - final serverLibraries = libraries[serverId] ?? []; - if (serverLibraries.isEmpty) { - appLogger.w('No libraries available for server $serverId'); - return []; - } - - // Filter to only visible movie/show libraries - final visibleLibraries = serverLibraries.where((library) { - if (library.type != 'movie' && library.type != 'show') { - return false; - } - if (library.hidden != null && library.hidden != 0) { - return false; - } - // Check app-level hidden libraries - if (hiddenLibraryKeys != null && hiddenLibraryKeys.contains(library.globalKey)) { - return false; - } - return true; - }).toList(); - - // Fetch hubs from all libraries in parallel - final libraryHubFutures = visibleLibraries.map((library) async { - try { - // Hubs are now tagged with server info at the source - final hubs = await client.getLibraryHubs(library.key); - appLogger.d('Fetched ${hubs.length} hubs for ${library.title} on $serverId'); - return hubs; - } catch (e) { - appLogger.w('Failed to fetch hubs for library ${library.title}: $e'); - return []; - } - }); - - final libraryHubResults = await Future.wait(libraryHubFutures); - - // Flatten all library hubs - final serverHubs = []; - for (final hubs in libraryHubResults) { - serverHubs.addAll(hubs); - } - - return serverHubs; - } catch (e, stackTrace) { - appLogger.e('Failed to fetch hubs from server $serverId', error: e, stackTrace: stackTrace); - return []; - } + final libs = await client.fetchLibraries(); + final visible = libs.where((l) { + if (l.kind != MediaKind.movie && l.kind != MediaKind.show) return false; + if (l.hidden) return false; + if (hiddenLibraryKeys != null && hiddenLibraryKeys.contains(l.globalKey)) return false; + return true; }); - - final results = await Future.wait(hubFutures); - final result = _collectAndLimitResults(results, limit); - - appLogger.i('Fetched ${result.length} library hubs from all servers'); - - return result; + final futures = visible.map((l) => client.fetchLibraryHubs(l.id, limit: limit)); + final results = await Future.wait(futures); + return [for (final list in results) ...list]; } - /// Search across all online servers - /// Results are automatically tagged with server info by PlexClient - Future> searchAcrossServers(String query, {int? limit}) async { + /// Filter hidden-library items, optionally split multi-library "Recently + /// Added" hubs by section, and drop empty hubs. + List _postProcessHubs( + List hubs, { + required String serverId, + Set? hiddenLibraryKeys, + List? libraries, + required bool splitRecentlyAdded, + }) { + var filtered = hubs; + if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) { + filtered = filtered + .map((hub) { + final filteredItems = hub.items.where((item) { + final libraryId = item.libraryId; + if (libraryId == null) return true; + final globalKey = buildGlobalKey(serverId, libraryId); + return !hiddenLibraryKeys.contains(globalKey); + }).toList(); + if (filteredItems.isEmpty) return null; + return hub.copyWith(items: filteredItems, size: filteredItems.length); + }) + .whereType() + .toList(); + } + + if (splitRecentlyAdded) { + filtered = _splitRecentlyAddedHubs(filtered, libraries); + } + return filtered; + } + + /// Search across all online servers (Plex + Jellyfin). Returns neutral + /// [MediaItem]s. + Future> searchAcrossServers(String query, {int? limit}) async { if (query.trim().isEmpty) { return []; } - final allResults = await _perServer( - operationName: 'searching for "$query"', - operation: (serverId, client, server) async { - return await client.search(query); - }, - ); + final clients = _serverManager.onlineClients; + if (clients.isEmpty) return []; - // Apply limit if specified + final futures = clients.entries.map((entry) async { + final client = entry.value; + try { + return await client.searchItems(query, limit: limit ?? 30); + } catch (e, st) { + appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st); + return []; + } + }); + + final allResults = (await Future.wait(futures)).expand((l) => l).toList(); final result = limit != null && limit < allResults.length ? allResults.sublist(0, limit) : allResults; appLogger.i('Found ${result.length} search results across all servers'); @@ -257,27 +213,9 @@ class DataAggregationService { return result; } - /// Get libraries for a specific server - Future> getLibrariesForServer(String serverId) async { - final client = _serverManager.getClient(serverId); - - if (client == null) { - appLogger.w('No client found for server $serverId'); - return []; - } - - try { - // Libraries are automatically tagged with server info by PlexClient - return await client.getLibraries(); - } catch (e, stackTrace) { - appLogger.e('Failed to fetch libraries for server $serverId', error: e, stackTrace: stackTrace); - return []; - } - } - - /// Group libraries by server - Map> groupLibrariesByServer(List libraries) { - final grouped = >{}; + /// Group libraries by server (internal aggregation helper). + Map> _groupLibrariesByServer(List libraries) { + final grouped = >{}; for (final library in libraries) { final serverId = library.serverId; @@ -289,39 +227,28 @@ class DataAggregationService { return grouped; } - // Private helper methods - - /// Collect results from multiple lists and optionally limit the total count. - List _collectAndLimitResults(List> results, int? limit) { - final all = []; - for (final items in results) { - all.addAll(items); - } - return limit != null && limit < all.length ? all.sublist(0, limit) : all; - } - /// Split "Recently Added" hubs that contain items from multiple libraries /// into separate per-library hubs, matching the official Plex client behavior. - List _splitRecentlyAddedHubs(List hubs, Map>? librariesByServer) { - final result = []; + List _splitRecentlyAddedHubs(List hubs, List? libraries) { + final result = []; for (final hub in hubs) { - final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; + final hubId = hub.identifier?.toLowerCase() ?? ''; if (!hubId.contains('.recent')) { result.add(hub); continue; } - // Group items by librarySectionID - final groups = >{}; - final ungrouped = []; + // Group items by libraryId + final groups = >{}; + final ungrouped = []; for (final item in hub.items) { - final sectionId = item.librarySectionID; - if (sectionId == null) { + final libraryId = item.libraryId; + if (libraryId == null) { ungrouped.add(item); } else { - groups.putIfAbsent(sectionId, () => []).add(item); + groups.putIfAbsent(libraryId, () => []).add(item); } } @@ -334,116 +261,42 @@ class DataAggregationService { // Multiple libraries — create one hub per library for (final entry in groups.entries) { final items = entry.value; - final libraryName = _resolveLibraryName(items.first, librariesByServer); + final libraryName = _resolveLibraryName(items.first, libraries); final title = libraryName != null ? 'Recently Added in $libraryName' : hub.title; result.add( - PlexHub( - hubKey: hub.hubKey, + hub.copyWith( title: title, - type: hub.type, - hubIdentifier: '${hub.hubIdentifier}_${entry.key}', + identifier: '${hub.identifier}_${entry.key}', size: items.length, - more: hub.more, items: items, - serverId: hub.serverId, - serverName: hub.serverName, - librarySectionID: entry.key, + libraryId: entry.key, ), ); } // Keep ungrouped items in a hub with the original title if (ungrouped.isNotEmpty) { - result.add( - PlexHub( - hubKey: hub.hubKey, - title: hub.title, - type: hub.type, - hubIdentifier: hub.hubIdentifier, - size: ungrouped.length, - more: hub.more, - items: ungrouped, - serverId: hub.serverId, - serverName: hub.serverName, - ), - ); + result.add(hub.copyWith(size: ungrouped.length, items: ungrouped)); } } return result; } - /// Resolve library name from item metadata or library lookup map. - String? _resolveLibraryName(PlexMetadata item, Map>? librariesByServer) { - // Try librarySectionTitle from the item itself (Plex API often includes it) - if (item.librarySectionTitle != null && item.librarySectionTitle!.isNotEmpty) { - return item.librarySectionTitle; + /// Resolve a library name from an item's [libraryTitle] or by looking up + /// the library in the supplied list. + String? _resolveLibraryName(MediaItem item, List? libraries) { + if (item.libraryTitle != null && item.libraryTitle!.isNotEmpty) { + return item.libraryTitle; } - - // Fall back to library lookup - if (librariesByServer != null && item.serverId != null && item.librarySectionID != null) { - final serverLibraries = librariesByServer[item.serverId]; - if (serverLibraries != null) { - for (final lib in serverLibraries) { - if (lib.key == item.librarySectionID.toString()) { - return lib.title; - } + if (libraries != null && item.libraryId != null) { + for (final lib in libraries) { + if (lib.id == item.libraryId) { + return lib.title; } } } - return null; } - - /// Base helper for per-server fan-out operations - /// - /// Returns raw results as (serverId, result) tuples. - /// Used by [_perServer] and [_perServerGrouped] for different aggregation strategies. - Future result)>> _perServerRaw({ - required String operationName, - required Future> Function(String serverId, PlexClient client, PlexServer? server) operation, - }) async { - final clients = _serverManager.onlineClients; - - if (clients.isEmpty) { - appLogger.w('No online servers available for $operationName'); - return []; - } - - appLogger.d('$operationName from ${clients.length} servers'); - - final futures = clients.entries.map((entry) async { - final serverId = entry.key; - final client = entry.value; - final server = _serverManager.getServer(serverId); - final sw = Stopwatch()..start(); - - try { - final result = await operation(serverId, client, server); - appLogger.d( - '$operationName for server $serverId completed in ${sw.elapsedMilliseconds}ms with ${result.length} items', - ); - return (serverId, result); - } catch (e, stackTrace) { - appLogger.e('Failed $operationName from server $serverId', error: e, stackTrace: stackTrace); - appLogger.d('$operationName for server $serverId failed after ${sw.elapsedMilliseconds}ms'); - return (serverId, []); - } - }); - - return await Future.wait(futures); - } - - /// Higher-order helper for per-server fan-out operations - /// - /// Iterates over all online clients, executes the operation for each server, - /// handles errors, updates server status, and flattens results into a single list. - Future> _perServer({ - required String operationName, - required Future> Function(String serverId, PlexClient client, PlexServer? server) operation, - }) async { - final results = await _perServerRaw(operationName: operationName, operation: operation); - return [for (final (_, items) in results) ...items]; - } } diff --git a/lib/services/discord_rpc_service.dart b/lib/services/discord_rpc_service.dart index 36b00021..448f5f06 100644 --- a/lib/services/discord_rpc_service.dart +++ b/lib/services/discord_rpc_service.dart @@ -3,12 +3,13 @@ import 'dart:async'; import 'package:dart_discord_presence/dart_discord_presence.dart'; import 'package:http/http.dart' as http; -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; import '../utils/app_logger.dart'; import '../utils/future_extensions.dart'; import '../utils/platform_detector.dart'; -import '../utils/plex_http_client.dart'; -import 'plex_client.dart'; +import '../utils/media_server_http_client.dart'; import 'settings_service.dart'; /// Cached Litterbox URL with expiry timestamp @@ -29,7 +30,9 @@ class DiscordRPCService { static const String _applicationId = '1453773470306402439'; static const String _litterboxUrl = 'https://litterbox.catbox.moe/resources/internals/api.php'; - /// Cache of Plex thumbnail paths to Litterbox URLs with expiry (1 hour) + /// Cache of thumbnail paths to Litterbox URLs with expiry (1 hour). Keyed + /// by `:` so the same path on different backends + /// doesn't collide. static final Map _litterboxCache = {}; static DiscordRPCService? _instance; @@ -42,8 +45,8 @@ class DiscordRPCService { bool _isConnected = false; bool _isEnabled = false; bool _isInitialized = false; - PlexMetadata? _currentMetadata; - PlexClient? _currentClient; + MediaItem? _currentMetadata; + MediaServerClient? _currentClient; String? _cachedThumbnailUrl; DateTime? _playbackStartTime; Duration? _mediaDuration; @@ -100,12 +103,14 @@ class DiscordRPCService { } } - /// Start showing presence for media playback - Future startPlayback(PlexMetadata metadata, PlexClient client) async { + /// Start showing presence for media playback. Works for any backend — + /// thumbnail upload uses the neutral [MediaServerClient.thumbnailUrl] / + /// [MediaServerClient.streamHeaders] surface. + Future startPlayback(MediaItem metadata, MediaServerClient client) async { _currentMetadata = metadata; _currentClient = client; _playbackStartTime = DateTime.now(); - _mediaDuration = metadata.duration != null ? Duration(milliseconds: metadata.duration!) : null; + _mediaDuration = metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null; _currentPosition = Duration.zero; _cachedThumbnailUrl = null; _playbackSpeed = 1.0; @@ -284,25 +289,34 @@ class DiscordRPCService { await _updatePresence(); } - Future _uploadThumbnail(PlexMetadata metadata, PlexClient client) async { + Future _uploadThumbnail(MediaItem metadata, MediaServerClient client) async { try { // Get the thumbnail path (prefer show poster for episodes) - final thumbPath = metadata.grandparentThumb ?? metadata.thumb; + final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath; if (thumbPath == null || thumbPath.isEmpty) return null; - // Check cache first (with expiry check) - final cached = _litterboxCache[thumbPath]; + // Check cache first (with expiry check). Key by backend so the same + // path on Plex and Jellyfin doesn't collide. + final cacheKey = '${client.backend.id}:$thumbPath'; + final cached = _litterboxCache[cacheKey]; if (cached != null && !cached.isExpired) { - appLogger.d('Using cached Litterbox URL for: $thumbPath'); + appLogger.d('Using cached Litterbox URL for: $cacheKey'); return cached.url; } - // Get the full URL with auth token - final imageUrl = client.getThumbnailUrl(thumbPath); + // Build the image URL. Both backends embed auth in the URL — Plex via + // `?X-Plex-Token=...`, Jellyfin via `?api_key=...` — so no extra + // headers are needed for the fetch below. We still pass [streamHeaders] + // for Plex installs that prefer header-based auth. + final imageUrl = client.thumbnailUrl(thumbPath); if (imageUrl.isEmpty) return null; // Fetch image data - final imageBytes = await httpClient.getBytes(imageUrl, timeout: const Duration(seconds: 10)); + final imageBytes = await httpClient.getBytes( + imageUrl, + headers: client.streamHeaders, + timeout: const Duration(seconds: 10), + ); if (imageBytes.isEmpty) return null; // Upload to Litterbox @@ -318,7 +332,7 @@ class DiscordRPCService { if (uploadedUrl.startsWith('http')) { // Cache the URL with 1 hour expiry (matching Litterbox) - _litterboxCache[thumbPath] = _CachedUrl(uploadedUrl, DateTime.now().add(const Duration(hours: 1))); + _litterboxCache[cacheKey] = _CachedUrl(uploadedUrl, DateTime.now().add(const Duration(hours: 1))); appLogger.d('Uploaded and cached thumbnail: $uploadedUrl'); return uploadedUrl; } @@ -344,7 +358,7 @@ class DiscordRPCService { timestamps: _buildTimestamps(), statusDisplayType: DiscordStatusDisplayType.details, largeAsset: _cachedThumbnailUrl != null - ? DiscordAsset(url: _cachedThumbnailUrl!, text: metadata.grandparentTitle ?? metadata.title!) + ? DiscordAsset(url: _cachedThumbnailUrl!, text: metadata.grandparentTitle ?? metadata.title ?? '') : null, ), ); @@ -381,34 +395,34 @@ class DiscordRPCService { } /// Build the main "details" line (first line of presence) - String _buildDetails(PlexMetadata metadata) { - switch (metadata.mediaType) { - case PlexMediaType.movie: + String _buildDetails(MediaItem metadata) { + switch (metadata.kind) { + case MediaKind.movie: final year = metadata.year != null ? ' (${metadata.year})' : ''; - return metadata.title! + year; + return (metadata.title ?? '') + year; - case PlexMediaType.episode: + case MediaKind.episode: // Show: "Show Name" or just episode title if no show name - return metadata.grandparentTitle ?? metadata.title!; + return metadata.grandparentTitle ?? metadata.title ?? ''; default: - return metadata.title!; + return metadata.title ?? ''; } } /// Build the "state" line (second line of presence) - String? _buildState(PlexMetadata metadata) { - switch (metadata.mediaType) { - case PlexMediaType.episode: + String? _buildState(MediaItem metadata) { + switch (metadata.kind) { + case MediaKind.episode: // Format: "S1 E5 - Episode Title" final season = metadata.parentIndex; final episode = metadata.index; if (season != null && episode != null) { - return 'S$season E$episode - ${metadata.title!}'; + return 'S$season E$episode - ${metadata.title ?? ''}'; } - return metadata.title!; + return metadata.title; - case PlexMediaType.movie: + case MediaKind.movie: return metadata.studio; default: diff --git a/lib/services/download_artwork_helpers.dart b/lib/services/download_artwork_helpers.dart new file mode 100644 index 00000000..a90204b6 --- /dev/null +++ b/lib/services/download_artwork_helpers.dart @@ -0,0 +1,38 @@ +import '../media/download_resolution.dart'; +import '../media/media_item.dart'; + +/// Maps a per-item image path/URL to the actual downloadable URL. +/// Plex resolves paths through `getThumbnailUrl` (token-aware); Jellyfin +/// stores absolute URLs already and passes them through. Returning `null` +/// or an empty string skips the entry. +typedef ArtworkUrlResolver = String? Function(String path); + +/// Stable storage key for artwork. Jellyfin image URLs carry `api_key` for +/// fetching, but persisted DB rows and hashed local filenames must not contain +/// long-lived tokens. +String artworkStorageKey(String pathOrUrl) { + final uri = Uri.tryParse(pathOrUrl); + if (uri == null || !uri.hasQuery) return pathOrUrl; + final params = Map.from(uri.queryParameters)..remove('api_key'); + return uri.replace(queryParameters: params.isEmpty ? null : params).toString(); +} + +/// Build [DownloadArtworkSpec]s for the four standard [MediaItem] image +/// fields (thumb, clearLogo, art, backgroundSquare). The four-field +/// enumeration is the same across backends; only the URL transformation +/// differs. +List buildArtworkSpecs(MediaItem item, ArtworkUrlResolver resolveUrl) { + final specs = []; + void addIfPresent(String? path) { + if (path == null || path.isEmpty) return; + final url = resolveUrl(path); + if (url == null || url.isEmpty) return; + specs.add(DownloadArtworkSpec(localKey: artworkStorageKey(path), url: url)); + } + + addIfPresent(item.thumbPath); + addIfPresent(item.clearLogoPath); + addIfPresent(item.artPath); + addIfPresent(item.backgroundSquarePath); + return specs; +} diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 9b1b76cf..3c4bf9be 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -2,39 +2,45 @@ import 'dart:async'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as path; -import 'package:plezy/utils/content_utils.dart'; -import 'package:plezy/utils/plex_http_client.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; import '../database/app_database.dart'; import '../database/download_operations.dart'; +import '../media/download_resolution.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; +import 'api_cache.dart'; +import 'download_artwork_helpers.dart'; import 'settings_service.dart'; import 'saf_storage_service.dart'; import 'package:saf_util/saf_util_platform_interface.dart' show SafDocumentFile; import '../models/download_models.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_media_info.dart'; import '../services/offline_mode_source.dart'; -import '../services/plex_client.dart'; import '../services/download_storage_service.dart'; -import '../services/plex_api_cache.dart'; import '../i18n/strings.g.dart'; import '../utils/app_logger.dart'; import '../utils/codec_utils.dart'; import '../utils/global_key_utils.dart'; -import '../utils/plex_cache_parser.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +typedef MediaClientResolver = MediaServerClient? Function(String serverId, {String? clientScopeId}); + /// Context for a download that's been enqueued with background_downloader. /// Carries metadata needed between enqueue and completion callback. class _DownloadContext { - final PlexMetadata metadata; + final MediaItem metadata; final DownloadQueueItem queueItem; final String filePath; // Absolute path (normal) or SAF dir URI (SAF mode) final String extension; - final PlexClient client; + final MediaServerClient client; final int? showYear; final bool isSafMode; - final PlexMediaInfo? mediaInfo; + final List? subtitles; _DownloadContext({ required this.metadata, @@ -44,15 +50,14 @@ class _DownloadContext { required this.client, this.showYear, this.isSafMode = false, - this.mediaInfo, + this.subtitles, }); } class DownloadManagerService { final AppDatabase _database; final DownloadStorageService _storageService; - final PlexApiCache _apiCache = PlexApiCache.instance; - final PlexHttpClient _http; + final MediaServerHttpClient _http; // Stream controller for download progress updates final _progressController = StreamController.broadcast(); @@ -68,10 +73,10 @@ class DownloadManagerService { // Items recovered with video complete but supplementary downloads missing final Set _pendingSupplementaryDownloads = {}; - // Resolve the correct PlexClient for a given serverId (set via setClientResolver). - // Falls back to _fallbackClient when the resolver is unavailable or returns null. - PlexClient? Function(String serverId)? _clientResolver; - PlexClient? _fallbackClient; + // Resolve the correct MediaServerClient for a given serverId/scope (set via setClientResolver). + // Falls back to _fallbackClient only when no serverId or resolver is available. + MediaClientResolver? _clientResolver; + MediaServerClient? _fallbackClient; OfflineModeSource? _offlineSource; @@ -139,13 +144,13 @@ class DownloadManagerService { DownloadManagerService({ required AppDatabase database, required DownloadStorageService storageService, - PlexHttpClient? http, + MediaServerHttpClient? http, }) : _database = database, _storageService = storageService, _http = http ?? httpClient; - /// Register a callback to resolve the correct PlexClient for a given serverId. - void setClientResolver(PlexClient? Function(String serverId) resolver) { + /// Register a callback to resolve the correct [MediaServerClient] for a given serverId. + void setClientResolver(MediaClientResolver resolver) { _clientResolver = resolver; } @@ -159,13 +164,206 @@ class DownloadManagerService { /// Look up the correct client for [serverId]. /// Returns null if the server is offline — callers should skip/defer the work. - PlexClient? _getClient(String? serverId) { + MediaServerClient? _getClient(String? serverId, {String? clientScopeId}) { if (serverId != null && _clientResolver != null) { - return _clientResolver!(serverId); + return _clientResolver!(serverId, clientScopeId: clientScopeId); } return _fallbackClient; } + Future _getClientForDownloadKey(String globalKey) async { + final parsed = parseGlobalKey(globalKey); + if (parsed == null) return _getClient(null); + final record = await _database.getDownloadedMedia(globalKey); + return _getClient(parsed.serverId, clientScopeId: record?.clientScopeId); + } + + String? activeClientScopeIdForServer(String serverId) { + final client = _getClient(serverId); + final scopeId = client?.cacheServerId; + if (scopeId == null || scopeId == serverId || scopeId.isEmpty) return null; + return scopeId; + } + + /// Bulk-load every backend's pinned metadata into one map keyed by + /// `buildGlobalKey(serverId, itemId)`. Plex and Jellyfin entries never + /// collide because `serverId` is globally unique across backends. + Future> getAllPinnedMetadata({bool preferActiveScope = false}) async { + final results = await Future.wait(MediaBackend.values.map((b) => ApiCache.forBackend(b).getAllPinnedMetadata())); + final merged = {for (final r in results) ...r}; + + for (final item in await _database.getAllDownloadedMetadata()) { + final client = _getClient(item.serverId, clientScopeId: item.clientScopeId); + final backend = client?.backend ?? await _backendForServer(item.serverId); + if (backend == null) continue; + for (final scopeId in _metadataScopeCandidates( + item.serverId, + downloadedClientScopeId: item.clientScopeId, + preferActiveScope: preferActiveScope, + )) { + final scoped = await ApiCache.forBackend(backend).getMetadata(scopeId, item.ratingKey); + if (scoped != null) { + merged[item.globalKey] = scoped; + break; + } + } + } + + return merged; + } + + /// Public mirror of [_lookupMetadata] for callers that hydrate offline + /// state outside the manager (e.g. [DownloadProvider]). + Future lookupMetadata(String serverId, String itemId, {bool preferActiveScope = false}) async { + final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); + for (final scopeId in _metadataScopeCandidates( + serverId, + downloadedClientScopeId: download?.clientScopeId, + preferActiveScope: preferActiveScope, + )) { + final hit = await _lookupMetadata(serverId, itemId, clientScopeId: scopeId == serverId ? null : scopeId); + if (hit != null) return hit; + } + return null; + } + + List _metadataScopeCandidates( + String serverId, { + String? downloadedClientScopeId, + required bool preferActiveScope, + }) { + final candidates = [ + if (preferActiveScope) ?activeClientScopeIdForServer(serverId), + ?downloadedClientScopeId, + ?_getClient(serverId, clientScopeId: downloadedClientScopeId)?.cacheServerId, + serverId, + ]; + return { + for (final id in candidates) + if (id.isNotEmpty) id, + }.toList(growable: false); + } + + /// Force-resolve metadata for [itemId] by hitting the live server when the + /// per-backend cache lookup misses. Pins the resulting cache row so the + /// next cold start finds it. Returns null when no online client is + /// available or the fetch itself fails. + /// + /// Used as a fallback by [DownloadProvider.refreshMetadataFromCache] to + /// recover from cache rows that were never written or got lost (cleared + /// data, schema reset, etc.) — without it, downloaded items render with + /// no title and sync rules show their rating key instead of the show + /// name. + Future fetchAndPinMetadata(String serverId, String itemId, {bool preferActiveScope = false}) async { + final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); + final clientScopeId = preferActiveScope + ? activeClientScopeIdForServer(serverId) ?? download?.clientScopeId + : download?.clientScopeId; + final client = _getClient(serverId, clientScopeId: clientScopeId); + if (client == null) return null; + try { + final metadata = await client.fetchItem(itemId); + if (metadata == null) return null; + await ApiCache.forBackend(client.backend).pinForOffline(client.cacheServerId, itemId); + return metadata; + } catch (e) { + appLogger.d('fetchAndPinMetadata failed for $serverId:$itemId', error: e); + return null; + } + } + + /// Resolve which backend the cache row for [serverId] uses. Reads the + /// `Connections` table directly so the lookup works even when the server + /// is currently offline (the connection persists across launches). + /// + /// Jellyfin's connection row is keyed by `${serverMachineId}/$userId` + /// while clients/cache rows use the bare machineId — match by prefix so + /// the lookup resolves either form. Uses [substr]-based prefix matching + /// (mirrors [JellyfinApiCache._serverContext]) so any `_` / `%` chars in + /// [serverId] are treated literally; `LIKE '$serverId/%'` would interpret + /// them as wildcards. + Future _backendForServer(String serverId) async { + // Prefer a live client — `MediaServerClient.backend` is in memory. + final live = _getClient(serverId); + if (live != null) return live.backend; + final prefix = '$serverId/'; + final row = + await (_database.select(_database.connections) + ..where((t) => t.id.equals(serverId) | t.id.substr(1, prefix.length).equals(prefix)) + ..limit(1)) + .getSingleOrNull(); + if (row == null) return null; + return switch (row.kind) { + 'jellyfin' => MediaBackend.jellyfin, + 'plex' => MediaBackend.plex, + _ => null, + }; + } + + /// Backend-aware metadata lookup. Dispatches to the right `getMetadata` + /// helper so callers don't have to thread backend identity through every + /// layer. + /// + /// 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. + Future _lookupMetadata(String serverId, String itemId, {String? clientScopeId}) async { + final backend = await _backendForServer(serverId); + final live = _getClient(serverId, clientScopeId: clientScopeId); + if (backend != null) { + return ApiCache.forBackend(backend).getMetadata(clientScopeId ?? live?.cacheServerId ?? serverId, itemId); + } + appLogger.w('Cache lookup for $serverId:$itemId — backend unresolved; trying all registered backends'); + for (final candidate in MediaBackend.values) { + if (clientScopeId != null && clientScopeId.isNotEmpty) { + final scopedHit = await ApiCache.forBackend(candidate).getMetadata(clientScopeId, itemId); + if (scopedHit != null) return scopedHit; + } + final hit = await ApiCache.forBackend(candidate).getMetadata(serverId, itemId); + if (hit != null) return hit; + } + return null; + } + + /// Backend-aware "ensure cached & pin" — populates the read-path cache via + /// `client.fetchItem(...)` (idempotent: no-op if warm, falls back to + /// existing cache on network error) and then pins the row so it survives + /// general cache eviction. + Future _pinMetadataForOffline(MediaServerClient client, MediaItem metadata) async { + final serverId = metadata.serverId; + if (serverId == null) { + appLogger.w('Cannot pin metadata without serverId'); + return; + } + try { + await client.fetchItem(metadata.id); + } catch (e) { + appLogger.w('fetchItem failed during offline-pin for ${metadata.globalKey}', error: e); + } + await ApiCache.forBackend(client.backend).pinForOffline(client.cacheServerId, metadata.id); + } + + Future _deleteForItemByServer(String serverId, String itemId, {String? clientScopeId}) async { + final backend = await _backendForServer(serverId); + final live = _getClient(serverId, clientScopeId: clientScopeId); + if (backend != null) { + await ApiCache.forBackend(backend).deleteForItem(clientScopeId ?? live?.cacheServerId ?? serverId, itemId); + return; + } + // Backend unresolved — purge from every registered backend so a stale + // row from either side doesn't outlive the deletion. Idempotent; + // missing rows are no-ops. + appLogger.w('Cache delete for $serverId:$itemId — backend unresolved; clearing all registered backends'); + for (final candidate in MediaBackend.values) { + if (clientScopeId != null && clientScopeId.isNotEmpty) { + await ApiCache.forBackend(candidate).deleteForItem(clientScopeId, itemId); + } + await ApiCache.forBackend(candidate).deleteForItem(serverId, itemId); + } + } + /// Initialize background_downloader with callbacks, notifications, and concurrency config. Future _initializeFileDownloader() async { if (_fileDownloaderInitialized) return; @@ -293,8 +491,8 @@ class DownloadManagerService { } /// Resume queued downloads that have no active processing. - /// Call after a PlexClient becomes available (e.g. after server connect on launch). - void resumeQueuedDownloads(PlexClient client) { + /// Call after a [MediaServerClient] becomes available (e.g. after server connect on launch). + void resumeQueuedDownloads(MediaServerClient client) { _fallbackClient = client; if (_isOffline) { @@ -322,7 +520,7 @@ class DownloadManagerService { /// Attempt supplementary downloads (artwork, subtitles) for items that were /// recovered with a completed video but missed post-processing. - Future _processPendingSupplementaryDownloads(PlexClient client) async { + Future _processPendingSupplementaryDownloads(MediaServerClient client) async { if (_pendingSupplementaryDownloads.isEmpty) return; final keys = Set.from(_pendingSupplementaryDownloads); @@ -330,9 +528,10 @@ class DownloadManagerService { for (final globalKey in keys) { try { - // Resolve the correct client for this item's server + // Resolve the correct client for this item's server/scope. final parsed = parseGlobalKey(globalKey); - final itemClient = _getClient(parsed?.serverId); + final record = await _database.getDownloadedMedia(globalKey); + final itemClient = await _getClientForDownloadKey(globalKey); if (itemClient == null) { appLogger.d('Deferring supplementary download $globalKey: server offline'); _pendingSupplementaryDownloads.add(globalKey); @@ -347,23 +546,27 @@ class DownloadManagerService { // Look up show year for episodes int? showYear; - if (metadata.type == 'episode' && metadata.grandparentRatingKey != null) { + if (metadata.isEpisode && metadata.grandparentId != null) { if (parsed != null) { - showYear = await _fetchShowYear(parsed.serverId, metadata.grandparentRatingKey); + showYear = await _fetchShowYear( + parsed.serverId, + metadata.grandparentId, + clientScopeId: record?.clientScopeId, + ); } } await _downloadArtwork(globalKey, metadata, itemClient); - await _downloadChapterThumbnails(metadata.serverId!, metadata.ratingKey, itemClient); + await _downloadChapterThumbnails(metadata.serverId!, metadata.id, itemClient); // Attempt subtitles try { - final playbackData = await itemClient.getVideoPlaybackData(metadata.ratingKey); - if (playbackData.mediaInfo != null) { - await _downloadSubtitles(globalKey, metadata, playbackData.mediaInfo!, itemClient, showYear: showYear); + final resolution = await itemClient.resolveDownload(metadata); + if (resolution.externalSubtitles.isNotEmpty) { + await _downloadSubtitles(globalKey, metadata, resolution.externalSubtitles, itemClient, showYear: showYear); } } catch (e) { - appLogger.w('Could not fetch playback data for deferred subtitles: $globalKey', error: e); + appLogger.w('Could not resolve subtitles for deferred download: $globalKey', error: e); } appLogger.i('Deferred supplementary downloads completed for $globalKey'); @@ -478,8 +681,8 @@ class DownloadManagerService { /// Queue a download for a media item Future queueDownload({ - required PlexMetadata metadata, - required PlexClient client, + required MediaItem metadata, + required MediaServerClient client, int priority = 0, bool downloadSubtitles = true, bool downloadArtwork = true, @@ -498,24 +701,20 @@ class DownloadManagerService { // Insert into database await _database.insertDownload( serverId: metadata.serverId!, - ratingKey: metadata.ratingKey, + clientScopeId: client.cacheServerId == metadata.serverId ? null : client.cacheServerId, + ratingKey: metadata.id, globalKey: globalKey, - type: metadata.type ?? '', - parentRatingKey: metadata.parentRatingKey, - grandparentRatingKey: metadata.grandparentRatingKey, + type: metadata.kind.id, + parentRatingKey: metadata.parentId, + grandparentRatingKey: metadata.grandparentId, status: DownloadStatus.queued.index, mediaIndex: mediaIndex, ); - // Ensure metadata is in cache before pinning. - // Normally getMetadataWithImages already cached the full API response (with chapters/markers), - // but if the network failed during the provider's fetch, the cache entry may not exist. - final cached = await _apiCache.get(metadata.serverId!, '/library/metadata/${metadata.ratingKey}'); - if (cached == null) { - await _cacheMetadataForOffline(metadata.serverId!, metadata.ratingKey, metadata); - } else { - await _apiCache.pinForOffline(metadata.serverId!, metadata.ratingKey); - } + // Populate the offline cache via the read path and pin so the row + // survives general eviction. Idempotent — fetchItem is a no-op when the + // cache is warm and falls back to the existing entry on network error. + await _pinMetadataForOffline(client, metadata); // Add to queue await _database.addToQueue( @@ -533,7 +732,7 @@ class DownloadManagerService { /// Process the download queue — prepares and enqueues items with background_downloader. /// Non-blocking: returns after all queued items are enqueued (downloads run natively). - Future _processQueue(PlexClient client) async { + Future _processQueue(MediaServerClient client) async { if (_isProcessingQueue) return; _isProcessingQueue = true; _fallbackClient = client; @@ -550,12 +749,11 @@ class DownloadManagerService { final nextItem = await _database.getNextQueueItem(); if (nextItem == null) break; - // Resolve the correct client for the item's server — skip if server is offline - final parsed = parseGlobalKey(nextItem.mediaGlobalKey); - final itemClient = _getClient(parsed?.serverId); + // Resolve the correct client for the item's server/scope — skip if unavailable. + final itemClient = await _getClientForDownloadKey(nextItem.mediaGlobalKey); if (itemClient == null) { appLogger.d('Skipping queued download ${nextItem.mediaGlobalKey}: server offline'); - continue; + break; } final enqueued = await _prepareAndEnqueueDownload(nextItem.mediaGlobalKey, itemClient, nextItem); if (enqueued) { @@ -582,7 +780,11 @@ class DownloadManagerService { /// Resolve metadata, video URL, and file path, then enqueue a background download task. /// Returns true if successfully enqueued, false if it failed immediately. - Future _prepareAndEnqueueDownload(String globalKey, PlexClient client, DownloadQueueItem queueItem) async { + Future _prepareAndEnqueueDownload( + String globalKey, + MediaServerClient client, + DownloadQueueItem queueItem, + ) async { try { // Guard: don't re-enqueue an item that's already completed or was deleted final existing = await _database.getDownloadedMedia(globalKey); @@ -601,12 +803,12 @@ class DownloadManagerService { final serverId = parsed.serverId; final ratingKey = parsed.ratingKey; - var metadata = await _apiCache.getMetadata(serverId, ratingKey); + MediaItem? metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: existing.clientScopeId); if (metadata == null) { // Cache miss — try re-fetching from server (cache may have been cleared between queue and prepare) appLogger.w('Cache miss for $globalKey, attempting network re-fetch'); try { - final fetched = await client.getMetadataWithImages(ratingKey); + final fetched = await client.fetchItem(ratingKey); if (fetched != null) metadata = fetched.copyWith(serverId: serverId); } catch (e) { appLogger.w('Network re-fetch failed for $globalKey', error: e); @@ -617,26 +819,25 @@ class DownloadManagerService { } final selectedMediaIndex = existing.mediaIndex; - var playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex); - if (playbackData.videoUrl == null) { - // Cache may contain a synthetic entry (from _cacheMetadataForOffline) without - // Media/Part data. Force a fresh network fetch to populate the cache properly. + var resolution = await client.resolveDownload(metadata, mediaIndex: selectedMediaIndex); + if (resolution.videoUrl == null) { + // Cache miss for the per-version fields — refresh from network. appLogger.w('No video URL from cache for $globalKey, retrying via network'); - final fetched = await client.getMetadataWithImages(ratingKey); + final fetched = await client.fetchItem(ratingKey); if (fetched != null) metadata = fetched.copyWith(serverId: serverId); - playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex); - if (playbackData.videoUrl == null) throw Exception('Could not get video URL for $globalKey'); + resolution = await client.resolveDownload(metadata, mediaIndex: selectedMediaIndex); + if (resolution.videoUrl == null) throw Exception('Could not get video URL for $globalKey'); } - final ext = _getExtensionFromUrl(playbackData.videoUrl!) ?? 'mp4'; + final ext = downloadExtensionFromUrl(resolution.videoUrl!) ?? 'mp4'; // Look up show year for episodes - final showYear = metadata.type == 'episode' - ? await _fetchShowYear(serverId, metadata.grandparentRatingKey) + final showYear = metadata.isEpisode + ? await _fetchShowYear(serverId, metadata.grandparentId, clientScopeId: existing.clientScopeId) : null; // Build display name for notifications - final displayName = metadata.type == 'episode' + final displayName = metadata.isEpisode ? '${metadata.grandparentTitle ?? metadata.displayTitle} - ${metadata.displayTitle}' : metadata.displayTitle; @@ -648,14 +849,14 @@ class DownloadManagerService { // SAF mode: use UriDownloadTask (writes directly to content:// URI, no pause/resume) final List pathComponents; final String safFileName; - if (metadata.type == 'movie') { + if (metadata.isMovie) { pathComponents = _storageService.getMovieSafPathComponents(metadata); safFileName = _storageService.getMovieSafFileName(metadata, ext); - } else if (metadata.type == 'episode') { + } else if (metadata.isEpisode) { pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); safFileName = _storageService.getEpisodeSafFileName(metadata, ext); } else { - pathComponents = [serverId, metadata.ratingKey]; + pathComponents = [serverId, metadata.id]; safFileName = 'video.$ext'; } @@ -668,7 +869,7 @@ class DownloadManagerService { await _cleanupSafTargetFile(safDirUri, safFileName); final task = UriDownloadTask( - url: playbackData.videoUrl!, + url: resolution.videoUrl!, filename: safFileName, directoryUri: Uri.parse(safDirUri), group: _downloadGroup, @@ -688,7 +889,7 @@ class DownloadManagerService { client: client, showYear: showYear, isSafMode: true, - mediaInfo: playbackData.mediaInfo, + subtitles: resolution.externalSubtitles, ); await _database.updateBgTaskId(globalKey, task.taskId); @@ -698,12 +899,12 @@ class DownloadManagerService { } else { // Normal mode: use DownloadTask with pause/resume support String downloadFilePath; - if (metadata.type == 'movie') { + if (metadata.isMovie) { downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext); - } else if (metadata.type == 'episode') { + } else if (metadata.isEpisode) { downloadFilePath = await _storageService.getEpisodeVideoPath(metadata, ext, showYear: showYear); } else { - downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.ratingKey, ext); + downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.id, ext); } // Clean up partial files from previous attempts to prevent @@ -716,7 +917,7 @@ class DownloadManagerService { await File(downloadFilePath).parent.create(recursive: true); final task = DownloadTask( - url: playbackData.videoUrl!, + url: resolution.videoUrl!, filename: path.basename(downloadFilePath), directory: path.dirname(downloadFilePath), baseDirectory: BaseDirectory.root, @@ -736,7 +937,7 @@ class DownloadManagerService { extension: ext, client: client, showYear: showYear, - mediaInfo: playbackData.mediaInfo, + subtitles: resolution.externalSubtitles, ); await _database.updateBgTaskId(globalKey, task.taskId); @@ -845,7 +1046,7 @@ class DownloadManagerService { await _database.updateBgTaskId(globalKey, null); await _transitionStatus(globalKey, DownloadStatus.queued); await _database.addToQueue(mediaGlobalKey: globalKey); - final client = _getClient(parseGlobalKey(globalKey)?.serverId); + final client = await _getClientForDownloadKey(globalKey); if (client != null) unawaited(_processQueue(client)); } @@ -875,7 +1076,7 @@ class DownloadManagerService { errorMessage.contains('Connection refused'); final isServerError = errorMessage.contains('500 Internal Server Error'); - final client = _getClient(parseGlobalKey(globalKey)?.serverId); + final client = await _getClientForDownloadKey(globalKey); final hadProgress = (existing?.downloadedBytes ?? 0) > 0; if (!isNetworkError && !isServerError && retryCount < _maxAppRetries && client != null) { @@ -924,14 +1125,14 @@ class DownloadManagerService { await _database.removeFromQueue(globalKey); // Try to enqueue more items from the queue - final client = _getClient(parseGlobalKey(globalKey)?.serverId); + final client = await _getClientForDownloadKey(globalKey); if (client != null) unawaited(_processQueue(client)); } /// Execute an app-level auto-retry: transition back to queued and re-enqueue. Future _performAutoRetry(String globalKey) async { if (_disposed) return; - final client = _getClient(parseGlobalKey(globalKey)?.serverId); + final client = await _getClientForDownloadKey(globalKey); if (client == null) { appLogger.w('Cannot auto-retry $globalKey: no client available'); return; @@ -1002,10 +1203,15 @@ class DownloadManagerService { // SAF mode recovery: re-derive path from metadata final parsed = parseGlobalKey(globalKey); if (parsed == null) throw Exception('Invalid globalKey for recovery: $globalKey'); - final metadata = await _apiCache.getMetadata(parsed.serverId, parsed.ratingKey); + final metadata = await _lookupMetadata( + parsed.serverId, + parsed.ratingKey, + clientScopeId: existing?.clientScopeId, + ); if (metadata == null) throw Exception('No metadata for SAF recovery of $globalKey'); - final ext = _getExtensionFromUrl(task.url) ?? 'mp4'; - storedPath = await _resolveSafStoredPath(metadata, ext, null) ?? ''; + final ext = downloadExtensionFromUrl(task.url) ?? 'mp4'; + storedPath = + await _resolveSafStoredPathForRecovery(metadata, ext, clientScopeId: existing?.clientScopeId) ?? ''; if (storedPath.isEmpty) throw Exception('Cannot resolve SAF path on recovery'); } else { // Normal mode recovery: reconstruct from task @@ -1020,7 +1226,7 @@ class DownloadManagerService { // ── Phase 2 (best-effort): supplementary downloads ── try { final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey); - final client = ctx?.client ?? _getClient(parseGlobalKey(globalKey)?.serverId); + final client = ctx?.client ?? await _getClientForDownloadKey(globalKey); final showYear = ctx?.showYear; // Get queue item settings (still in drift at this point) @@ -1035,20 +1241,20 @@ class DownloadManagerService { if (metadata != null && client != null) { if (downloadArtwork) { await _downloadArtwork(globalKey, metadata, client); - await _downloadChapterThumbnails(metadata.serverId!, metadata.ratingKey, client); + await _downloadChapterThumbnails(metadata.serverId!, metadata.id, client); } if (downloadSubtitles) { - PlexMediaInfo? mediaInfo = ctx?.mediaInfo; - if (mediaInfo == null) { + var subtitles = ctx?.subtitles; + if (subtitles == null) { try { - final playbackData = await client.getVideoPlaybackData(metadata.ratingKey); - mediaInfo = playbackData.mediaInfo; + final resolution = await client.resolveDownload(metadata); + subtitles = resolution.externalSubtitles; } catch (e) { - appLogger.w('Could not re-fetch playback data for subtitles', error: e); + appLogger.w('Could not re-resolve subtitles', error: e); } } - if (mediaInfo != null) { - await _downloadSubtitles(globalKey, metadata, mediaInfo, client, showYear: showYear); + if (subtitles != null && subtitles.isNotEmpty) { + await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear); } } } @@ -1067,42 +1273,40 @@ class DownloadManagerService { } finally { _completingKeys.remove(globalKey); // Always advance the queue, even after errors - final nextClient = _getClient(parseGlobalKey(globalKey)?.serverId); + final nextClient = await _getClientForDownloadKey(globalKey); if (nextClient != null) unawaited(_processQueue(nextClient)); } } /// Resolve metadata from cache using a globalKey - Future _resolveMetadata(String globalKey) async { + Future _resolveMetadata(String globalKey) async { final parsed = parseGlobalKey(globalKey); if (parsed == null) return null; - return _apiCache.getMetadata(parsed.serverId, parsed.ratingKey); + final record = await _database.getDownloadedMedia(globalKey); + return _lookupMetadata(parsed.serverId, parsed.ratingKey, clientScopeId: record?.clientScopeId); } /// Look up the year of the parent show for an episode (used for folder naming). - Future _fetchShowYear(String serverId, String? grandparentRatingKey) async { + Future _fetchShowYear(String serverId, String? grandparentRatingKey, {String? clientScopeId}) async { if (grandparentRatingKey == null) return null; - final showCached = await _apiCache.get(serverId, '/library/metadata/$grandparentRatingKey'); - final showJson = PlexCacheParser.extractFirstMetadata(showCached); - if (showJson != null) return PlexMetadata.fromJson(showJson).year; - return null; + return (await _lookupMetadata(serverId, grandparentRatingKey, clientScopeId: clientScopeId))?.year; } /// Re-derive the SAF file URI from metadata (for recovery when context is lost) - Future _resolveSafStoredPath(PlexMetadata metadata, String ext, int? showYear) async { + Future _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear) async { final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return null; final List pathComponents; final String safFileName; - if (metadata.type == 'movie') { + if (metadata.isMovie) { pathComponents = _storageService.getMovieSafPathComponents(metadata); safFileName = _storageService.getMovieSafFileName(metadata, ext); - } else if (metadata.type == 'episode') { + } else if (metadata.isEpisode) { pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); safFileName = _storageService.getEpisodeSafFileName(metadata, ext); } else { - pathComponents = [metadata.serverId!, metadata.ratingKey]; + pathComponents = [metadata.serverId!, metadata.id]; safFileName = 'video.$ext'; } @@ -1113,40 +1317,42 @@ class DownloadManagerService { return child?.uri; } + @visibleForTesting + Future debugResolveSafRecoveryShowYear(MediaItem metadata, {String? clientScopeId}) { + return _resolveSafRecoveryShowYear(metadata, clientScopeId: clientScopeId); + } + + Future _resolveSafStoredPathForRecovery(MediaItem metadata, String ext, {String? clientScopeId}) async { + final showYear = await _resolveSafRecoveryShowYear(metadata, clientScopeId: clientScopeId); + return await _resolveSafStoredPath(metadata, ext, showYear) ?? + (showYear == null ? null : await _resolveSafStoredPath(metadata, ext, null)); + } + + Future _resolveSafRecoveryShowYear(MediaItem metadata, {String? clientScopeId}) async { + final serverId = metadata.serverId; + if (!metadata.isEpisode || serverId == null) return null; + return _fetchShowYear(serverId, metadata.grandparentId, clientScopeId: clientScopeId); + } + /// Download artwork for a media item using hash-based storage /// Downloads all artwork types: thumb/poster, clearLogo, and background art - Future _downloadArtwork(String globalKey, PlexMetadata metadata, PlexClient client) async { + Future _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) return; try { _emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'artwork'); final serverId = metadata.serverId!; - - // Download thumb/poster - if (metadata.thumb != null) { - await _downloadSingleArtwork(serverId, metadata.thumb!, client); - } - - // Download clear logo - if (metadata.clearLogo != null) { - await _downloadSingleArtwork(serverId, metadata.clearLogo!, client); - } - - // Download background art - if (metadata.art != null) { - await _downloadSingleArtwork(serverId, metadata.art!, client); - } - - // Download square background art - if (metadata.backgroundSquare != null) { - await _downloadSingleArtwork(serverId, metadata.backgroundSquare!, client); + final specs = client.resolveDownloadArtwork(metadata); + for (final spec in specs) { + await _downloadSingleArtwork(serverId, spec); } // Store thumb reference in database (primary artwork for display) - await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: metadata.thumb); + final storedThumbPath = metadata.thumbPath == null ? null : artworkStorageKey(metadata.thumbPath!); + await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: storedThumbPath); - _emitProgressWithArtwork(globalKey, thumbPath: metadata.thumb); + _emitProgressWithArtwork(globalKey, thumbPath: storedThumbPath); appLogger.d('Artwork downloaded for $globalKey'); } catch (e) { appLogger.w('Failed to download artwork for $globalKey', error: e); @@ -1154,73 +1360,61 @@ class DownloadManagerService { } } - /// Download a single artwork file if it doesn't already exist - Future _downloadSingleArtwork(String serverId, String artworkPath, PlexClient client) async { + /// Download a single artwork blob if not already on disk. The [spec] carries + /// both the storage key (used to hash the local filename) and the absolute + /// URL to fetch. + Future _downloadSingleArtwork(String serverId, DownloadArtworkSpec spec) async { try { // Check if already downloaded (deduplication) - if (await _storageService.artworkExists(serverId, artworkPath)) { - appLogger.d('Artwork already exists: $artworkPath'); + if (await _storageService.artworkExists(serverId, spec.localKey)) { + appLogger.d('Artwork already exists: ${spec.localKey}'); return; } - final url = client.getThumbnailUrl(artworkPath); - if (url.isEmpty) { - appLogger.w('Empty thumbnail URL for: $artworkPath'); + if (spec.url.isEmpty) { + appLogger.w('Empty artwork URL for: ${spec.localKey}'); return; } - final filePath = await _storageService.getArtworkPathFromThumb(serverId, artworkPath); + final filePath = await _storageService.getArtworkPathFromThumb(serverId, spec.localKey); final file = File(filePath); // Ensure parent directory exists await file.parent.create(recursive: true); // Download the artwork - await _http.downloadFile(url, filePath); - appLogger.i('Downloaded artwork: $artworkPath -> $filePath'); + await _http.downloadFile(spec.url, filePath); + appLogger.i('Downloaded artwork: ${spec.localKey} -> $filePath'); } catch (e, stack) { - appLogger.w('Failed to download artwork: $artworkPath', error: e, stackTrace: stack); + appLogger.w('Failed to download artwork: ${spec.localKey}', error: e, stackTrace: stack); // Don't throw - artwork download failures shouldn't kill the entire download } } /// Download all artwork for a metadata item (public method for parent metadata) /// Downloads thumb/poster, clearLogo, and background art - Future downloadArtworkForMetadata(PlexMetadata metadata, PlexClient client) async { + Future downloadArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) return; final serverId = metadata.serverId!; - - // Download thumb/poster - if (metadata.thumb != null) { - await _downloadSingleArtwork(serverId, metadata.thumb!, client); - } - - // Download clear logo - if (metadata.clearLogo != null) { - await _downloadSingleArtwork(serverId, metadata.clearLogo!, client); - } - - // Download background art - if (metadata.art != null) { - await _downloadSingleArtwork(serverId, metadata.art!, client); - } - - // Download square background art - if (metadata.backgroundSquare != null) { - await _downloadSingleArtwork(serverId, metadata.backgroundSquare!, client); + for (final spec in client.resolveDownloadArtwork(metadata)) { + await _downloadSingleArtwork(serverId, spec); } } - /// Download chapter thumbnail images for a media item - Future _downloadChapterThumbnails(String serverId, String ratingKey, PlexClient client) async { + /// Download chapter thumbnail images for a media item. Works for any + /// backend whose [MediaServerClient.fetchPlaybackExtras] returns chapters + /// with a `thumb` path — Plex's `/library/parts/X/indexes/sd/Y` and + /// Jellyfin's `/Items/X/Images/Chapter/N?tag=Y` both pass through. + Future _downloadChapterThumbnails(String serverId, String ratingKey, MediaServerClient client) async { try { - // Get chapters from the cached API response - final extras = await client.getPlaybackExtras(ratingKey); + final extras = await client.fetchPlaybackExtras(ratingKey); for (final chapter in extras.chapters) { - if (chapter.thumb != null) { - await _downloadSingleArtwork(serverId, chapter.thumb!, client); - } + final thumb = chapter.thumb; + if (thumb == null || thumb.isEmpty) continue; + final url = client.thumbnailUrl(thumb); + if (url.isEmpty) continue; + await _downloadSingleArtwork(serverId, DownloadArtworkSpec(localKey: thumb, url: url)); } if (extras.chapters.isNotEmpty) { @@ -1235,31 +1429,23 @@ class DownloadManagerService { /// [showYear]: For episodes, pass the show's premiere year (not the episode's year) Future _downloadSubtitles( String globalKey, - PlexMetadata metadata, - PlexMediaInfo mediaInfo, - PlexClient client, { + MediaItem metadata, + List subtitles, + MediaServerClient client, { int? showYear, }) async { try { _emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'subtitles'); - for (final subtitle in mediaInfo.subtitleTracks) { - // Only download external subtitles - if (!subtitle.isExternal || subtitle.key == null) { - continue; - } - - final baseUrl = client.config.baseUrl; - final token = client.config.token ?? ''; - final subtitleUrl = subtitle.getSubtitleUrl(baseUrl, token); - if (subtitleUrl == null) continue; - - // Determine file extension + for (final subtitle in subtitles) { + // Determine file extension from codec final extension = CodecUtils.getSubtitleExtension(subtitle.codec); // Get user-friendly subtitle path based on media type final String subtitlePath; - if (metadata.isEpisode) { + if (_storageService.isUsingSaf) { + subtitlePath = await _storageService.getSubtitlePath(metadata.serverId!, metadata.id, subtitle.id, extension); + } else if (metadata.isEpisode) { subtitlePath = await _storageService.getEpisodeSubtitlePath( metadata, subtitle.id, @@ -1270,18 +1456,13 @@ class DownloadManagerService { subtitlePath = await _storageService.getMovieSubtitlePath(metadata, subtitle.id, extension); } else { // Fallback to old structure - subtitlePath = await _storageService.getSubtitlePath( - metadata.serverId!, - metadata.ratingKey, - subtitle.id, - extension, - ); + subtitlePath = await _storageService.getSubtitlePath(metadata.serverId!, metadata.id, subtitle.id, extension); } // Download subtitle file final file = File(subtitlePath); await file.parent.create(recursive: true); - await _http.downloadFile(subtitleUrl, subtitlePath); + await _http.downloadFile(subtitle.url, subtitlePath); appLogger.d('Downloaded subtitle ${subtitle.id} for $globalKey'); } @@ -1291,15 +1472,6 @@ class DownloadManagerService { } } - String? _getExtensionFromUrl(String url) { - final uri = Uri.tryParse(url); - if (uri == null) return null; - final path = uri.path; - final lastDot = path.lastIndexOf('.'); - if (lastDot == -1) return null; - return path.substring(lastDot + 1).split('?').first; - } - void _emitProgress( String globalKey, DownloadStatus status, @@ -1383,7 +1555,7 @@ class DownloadManagerService { } /// Resume a paused download - Future resumeDownload(String globalKey, PlexClient client) async { + Future resumeDownload(String globalKey, MediaServerClient client) async { final bgTaskId = await _database.getBgTaskId(globalKey); // Try native resume first (only works for normal-mode DownloadTask that was paused) @@ -1405,19 +1577,19 @@ class DownloadManagerService { await _database.updateDownloadProgress(globalKey, 0, 0, 0); await _transitionStatus(globalKey, DownloadStatus.queued); await _database.addToQueue(mediaGlobalKey: globalKey); - final resolvedClient = _getClient(parseGlobalKey(globalKey)?.serverId) ?? client; + final resolvedClient = await _getClientForDownloadKey(globalKey) ?? client; unawaited(_processQueue(resolvedClient)); } /// Retry a failed download - Future retryDownload(String globalKey, PlexClient client) async { + Future retryDownload(String globalKey, MediaServerClient client) async { _autoRetryTimers.remove(globalKey)?.cancel(); await _database.clearDownloadError(globalKey); await _database.updateBgTaskId(globalKey, null); await _database.updateDownloadProgress(globalKey, 0, 0, 0); await _transitionStatus(globalKey, DownloadStatus.queued); await _database.addToQueue(mediaGlobalKey: globalKey); - final resolvedClient = _getClient(parseGlobalKey(globalKey)?.serverId) ?? client; + final resolvedClient = await _getClientForDownloadKey(globalKey) ?? client; unawaited(_processQueue(resolvedClient)); } @@ -1452,18 +1624,20 @@ class DownloadManagerService { final serverId = parsed.serverId; final ratingKey = parsed.ratingKey; - final metadata = await _apiCache.getMetadata(serverId, ratingKey); + final downloadRecord = await _database.getDownloadedMedia(globalKey); + final clientScopeId = downloadRecord?.clientScopeId; + final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: clientScopeId); if (metadata == null) { // Fallback deletion without progress - await _deleteMediaFilesWithMetadata(serverId, ratingKey); - await _apiCache.deleteForItem(serverId, ratingKey); + await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId); + await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId); await _database.deleteDownload(globalKey); return; } // Determine total items to delete - final totalItems = await _getTotalItemsToDelete(metadata, serverId); + final totalItems = await _getTotalItemsToDelete(metadata, serverId, clientScopeId: clientScopeId); // Emit initial progress _emitDeletionProgress( @@ -1471,10 +1645,10 @@ class DownloadManagerService { ); // Delete files from storage (with progress updates) - await _deleteMediaFilesWithMetadata(serverId, ratingKey); + await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId); // Delete from API cache - await _apiCache.deleteForItem(serverId, ratingKey); + await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId); // Delete from database await _database.deleteDownload(globalKey); @@ -1497,16 +1671,16 @@ class DownloadManagerService { } /// Calculate total items to delete (for progress tracking) - Future _getTotalItemsToDelete(PlexMetadata metadata, String _) async { - switch (metadata.mediaType) { - case PlexMediaType.episode: - case PlexMediaType.movie: + Future _getTotalItemsToDelete(MediaItem metadata, String serverId, {String? clientScopeId}) async { + switch (metadata.kind) { + case MediaKind.episode: + case MediaKind.movie: return 1; - case PlexMediaType.season: - final episodes = await _database.getEpisodesBySeason(metadata.ratingKey); + case MediaKind.season: + final episodes = await _database.getEpisodesBySeason(metadata.id, serverId: serverId); return episodes.length; - case PlexMediaType.show: - final episodes = await _database.getEpisodesByShow(metadata.ratingKey); + case MediaKind.show: + final episodes = await _database.getEpisodesByShow(metadata.id, serverId: serverId); return episodes.length; default: return 1; @@ -1514,15 +1688,16 @@ class DownloadManagerService { } /// Delete media files using metadata to find correct paths - Future _deleteMediaFilesWithMetadata(String serverId, String ratingKey) async { + Future _deleteMediaFilesWithMetadata(String serverId, String ratingKey, {String? clientScopeId}) async { try { + final gk = buildGlobalKey(serverId, ratingKey); + final downloadRecord = await _database.getDownloadedMedia(gk); // Get metadata from API cache - final metadata = await _apiCache.getMetadata(serverId, ratingKey); + final scopeId = clientScopeId ?? downloadRecord?.clientScopeId; + final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: scopeId); if (metadata == null) { // Fallback: Try database record - final gk = buildGlobalKey(serverId, ratingKey); - final downloadRecord = await _database.getDownloadedMedia(gk); if (downloadRecord?.videoFilePath != null) { await _deleteByFilePath(downloadRecord!); return; @@ -1532,36 +1707,51 @@ class DownloadManagerService { } final isSaf = _storageService.isUsingSaf; - switch (metadata.mediaType) { - case PlexMediaType.episode: - isSaf ? await _deleteEpisodeFilesSaf(metadata, serverId) : await _deleteEpisodeFiles(metadata, serverId); + switch (metadata.kind) { + case MediaKind.episode: + isSaf + ? await _deleteEpisodeFilesSaf(metadata, serverId, clientScopeId: scopeId) + : await _deleteEpisodeFiles(metadata, serverId, clientScopeId: scopeId); break; - case PlexMediaType.season: - isSaf ? await _deleteSeasonFilesSaf(metadata, serverId) : await _deleteSeasonFiles(metadata, serverId); + case MediaKind.season: + isSaf + ? await _deleteSeasonFilesSaf(metadata, serverId, clientScopeId: scopeId) + : await _deleteSeasonFiles(metadata, serverId, clientScopeId: scopeId); break; - case PlexMediaType.show: - isSaf ? await _deleteShowFilesSaf(metadata, serverId) : await _deleteShowFiles(metadata, serverId); + case MediaKind.show: + isSaf + ? await _deleteShowFilesSaf(metadata, serverId, clientScopeId: scopeId) + : await _deleteShowFiles(metadata, serverId, clientScopeId: scopeId); break; - case PlexMediaType.movie: - isSaf ? await _deleteMovieFilesSaf(metadata, serverId) : await _deleteMovieFiles(metadata, serverId); + case MediaKind.movie: + isSaf + ? await _deleteMovieFilesSaf(metadata, serverId, clientScopeId: scopeId) + : await _deleteMovieFiles(metadata, serverId, clientScopeId: scopeId); break; default: - appLogger.w('Unknown type for deletion: ${metadata.type}'); + appLogger.w('Unknown type for deletion: ${metadata.kind.id}'); } } catch (e, stack) { appLogger.e('Error deleting files', error: e, stackTrace: stack); } } - /// Get chapter thumb paths from cached metadata - Future> _getChapterThumbPaths(String serverId, String ratingKey) async { + /// Get chapter thumb paths from cached metadata. Backend-aware: routes + /// through the resolved [MediaServerClient] so Jellyfin items return + /// their `/Items/.../Images/Chapter/...?tag=...` paths and Plex items + /// return their `/library/parts/.../indexes/sd/...` paths. Both shapes + /// hash through [DownloadStorageService] the same way. + /// + /// `fetchPlaybackExtras` consults each backend's cache first, so this + /// stays cheap during deletion (no network round-trip when the metadata + /// is already cached, which it always is for downloaded items). + Future> _getChapterThumbPaths(String serverId, String ratingKey, {String? clientScopeId}) async { try { - final cachedData = await _apiCache.get(serverId, '/library/metadata/$ratingKey'); - final chapters = PlexCacheParser.extractChapters(cachedData); - if (chapters == null) return []; - - return chapters - .map((ch) => ch['thumb'] as String?) + final client = _getClient(serverId, clientScopeId: clientScopeId); + if (client == null) return []; + final extras = await client.fetchPlaybackExtras(ratingKey); + return extras.chapters + .map((ch) => ch.thumb) .where((thumb) => thumb != null && thumb.isNotEmpty) .cast() .toList(); @@ -1576,9 +1766,11 @@ class DownloadManagerService { /// Pre-loads all chapter paths for other items on the same server in one pass, /// then checks membership in a Set — O(items * chapters) instead of /// O(thumbs * items * chapters) with repeated DB queries. - Future _deleteChapterThumbnails(String serverId, String ratingKey) async { + Future _deleteChapterThumbnails(String serverId, String ratingKey, {String? clientScopeId}) async { try { - final thumbPaths = await _getChapterThumbPaths(serverId, ratingKey); + final record = await _database.getDownloadedMedia(buildGlobalKey(serverId, ratingKey)); + final scopeId = clientScopeId ?? record?.clientScopeId; + final thumbPaths = await _getChapterThumbPaths(serverId, ratingKey, clientScopeId: scopeId); if (thumbPaths.isEmpty) { appLogger.d('No chapter thumbnails to delete for $ratingKey'); @@ -1590,7 +1782,11 @@ class DownloadManagerService { final inUseThumbPaths = {}; for (final item in otherItems) { if (item.ratingKey == ratingKey) continue; - final itemChapterPaths = await _getChapterThumbPaths(serverId, item.ratingKey); + final itemChapterPaths = await _getChapterThumbPaths( + serverId, + item.ratingKey, + clientScopeId: item.clientScopeId, + ); inUseThumbPaths.addAll(itemChapterPaths); } @@ -1624,10 +1820,10 @@ class DownloadManagerService { } /// Delete episode files - Future _deleteEpisodeFiles(PlexMetadata episode, String serverId) async { + Future _deleteEpisodeFiles(MediaItem episode, String serverId, {String? clientScopeId}) async { try { - final parentMetadata = episode.grandparentRatingKey != null - ? await _apiCache.getMetadata(serverId, episode.grandparentRatingKey!) + final parentMetadata = episode.grandparentId != null + ? await _lookupMetadata(serverId, episode.grandparentId!, clientScopeId: clientScopeId) : null; final showYear = parentMetadata?.year; @@ -1653,34 +1849,35 @@ class DownloadManagerService { } // Delete chapter thumbnails (with reference counting) - await _deleteChapterThumbnails(serverId, episode.ratingKey); + await _deleteChapterThumbnails(serverId, episode.id, clientScopeId: clientScopeId); // Clean up parent directories if empty await _cleanupEmptyDirectories(episode, showYear); // Safety net: verify the actual DB-recorded file is gone - await _ensureDbFileDeleted(serverId, episode.ratingKey); + await _ensureDbFileDeleted(serverId, episode.id); } catch (e, stack) { appLogger.e('Error deleting episode files', error: e, stackTrace: stack); } } /// Delete season files - Future _deleteSeasonFiles(PlexMetadata season, String serverId) async { + Future _deleteSeasonFiles(MediaItem season, String serverId, {String? clientScopeId}) async { try { - final parentMetadata = season.parentRatingKey != null - ? await _apiCache.getMetadata(serverId, season.parentRatingKey!) + final parentMetadata = season.parentId != null + ? await _lookupMetadata(serverId, season.parentId!, clientScopeId: clientScopeId) : null; final showYear = parentMetadata?.year; // Get all episodes in this season - final episodesInSeason = await _database.getEpisodesBySeason(season.ratingKey); + final episodesInSeason = await _database.getEpisodesBySeason(season.id, serverId: serverId); - appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.ratingKey}'); + appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id}'); await _deleteEpisodesInCollection( episodes: episodesInSeason, serverId: serverId, - parentKey: season.ratingKey, + clientScopeId: clientScopeId, + parentKey: season.id, parentTitle: season.displayTitle, ); @@ -1702,6 +1899,7 @@ class DownloadManagerService { Future _deleteEpisodesInCollection({ required List episodes, required String serverId, + String? clientScopeId, required String parentKey, required String parentTitle, }) async { @@ -1721,34 +1919,45 @@ class DownloadManagerService { ); if (isSaf) { - final episodeMetadata = await _apiCache.getMetadata(serverId, episode.ratingKey); + final episodeScopeId = episode.clientScopeId ?? clientScopeId; + final episodeMetadata = await _lookupMetadata(serverId, episode.ratingKey, clientScopeId: episodeScopeId); if (episodeMetadata != null) { - await _deleteEpisodeFilesSaf(episodeMetadata, serverId, skipSafVideoAndParents: true); + await _deleteEpisodeFilesSaf( + episodeMetadata, + serverId, + clientScopeId: episodeScopeId, + skipSafVideoAndParents: true, + ); } else { - await _deleteChapterThumbnails(serverId, episode.ratingKey); + await _deleteChapterThumbnails(serverId, episode.ratingKey, clientScopeId: episodeScopeId); await _deleteByFilePath(episode); } } else { - await _deleteChapterThumbnails(serverId, episode.ratingKey); + await _deleteChapterThumbnails( + serverId, + episode.ratingKey, + clientScopeId: episode.clientScopeId ?? clientScopeId, + ); await _deleteByFilePath(episode); } - await _apiCache.deleteForItem(serverId, episode.ratingKey); + await _deleteForItemByServer(serverId, episode.ratingKey, clientScopeId: episode.clientScopeId ?? clientScopeId); await _database.deleteDownload(episodeGlobalKey); } } /// Delete show files - Future _deleteShowFiles(PlexMetadata show, String serverId) async { + Future _deleteShowFiles(MediaItem show, String serverId, {String? clientScopeId}) async { try { // Get all episodes in this show - final episodesInShow = await _database.getEpisodesByShow(show.ratingKey); + final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId); - appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.ratingKey}'); + appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id}'); await _deleteEpisodesInCollection( episodes: episodesInShow, serverId: serverId, - parentKey: show.ratingKey, + clientScopeId: clientScopeId, + parentKey: show.id, parentTitle: show.displayTitle, ); @@ -1763,7 +1972,7 @@ class DownloadManagerService { } /// Delete movie files - Future _deleteMovieFiles(PlexMetadata movie, String serverId) async { + Future _deleteMovieFiles(MediaItem movie, String serverId, {String? clientScopeId}) async { try { final movieDir = await _storageService.getMovieDirectory(movie); if (await movieDir.exists()) { @@ -1772,16 +1981,16 @@ class DownloadManagerService { } // Delete chapter thumbnails (with reference counting) - await _deleteChapterThumbnails(serverId, movie.ratingKey); + await _deleteChapterThumbnails(serverId, movie.id, clientScopeId: clientScopeId); // Safety net: verify the actual DB-recorded file is gone - await _ensureDbFileDeleted(serverId, movie.ratingKey); + await _ensureDbFileDeleted(serverId, movie.id); } catch (e, stack) { appLogger.e('Error deleting movie files', error: e, stackTrace: stack); } } - Future _deleteMovieFilesSaf(PlexMetadata movie, String serverId) async { + Future _deleteMovieFilesSaf(MediaItem movie, String serverId, {String? clientScopeId}) async { try { final safBaseUri = _storageService.safBaseUri; if (safBaseUri != null) { @@ -1793,8 +2002,8 @@ class DownloadManagerService { await _deleteSafDirRecursive(movieDir.uri, description: 'movie directory'); } } - await _deleteChapterThumbnails(serverId, movie.ratingKey); - await _ensureDbFileDeleted(serverId, movie.ratingKey); + await _deleteChapterThumbnails(serverId, movie.id, clientScopeId: clientScopeId); + await _ensureDbFileDeleted(serverId, movie.id); } catch (e, stack) { appLogger.e('Error deleting SAF movie files', error: e, stackTrace: stack); } @@ -1803,13 +2012,14 @@ class DownloadManagerService { /// When called inside a bulk season/show delete, the caller wipes the parent /// dir — so we skip the SAF video delete and parent walk-up here. Future _deleteEpisodeFilesSaf( - PlexMetadata episode, + MediaItem episode, String serverId, { + String? clientScopeId, bool skipSafVideoAndParents = false, }) async { try { - final parentMetadata = episode.grandparentRatingKey != null - ? await _apiCache.getMetadata(serverId, episode.grandparentRatingKey!) + final parentMetadata = episode.grandparentId != null + ? await _lookupMetadata(serverId, episode.grandparentId!, clientScopeId: clientScopeId) : null; final showYear = parentMetadata?.year; @@ -1847,30 +2057,31 @@ class DownloadManagerService { appLogger.i('Deleted episode subtitles: ${subsDir.path}'); } - await _deleteChapterThumbnails(serverId, episode.ratingKey); + await _deleteChapterThumbnails(serverId, episode.id, clientScopeId: clientScopeId); if (!skipSafVideoAndParents) { await _deleteEmptySafDirsInOrder([seasonDirUri, showDirUri]); - await _ensureDbFileDeleted(serverId, episode.ratingKey); + await _ensureDbFileDeleted(serverId, episode.id); } } catch (e, stack) { appLogger.e('Error deleting SAF episode files', error: e, stackTrace: stack); } } - Future _deleteSeasonFilesSaf(PlexMetadata season, String serverId) async { + Future _deleteSeasonFilesSaf(MediaItem season, String serverId, {String? clientScopeId}) async { try { - final parentMetadata = season.parentRatingKey != null - ? await _apiCache.getMetadata(serverId, season.parentRatingKey!) + final parentMetadata = season.parentId != null + ? await _lookupMetadata(serverId, season.parentId!, clientScopeId: clientScopeId) : null; final showYear = parentMetadata?.year; - final episodesInSeason = await _database.getEpisodesBySeason(season.ratingKey); - appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.ratingKey} (SAF)'); + final episodesInSeason = await _database.getEpisodesBySeason(season.id, serverId: serverId); + appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id} (SAF)'); await _deleteEpisodesInCollection( episodes: episodesInSeason, serverId: serverId, - parentKey: season.ratingKey, + clientScopeId: clientScopeId, + parentKey: season.id, parentTitle: season.displayTitle, ); @@ -1897,14 +2108,15 @@ class DownloadManagerService { } } - Future _deleteShowFilesSaf(PlexMetadata show, String serverId) async { + Future _deleteShowFilesSaf(MediaItem show, String serverId, {String? clientScopeId}) async { try { - final episodesInShow = await _database.getEpisodesByShow(show.ratingKey); - appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.ratingKey} (SAF)'); + final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId); + appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id} (SAF)'); await _deleteEpisodesInCollection( episodes: episodesInShow, serverId: serverId, - parentKey: show.ratingKey, + clientScopeId: clientScopeId, + parentKey: show.id, parentTitle: show.displayTitle, ); @@ -1988,7 +2200,7 @@ class DownloadManagerService { /// Clean up empty directories after deleting episode (file mode only — the /// SAF deleters call [_deleteEmptySafDirsInOrder] directly). - Future _cleanupEmptyDirectories(PlexMetadata episode, int? showYear) async { + Future _cleanupEmptyDirectories(MediaItem episode, int? showYear) async { if (_storageService.isUsingSaf) return; final seasonDir = await _storageService.getSeasonDirectory(episode, showYear: showYear); @@ -2009,7 +2221,7 @@ class DownloadManagerService { } /// Clean up show directory if empty (file mode only). - Future _cleanupShowDirectory(PlexMetadata metadata, int? showYear) async { + Future _cleanupShowDirectory(MediaItem metadata, int? showYear) async { if (_storageService.isUsingSaf) return; final showDir = await _storageService.getShowDirectory(metadata, showYear: showYear); @@ -2027,8 +2239,8 @@ class DownloadManagerService { } /// Check if season artwork is in use - Future _isSeasonArtworkInUse(PlexMetadata episode, int? _) async { - final seasonKey = episode.parentRatingKey; + Future _isSeasonArtworkInUse(MediaItem episode, int? _) async { + final seasonKey = episode.parentId; if (seasonKey == null) return false; final otherEpisodes = await _database.getEpisodesBySeason(seasonKey); @@ -2038,8 +2250,8 @@ class DownloadManagerService { } /// Check if show artwork is in use - Future _isShowArtworkInUse(PlexMetadata metadata, int? _) async { - final showKey = metadata.grandparentRatingKey ?? metadata.parentRatingKey ?? metadata.ratingKey; + Future _isShowArtworkInUse(MediaItem metadata, int? _) async { + final showKey = metadata.grandparentId ?? metadata.parentId ?? metadata.id; // Use targeted query instead of full table scan final showEpisodes = await _database.getEpisodesByShow(showKey); @@ -2097,8 +2309,9 @@ class DownloadManagerService { } } - // thumbPath is a Plex API path (e.g. /library/metadata/123/thumb/...), - // not a local file path — resolve it via getArtworkPathFromThumb + // thumbPath is a server-side API path (Plex /library/metadata/.../thumb, + // Jellyfin /Items/.../Images/Primary), not a local file path — + // resolve it via getArtworkPathFromThumb if (record.thumbPath != null) { final parsed = parseGlobalKey(record.globalKey); if (parsed != null) { @@ -2122,50 +2335,16 @@ class DownloadManagerService { } /// Save metadata for a media item (show, season, movie, or episode) - /// Used to persist parent metadata (shows/seasons) for offline display - Future saveMetadata(PlexMetadata metadata) async { + /// Used to persist parent metadata (shows/seasons) for offline display. + /// + /// Both backends now 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) { appLogger.w('Cannot save metadata without serverId'); return; } - - // Cache to API cache for offline use - await _cacheMetadataForOffline(metadata.serverId!, metadata.ratingKey, metadata); - } - - /// Cache metadata in the API response format for offline access - /// This simulates what PlexClient would receive from the server - /// Merges with existing cache to preserve Chapter/Marker/Media arrays - Future _cacheMetadataForOffline(String serverId, String ratingKey, PlexMetadata metadata) async { - final endpoint = '/library/metadata/$ratingKey'; - - // Check for existing cache entry to preserve fields not in PlexMetadata - final existing = await _apiCache.get(serverId, endpoint); - final existingMeta = PlexCacheParser.extractFirstMetadata(existing); - - Map merged; - if (existingMeta != null) { - // Start with existing (has Chapter/Marker/Media), overlay new metadata - merged = existingMeta; - final newJson = metadata.toJson(); - // Only update fields that toJson() sets to non-null values - for (final entry in newJson.entries) { - if (entry.value != null) { - merged[entry.key] = entry.value; - } - } - } else { - merged = metadata.toJson(); - } - - final cachedResponse = { - 'MediaContainer': { - 'Metadata': [merged], - }, - }; - - await _apiCache.put(serverId, endpoint, cachedResponse); - await _apiCache.pinForOffline(serverId, ratingKey); + await _pinMetadataForOffline(client, metadata); } void dispose() { @@ -2186,3 +2365,25 @@ class DownloadManagerService { _deletionProgressController.close(); } } + +String? downloadExtensionFromUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return null; + final lastSegment = uri.pathSegments.isEmpty ? '' : uri.pathSegments.last; + final lastDot = lastSegment.lastIndexOf('.'); + if (lastDot != -1 && lastDot < lastSegment.length - 1) { + return _safeDownloadExtension(lastSegment.substring(lastDot + 1)); + } + for (final entry in uri.queryParameters.entries) { + if (entry.key.toLowerCase() == 'container') { + return _safeDownloadExtension(entry.value); + } + } + return null; +} + +String? _safeDownloadExtension(String raw) { + final ext = raw.split(RegExp(r'[,|]')).first.trim().replaceFirst(RegExp(r'^\.+'), '').toLowerCase(); + if (ext.isEmpty || !RegExp(r'^[a-z0-9][a-z0-9._-]{0,39}$').hasMatch(ext)) return null; + return ext; +} diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index da722165..13cf61fb 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; import 'settings_service.dart'; @@ -86,7 +86,7 @@ class DownloadStorageService { } /// Format episode filename base: S{XX}E{XX} - {Title} - String _formatEpisodeFileName(PlexMetadata episode) { + String _formatEpisodeFileName(MediaItem episode) { final season = padNumber(episode.parentIndex ?? 0, 2); final ep = padNumber(episode.index ?? 0, 2); final episodeName = _sanitizeFileName(episode.title!); @@ -168,8 +168,11 @@ class DownloadStorageService { return artworkDir; } - /// Get artwork file path from Plex thumb path (synchronous, requires initialization) - /// Returns path to cached artwork file using hash of the thumb URL, or null if not initialized + /// Get artwork file path from a server-side thumb path (synchronous, requires initialization). + /// Works for any backend — the thumb path is hashed alongside the serverId, + /// so Plex `/library/metadata/.../thumb` and Jellyfin + /// `/Items/.../Images/Primary` paths both round-trip cleanly. + /// Returns path to cached artwork file using hash of the thumb URL, or null if not initialized. /// Example: artwork/a1b2c3d4e5f6.jpg String? getArtworkPathSync(String serverId, String thumbPath) { if (_artworkDirectoryPath == null) return null; @@ -178,7 +181,8 @@ class DownloadStorageService { return path.join(_artworkDirectoryPath!, '$hash.jpg'); } - /// Get artwork file path from Plex thumb path (async version) + /// Get artwork file path from a server-side thumb path (async version). + /// Backend-neutral — see [getArtworkPathSync] for details. Future getArtworkPathFromThumb(String serverId, String thumbPath) async { final artworkDir = await getArtworkDirectory(); final hash = _hashArtworkPath(serverId, thumbPath); @@ -260,27 +264,27 @@ class DownloadStorageService { } /// Get the folder name for a movie: "Movie Name (YYYY)" - String _getMovieFolderName(PlexMetadata movie) { + String _getMovieFolderName(MediaItem movie) { return _formatTitleWithYear(movie.title!, movie.year); } /// Get the folder name for a TV show: "Show Name (YYYY)" /// [showYear]: Pass explicitly for episodes (episode.year may differ from show's year) - String _getShowFolderName(PlexMetadata metadata, {int? showYear}) { + String _getShowFolderName(MediaItem metadata, {int? showYear}) { final title = metadata.grandparentTitle ?? metadata.title!; final year = showYear ?? metadata.year; return _formatTitleWithYear(title, year); } /// Get movie directory: downloads/Movies/{Movie Name} ({Year})/ - Future getMovieDirectory(PlexMetadata movie) async { + Future getMovieDirectory(MediaItem movie) async { final baseDir = await getDownloadsDirectory(); final movieFolder = _getMovieFolderName(movie); return _ensureDirectoryExists(Directory(path.join(baseDir.path, 'Movies', movieFolder))); } /// Get movie video file path: .../Movie Name (YYYY)/Movie Name (YYYY).{ext} - Future getMovieVideoPath(PlexMetadata movie, String extension) async { + Future getMovieVideoPath(MediaItem movie, String extension) async { final movieDir = await getMovieDirectory(movie); final fileName = _getMovieFolderName(movie); return path.join(movieDir.path, '$fileName.$extension'); @@ -289,7 +293,7 @@ class DownloadStorageService { /// Get show directory: downloads/TV Shows/{Show Name} ({Year})/ /// [showYear]: Pass the show's premiere year explicitly (for episodes, the episode's /// year may differ from the show's year). If not provided, uses metadata.year. - Future getShowDirectory(PlexMetadata metadata, {int? showYear}) async { + Future getShowDirectory(MediaItem metadata, {int? showYear}) async { final baseDir = await getDownloadsDirectory(); final showFolder = _getShowFolderName(metadata, showYear: showYear); return _ensureDirectoryExists(Directory(path.join(baseDir.path, 'TV Shows', showFolder))); @@ -297,7 +301,7 @@ class DownloadStorageService { /// Get season directory: .../TV Shows/{Show}/Season {XX}/ /// [showYear]: Pass the show's premiere year (not episode or season year) - Future getSeasonDirectory(PlexMetadata metadata, {int? showYear}) async { + Future getSeasonDirectory(MediaItem metadata, {int? showYear}) async { final showDir = await getShowDirectory(metadata, showYear: showYear); final seasonNum = padNumber(metadata.parentIndex ?? 0, 2); return _ensureDirectoryExists(Directory(path.join(showDir.path, 'Season $seasonNum'))); @@ -305,7 +309,7 @@ class DownloadStorageService { /// Get base path info for episode files (season directory path and formatted filename). /// [showYear]: Pass the show's premiere year (not episode year) - Future<({String seasonDirPath, String fileName})> _getEpisodeBasePath(PlexMetadata episode, {int? showYear}) async { + Future<({String seasonDirPath, String fileName})> _getEpisodeBasePath(MediaItem episode, {int? showYear}) async { final seasonDir = await getSeasonDirectory(episode, showYear: showYear); final fileName = _formatEpisodeFileName(episode); return (seasonDirPath: seasonDir.path, fileName: fileName); @@ -313,41 +317,41 @@ class DownloadStorageService { /// Get episode video file path: .../Season XX/S{XX}E{XX} - {Title}.{ext} /// [showYear]: Pass the show's premiere year (not episode year) - Future getEpisodeVideoPath(PlexMetadata episode, String extension, {int? showYear}) async { + Future getEpisodeVideoPath(MediaItem episode, String extension, {int? showYear}) async { final base = await _getEpisodeBasePath(episode, showYear: showYear); return path.join(base.seasonDirPath, '${base.fileName}.$extension'); } /// Get episode thumbnail path: .../Season XX/S{XX}E{XX} - {Title}.jpg /// [showYear]: Pass the show's premiere year (not episode year) - Future getEpisodeThumbnailPath(PlexMetadata episode, {int? showYear}) async { + Future getEpisodeThumbnailPath(MediaItem episode, {int? showYear}) async { final base = await _getEpisodeBasePath(episode, showYear: showYear); return path.join(base.seasonDirPath, '${base.fileName}.jpg'); } /// Get subtitles directory for episode: .../Season XX/S{XX}E{XX} - {Title}_subs/ /// [showYear]: Pass the show's premiere year (not episode year) - Future getEpisodeSubtitlesDirectory(PlexMetadata episode, {int? showYear}) async { + Future getEpisodeSubtitlesDirectory(MediaItem episode, {int? showYear}) async { final base = await _getEpisodeBasePath(episode, showYear: showYear); return _ensureDirectoryExists(Directory(path.join(base.seasonDirPath, '${base.fileName}_subs'))); } /// Get episode subtitle path /// [showYear]: Pass the show's premiere year (not episode year) - Future getEpisodeSubtitlePath(PlexMetadata episode, int trackId, String extension, {int? showYear}) async { + Future getEpisodeSubtitlePath(MediaItem episode, int trackId, String extension, {int? showYear}) async { final subsDir = await getEpisodeSubtitlesDirectory(episode, showYear: showYear); return path.join(subsDir.path, '$trackId.$extension'); } /// Get subtitles directory for movie - Future getMovieSubtitlesDirectory(PlexMetadata movie) async { + Future getMovieSubtitlesDirectory(MediaItem movie) async { final movieDir = await getMovieDirectory(movie); final baseName = _getMovieFolderName(movie); return _ensureDirectoryExists(Directory(path.join(movieDir.path, '${baseName}_subs'))); } /// Get movie subtitle path - Future getMovieSubtitlePath(PlexMetadata movie, int trackId, String extension) async { + Future getMovieSubtitlePath(MediaItem movie, int trackId, String extension) async { final subsDir = await getMovieSubtitlesDirectory(movie); return path.join(subsDir.path, '$trackId.$extension'); } @@ -461,43 +465,43 @@ class DownloadStorageService { /// Get path components for SAF based on media type /// Returns list of directory names to create under the SAF base - List getMovieSafPathComponents(PlexMetadata movie) { + List getMovieSafPathComponents(MediaItem movie) { return ['Movies', _getMovieFolderName(movie)]; } /// Get path components for episode SAF storage - List getEpisodeSafPathComponents(PlexMetadata episode, {int? showYear}) { + List getEpisodeSafPathComponents(MediaItem episode, {int? showYear}) { final showFolder = _getShowFolderName(episode, showYear: showYear); final seasonNum = padNumber(episode.parentIndex ?? 0, 2); return ['TV Shows', showFolder, 'Season $seasonNum']; } /// Get SAF path components for a show directory: ['TV Shows', {showFolder}] - List getShowSafPathComponents(PlexMetadata metadata, {int? showYear}) { + List getShowSafPathComponents(MediaItem metadata, {int? showYear}) { return ['TV Shows', _getShowFolderName(metadata, showYear: showYear)]; } /// Get SAF path components for a season directory when called with season metadata: /// ['TV Shows', {showFolder}, 'Season XX']. Uses season.index for the season number. - List getSeasonSafPathComponents(PlexMetadata season, {int? showYear}) { + List getSeasonSafPathComponents(MediaItem season, {int? showYear}) { final showFolder = _getShowFolderName(season, showYear: showYear); final seasonNum = padNumber(season.index ?? 0, 2); return ['TV Shows', showFolder, 'Season $seasonNum']; } /// Get SAF file name for a movie - String getMovieSafFileName(PlexMetadata movie, String extension) { + String getMovieSafFileName(MediaItem movie, String extension) { return '${_getMovieFolderName(movie)}.$extension'; } /// Get SAF file name for an episode - String getEpisodeSafFileName(PlexMetadata episode, String extension) { + String getEpisodeSafFileName(MediaItem episode, String extension) { final fileName = _formatEpisodeFileName(episode); return '$fileName.$extension'; } /// Get the extension-less episode filename used for SAF lookups. - String getEpisodeSafBaseName(PlexMetadata episode) => _formatEpisodeFileName(episode); + String getEpisodeSafBaseName(MediaItem episode) => _formatEpisodeFileName(episode); /// Check if a path is a SAF content URI bool isSafUri(String storedPath) { diff --git a/lib/services/episode_navigation_service.dart b/lib/services/episode_navigation_service.dart index bbf250f2..a34417f3 100644 --- a/lib/services/episode_navigation_service.dart +++ b/lib/services/episode_navigation_service.dart @@ -3,16 +3,20 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/play_queue.dart'; import '../mpv/mpv.dart'; -import '../models/plex_metadata.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; +import '../services/multi_server_manager.dart'; import '../utils/app_logger.dart'; import '../utils/video_player_navigation.dart'; /// Result of loading adjacent episodes class AdjacentEpisodes { - final PlexMetadata? next; - final PlexMetadata? previous; + final MediaItem? next; + final MediaItem? previous; AdjacentEpisodes({this.next, this.previous}); @@ -27,31 +31,62 @@ class AdjacentEpisodes { /// - Navigating between episodes while preserving track selections /// - Supporting both sequential and shuffle playback modes /// -/// All episode navigation uses Plex play queues for consistent behavior. +/// Plex episodes navigate through the server-side `/playQueues` queue; +/// Jellyfin (and any other backend whose +/// [MediaServerClient.fetchClientSideEpisodeQueue] returns rows) builds +/// a centred 21-item local queue here and publishes it through +/// [PlaybackStateProvider] so the rest of the player reads prev/next from +/// the same source. class EpisodeNavigationService { + /// Cached client-side episode lists, keyed by `seriesId`. Populated by + /// backends without server-side play queues (Jellyfin); Plex skips this + /// path entirely. Fetched once per series; subsequent navigation within + /// the show re-uses the cache so jumping anywhere doesn't trigger a + /// refetch. + /// + /// Bounded by [_seriesCacheCapacity] LRU-style: each entry holds up to + /// 200 episodes (~50–80 KB each at typical metadata sizes), so an + /// unbounded map opens an OOM door for users who hop between many shows + /// in one session. `LinkedHashMap` preserves insertion order; we re-touch + /// on hit to keep the most recently used at the back. + final Map> _seriesEpisodeCache = >{}; + + /// Maximum number of distinct series whose episode lists stay resident. + /// 5 covers any plausible "binge a few shows in parallel" workflow without + /// holding ~5–10 MB of metadata when the user wanders the library. + static const int _seriesCacheCapacity = 5; + /// Load the next and previous episodes for the current episode /// /// Returns null for episodes if: /// - Not applicable (e.g., movie content) /// - Next episode doesn't exist (end of season/series) /// - Previous episode doesn't exist (first episode) - Future loadAdjacentEpisodes({required BuildContext context, required PlexMetadata metadata}) async { + Future loadAdjacentEpisodes({required BuildContext context, required MediaItem metadata}) async { try { + // Resolve providers up-front so we don't reach for `context` after + // any of the awaits below — avoids the + // `use_build_context_synchronously` lint and the genuine "widget + // unmounted mid-load" race it warns about. + final serverManager = context.read().serverManager; final playbackState = context.read(); - // All episode navigation now uses play queues (sequential, shuffle, playlists) - // If no queue is active, navigation is not available + // For Jellyfin, build (or refresh) the centered 21-item window and + // publish it into PlaybackStateProvider so the rest of this method — + // and the queue button/sheet — can read prev/next from the same + // place Plex does. Plex playback comes in here with its server-side + // queue already populated by `_ensurePlayQueue` so this branch is + // a no-op (Plex's `fetchClientSideEpisodeQueue` returns null). + await _ensureLocalEpisodeQueue(serverManager, playbackState, metadata); + + // Both backends now read prev/next off PlaybackStateProvider. if (!playbackState.isQueueActive) { return AdjacentEpisodes(); } - - // Use the play queue for next/previous navigation - final next = await playbackState.getNextEpisode(metadata.ratingKey, loopQueue: false); - final previous = await playbackState.getPreviousEpisode(metadata.ratingKey); - + final next = await playbackState.getNextEpisode(metadata.id, loopQueue: false); + final previous = await playbackState.getPreviousEpisode(metadata.id); final mode = playbackState.isShuffleActive ? 'Shuffle' : 'Sequential'; appLogger.d('$mode mode - Next: ${next?.title}, Previous: ${previous?.title}'); - return AdjacentEpisodes(next: next, previous: previous); } catch (e) { // Non-critical: Failed to load next/previous episode metadata @@ -60,13 +95,63 @@ class EpisodeNavigationService { } } + /// Ensure [PlaybackStateProvider] holds a centered 21-item window of + /// the current series. Cached per-series, so jumping anywhere in the + /// show only triggers one wire fetch per session. No-op for movies, + /// items without a series anchor, or backends whose + /// [MediaServerClient.fetchClientSideEpisodeQueue] returns null (Plex's + /// queue lives server-side and is populated elsewhere). + Future _ensureLocalEpisodeQueue( + MultiServerManager serverManager, + PlaybackStateProvider playbackState, + MediaItem metadata, + ) async { + if (metadata.serverId == null || !metadata.isEpisode || metadata.grandparentId == null) { + return; + } + final seriesId = metadata.grandparentId!; + // Don't replace a playlist/collection queue with a series queue. + // The launcher (e.g. [JellyfinSequentialLauncher]) sets contextKey to + // the playlist/collection id; a series rebuild here would clobber it + // and prev/next would walk the show instead of the user's list. + final activeKey = playbackState.shuffleContextKey; + if (playbackState.isQueueActive && activeKey != null && activeKey != seriesId) { + return; + } + var allEpisodes = _readSeriesCache(seriesId); + if (allEpisodes == null) { + final client = serverManager.getClient(metadata.serverId!); + if (client == null) return; + try { + allEpisodes = await client.fetchClientSideEpisodeQueue(seriesId); + } catch (e, st) { + appLogger.w('Failed series-episodes fetch for queue', error: e, stackTrace: st); + return; + } + if (allEpisodes == null) return; // backend uses a server-side queue (Plex) + if (allEpisodes.isEmpty) return; // empty series + _writeSeriesCache(seriesId, allEpisodes); + } + final anchorIdx = allEpisodes.indexWhere((m) => m.id == metadata.id); + if (anchorIdx < 0) return; + + final queue = LocalPlayQueue( + id: '${metadata.backend.id}:$seriesId', + items: allEpisodes, + currentIndex: anchorIdx, + backendId: metadata.backend.id, + ); + playbackState.setPlaybackFromLocalQueue(queue, contextKey: seriesId); + appLogger.d('Local episode queue (${allEpisodes.length} episodes, anchor: $anchorIdx)'); + } + /// Navigate to the next or previous episode /// /// Preserves the current audio track, subtitle track, and playback rate /// selections when transitioning between episodes. Future navigateToEpisode({ required BuildContext context, - required PlexMetadata episode, + required MediaItem episode, required Player? player, bool usePushReplacement = true, }) async { @@ -103,4 +188,23 @@ class EpisodeNavigationService { ); } } + + /// LRU-touching read: re-inserts the entry so it becomes the most recent. + /// Returns null on miss. + List? _readSeriesCache(String seriesId) { + final value = _seriesEpisodeCache.remove(seriesId); + if (value != null) { + _seriesEpisodeCache[seriesId] = value; + } + return value; + } + + /// LRU-bounded write: evicts the oldest entry when capacity is exceeded. + void _writeSeriesCache(String seriesId, List episodes) { + _seriesEpisodeCache.remove(seriesId); + _seriesEpisodeCache[seriesId] = episodes; + while (_seriesEpisodeCache.length > _seriesCacheCapacity) { + _seriesEpisodeCache.remove(_seriesEpisodeCache.keys.first); + } + } } diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart index 356f5bc5..32567c53 100644 --- a/lib/services/external_player_service.dart +++ b/lib/services/external_player_service.dart @@ -3,23 +3,26 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; import '../models/external_player_models.dart'; -import '../models/plex_metadata.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import '../i18n/strings.g.dart'; -import 'plex_client.dart'; import 'settings_service.dart'; const _externalPlayerChannel = MethodChannel('com.plezy/external_player'); class ExternalPlayerService { - /// Launch an external player with either a pre-resolved [videoUrl] (e.g. local - /// file path for downloaded content) or by fetching the streaming URL from [client]. + /// Launch an external player with either a pre-resolved [videoUrl] (e.g. + /// a local file path for downloaded content) or by asking [client] to + /// resolve the streaming URL for [metadata]. Each backend implements + /// `resolveExternalPlaybackUrl` for the right shape (Plex part URL, + /// Jellyfin `/Videos/{id}/stream?Static=true`). static Future launch({ required BuildContext context, - PlexMetadata? metadata, - PlexClient? client, + MediaItem? metadata, + MediaServerClient? client, int mediaIndex = 0, String? videoUrl, }) async { @@ -29,15 +32,14 @@ class ExternalPlayerService { if (videoUrl != null) { resolvedUrl = videoUrl; } else if (client != null && metadata != null) { - final playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: mediaIndex); - - if (!playbackData.hasValidVideoUrl) { + final url = await client.resolveExternalPlaybackUrl(metadata, mediaIndex: mediaIndex); + if (url == null || url.isEmpty) { if (context.mounted) { showErrorSnackBar(context, t.messages.fileInfoNotAvailable); } return false; } - resolvedUrl = playbackData.videoUrl!; + resolvedUrl = url; } else { appLogger.e('ExternalPlayerService.launch requires either videoUrl or client+metadata'); return false; diff --git a/lib/services/favorite_channels_repository.dart b/lib/services/favorite_channels_repository.dart new file mode 100644 index 00000000..e6848dc2 --- /dev/null +++ b/lib/services/favorite_channels_repository.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/livetv_channel.dart'; + +/// Persistence boundary for the per-connection favorite-channel list shown +/// in the Live TV picker. Pulled out of `_JellyfinLiveTvSupport` so the +/// favorites round-trip can be exercised with an in-memory fake instead of +/// the platform `SharedPreferences` plugin. +/// +/// The repo owns the *serialisation*; the call site owns the *key naming* +/// (which depends on connection id + machineId — backend-specific concerns +/// the repo shouldn't have to know about). +abstract class FavoriteChannelsRepository { + /// Reads channels for [key]. If absent, falls back to [legacyKey] one + /// time, migrating the value into [key] and clearing the legacy slot. + Future> read({required String key, required String legacyKey}); + + /// Replaces the channel list under [key]. + Future write(String key, List channels); +} + +/// Production implementation. Holds no state; the platform plugin has its +/// own caching layer behind `SharedPreferences.getInstance()`. +class SharedPreferencesFavoriteChannelsRepository implements FavoriteChannelsRepository { + const SharedPreferencesFavoriteChannelsRepository(); + + @override + Future> read({required String key, required String legacyKey}) async { + final prefs = await SharedPreferences.getInstance(); + var raw = prefs.getString(key); + if (raw == null) { + // Migrate from the legacy bare-machineId slot. Only the first user to + // read inherits it; the rest start empty (favorites were always + // user-scoped semantically — the legacy key just couldn't express it). + final legacy = prefs.getString(legacyKey); + if (legacy != null) { + await prefs.setString(key, legacy); + await prefs.remove(legacyKey); + raw = legacy; + } + } + if (raw == null || raw.isEmpty) return const []; + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + return decoded.whereType>().map(FavoriteChannel.fromJson).toList(); + } + + @override + Future write(String key, List channels) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(key, jsonEncode(channels.map((c) => c.toJson()).toList())); + } +} diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart new file mode 100644 index 00000000..6b8e2998 --- /dev/null +++ b/lib/services/file_info_parser.dart @@ -0,0 +1,210 @@ +import '../media/media_source_info.dart'; +import '../utils/json_utils.dart'; +import 'plex_constants.dart'; + +/// Backend-agnostic stream-array walker. Plex and Jellyfin both express the +/// per-source stream list (video/audio/subtitle entries) as `List` +/// under different field names; the per-backend [FileInfoStreamReader] +/// implementations encapsulate the naming differences so the call sites in +/// each client can hand a streams array straight to [walkStreams] and read +/// the four-tuple result. +enum FileInfoStreamType { video, audio, subtitle } + +/// Single-pass result of walking a streams array. Keeps both the raw +/// `videoStream` / `audioStream` map pointers (for callers that need to dig +/// out keys the parsed track classes don't carry — e.g. `colorSpace`, +/// `BitDepth`, `BitRate`) and the parsed neutral track lists. +class FileInfoStreams { + final Map? videoStream; + final Map? audioStream; + final List audioTracks; + final List subtitleTracks; + final double? frameRate; + + const FileInfoStreams({ + required this.videoStream, + required this.audioStream, + required this.audioTracks, + required this.subtitleTracks, + required this.frameRate, + }); + + static const empty = FileInfoStreams( + videoStream: null, + audioStream: null, + audioTracks: [], + subtitleTracks: [], + frameRate: null, + ); +} + +abstract class FileInfoStreamReader { + /// Classify a raw stream entry — return null to skip (unknown / irrelevant). + FileInfoStreamType? typeOf(Map stream); + + /// Build a neutral [MediaAudioTrack] from a backend-specific audio entry. + /// [autoIndex] is the 1-based ordinal of this track among audio entries + /// in the streams array; backends that lack a stable per-stream `id` can + /// fall back to it. + MediaAudioTrack toAudioTrack(Map stream, int autoIndex); + + /// Build a neutral [MediaSubtitleTrack] from a backend-specific subtitle + /// entry. See [autoIndex] note on [toAudioTrack]. + MediaSubtitleTrack toSubtitleTrack(Map stream, int autoIndex); + + /// Pull the playback frame rate out of the video stream entry. Used by + /// callers that build a [MediaSourceInfo] for the player so the renderer + /// can pick the right refresh-rate match on capable displays. + double? frameRateOf(Map videoStream); +} + +/// Walk [streams] in a single pass. Captures the first video / audio entries +/// (later ones are ignored — both backends serve a single primary track per +/// type), accumulates *all* audio / subtitle tracks for selection UIs, and +/// extracts the frame rate from the video entry. +FileInfoStreams walkStreams(List? streams, FileInfoStreamReader reader) { + if (streams == null || streams.isEmpty) return FileInfoStreams.empty; + final audioTracks = []; + final subtitleTracks = []; + Map? videoStream; + Map? audioStream; + double? frameRate; + var audioIndex = 0; + var subtitleIndex = 0; + for (final raw in streams) { + if (raw is! Map) continue; + final type = reader.typeOf(raw); + if (type == null) continue; + switch (type) { + case FileInfoStreamType.video: + videoStream ??= raw; + frameRate ??= reader.frameRateOf(raw); + case FileInfoStreamType.audio: + audioStream ??= raw; + audioIndex++; + audioTracks.add(reader.toAudioTrack(raw, audioIndex)); + case FileInfoStreamType.subtitle: + subtitleIndex++; + subtitleTracks.add(reader.toSubtitleTrack(raw, subtitleIndex)); + } + } + return FileInfoStreams( + videoStream: videoStream, + audioStream: audioStream, + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + frameRate: frameRate, + ); +} + +/// Reader for Plex's `Part.Stream[]` entries. Field naming follows Plex's +/// camelCase: `streamType` (1=video, 2=audio, 3=subtitle), numeric `id`, +/// `language`/`languageCode`, `selected`/`forced` arrive as bool-ish strings +/// or 0/1 ints (handled by [flexibleBool]). +class PlexFileInfoStreamReader implements FileInfoStreamReader { + const PlexFileInfoStreamReader(); + + @override + FileInfoStreamType? typeOf(Map stream) { + final t = stream['streamType']; + if (t is! int) return null; + return switch (t) { + PlexStreamType.video => FileInfoStreamType.video, + PlexStreamType.audio => FileInfoStreamType.audio, + PlexStreamType.subtitle => FileInfoStreamType.subtitle, + _ => null, + }; + } + + @override + MediaAudioTrack toAudioTrack(Map stream, int _) { + return MediaAudioTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + channels: stream['channels'] as int?, + selected: flexibleBool(stream['selected']), + ); + } + + @override + MediaSubtitleTrack toSubtitleTrack(Map stream, int _) { + return MediaSubtitleTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + selected: flexibleBool(stream['selected']), + forced: flexibleBool(stream['forced']), + key: stream['key'] as String?, + ); + } + + @override + double? frameRateOf(Map videoStream) { + return (videoStream['frameRate'] as num?)?.toDouble(); + } +} + +/// Reader for Jellyfin's `MediaSources[].MediaStreams[]` entries. Field +/// naming is PascalCase: `Type` ('Video'/'Audio'/'Subtitle'), `Index` per +/// stream-type ordinal, `IsDefault`/`IsForced` as proper booleans. The +/// per-stream `Index` can theoretically be null on misconfigured items, so +/// the reader falls back to the walker's `autoIndex` for stable IDs. +class JellyfinFileInfoStreamReader implements FileInfoStreamReader { + const JellyfinFileInfoStreamReader(); + + @override + FileInfoStreamType? typeOf(Map stream) { + final type = (stream['Type'] as String?)?.toLowerCase(); + return switch (type) { + 'video' => FileInfoStreamType.video, + 'audio' => FileInfoStreamType.audio, + 'subtitle' => FileInfoStreamType.subtitle, + _ => null, + }; + } + + @override + MediaAudioTrack toAudioTrack(Map s, int autoIndex) { + return MediaAudioTrack( + id: (s['Index'] as int?) ?? autoIndex, + index: s['Index'] as int?, + codec: s['Codec'] as String?, + language: s['Language'] as String?, + languageCode: s['Language'] as String?, + title: s['Title'] as String?, + displayTitle: s['DisplayTitle'] as String?, + channels: s['Channels'] as int?, + selected: s['IsDefault'] == true, + ); + } + + @override + MediaSubtitleTrack toSubtitleTrack(Map s, int autoIndex) { + return MediaSubtitleTrack( + id: (s['Index'] as int?) ?? autoIndex, + index: s['Index'] as int?, + codec: s['Codec'] as String?, + language: s['Language'] as String?, + languageCode: s['Language'] as String?, + title: s['Title'] as String?, + displayTitle: s['DisplayTitle'] as String?, + selected: s['IsDefault'] == true, + forced: s['IsForced'] == true, + key: null, + ); + } + + @override + double? frameRateOf(Map videoStream) { + return (videoStream['RealFrameRate'] as num?)?.toDouble() ?? (videoStream['AverageFrameRate'] as num?)?.toDouble(); + } +} diff --git a/lib/services/image_cache_service.dart b/lib/services/image_cache_service.dart index 9e91f59e..ad79f81e 100644 --- a/lib/services/image_cache_service.dart +++ b/lib/services/image_cache_service.dart @@ -4,9 +4,11 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:http/http.dart' as http; import '../utils/platform_detector.dart'; -import '../utils/plex_http_client.dart'; +import '../utils/media_server_http_client.dart'; -/// Custom cache manager for Plex image transcoding with HTTP/2 multiplexing. +/// Custom cache manager for media-server image transcoding with HTTP/2 +/// multiplexing. Used for both Plex and Jellyfin artwork (the class name +/// predates Jellyfin support — it's backend-neutral). /// /// Uses the platform-native HTTP client so iOS/macOS (CupertinoClient) and /// Android (CronetClient) benefit from HTTP/2 connection multiplexing — diff --git a/lib/services/jellyfin_api_cache.dart b/lib/services/jellyfin_api_cache.dart new file mode 100644 index 00000000..9dfb8103 --- /dev/null +++ b/lib/services/jellyfin_api_cache.dart @@ -0,0 +1,256 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +import '../database/app_database.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../utils/global_key_utils.dart'; +import '../utils/isolate_helper.dart'; +import 'api_cache.dart'; +import 'credential_vault.dart'; +import 'jellyfin_mappers.dart'; + +/// Jellyfin-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 +/// `/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 JellyfinApiCache? _instance; + static JellyfinApiCache get instance { + if (_instance == null) { + throw StateError('JellyfinApiCache not initialized. Call JellyfinApiCache.initialize() first.'); + } + return _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. + static void initialize(AppDatabase db) { + _instance = JellyfinApiCache._(db); + ApiCache.registerInstance(MediaBackend.jellyfin, _instance!); + } + + static final RegExp _itemKeyPattern = RegExp(r'/Users/[^/]+/Items/([^/?]+)$'); + + String _itemPattern(String serverId, String itemId) => '$serverId:/Users/%/Items/$itemId'; + + /// Delete cached item metadata for [itemId] (single-item endpoint only; + /// children-list endpoints are out of scope for v1 — they'll get cleaned + /// up via [deleteForServer] or [clearAll]). + @override + Future deleteForItem(String serverId, String itemId) async { + await (database.delete(database.apiCache)..where((t) => t.cacheKey.like(_itemPattern(serverId, itemId)))).go(); + } + + /// Pin the metadata row(s) for [itemId] so they survive cache eviction. + @override + Future pinForOffline(String serverId, String itemId) => pinByKeyPattern(_itemPattern(serverId, itemId)); + + /// Unpin a previously pinned item. + Future unpinForOffline(String serverId, String itemId) => unpinByKeyPattern(_itemPattern(serverId, itemId)); + + /// Whether the metadata for [itemId] is pinned for offline. + /// + /// Named `isPinnedItemId` to avoid colliding with the inherited + /// [ApiCache.isPinned]'s identical Dart signature. + Future isPinnedItemId(String serverId, String itemId) => hasPinnedMatching(_itemPattern(serverId, itemId)); + + /// Get all pinned Jellyfin item ids for a server. + Future> getPinnedItemIds(String serverId) => extractPinnedIds(serverId, _itemKeyPattern); + + /// Fetch and parse a [MediaItem] from cache. + /// + /// Returns `null` when no matching row is cached, the row's JSON is + /// unparseable, or the [Connections] row for [serverId] is missing/has no + /// usable `baseUrl`. + /// + /// Image paths are run through [JellyfinImageAbsolutizer] so cached items + /// carry the same absolute URLs as items produced by [JellyfinClient]'s + /// live mapper boundary — without this, downstream consumers (artwork + /// downloads, offline image rendering) see raw `/Items/...` paths and + /// fail. + /// + /// Single-item path stays on the main isolate — decoding one BaseItemDto + /// is cheap and matches [PlexApiCache.getMetadata]'s shape. Bulk-load + /// callers go through [getAllPinnedMetadata] which still parallelises. + @override + Future getMetadata(String serverId, String itemId) async { + final row = await (database.select( + database.apiCache, + )..where((t) => t.cacheKey.like(_itemPattern(serverId, itemId)))).get(); + if (row.isEmpty) return null; + + final ctx = await _serverContext(serverId); + if (ctx == null) return null; + + try { + final data = jsonDecode(row.first.data) as Map; + final absolutizer = JellyfinImageAbsolutizer(baseUrl: ctx.baseUrl, accessToken: ctx.accessToken); + return JellyfinMappers.mediaItem(data, serverId: ctx.machineId, serverName: ctx.name, absolutizer: absolutizer); + } catch (_) { + return null; + } + } + + /// Persist a watched/unwatched flip into every cached `BaseItemDto` row + /// for [itemId] (one per cached userId). Mirrors what the server returns + /// after the flip so a later cache reload reflects the current watched + /// state without a network roundtrip. + /// + /// [viewOffsetMs] is converted to Jellyfin's 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. + @override + Future applyWatchState({ + required String serverId, + required String itemId, + required bool isWatched, + int? viewOffsetMs, + int? lastViewedAt, + int? viewedLeafCount, + }) async { + final query = database.select(database.apiCache)..where((t) => t.cacheKey.like(_itemPattern(serverId, itemId))); + final rows = await query.get(); + if (rows.isEmpty) return; + for (final row in rows) { + try { + final data = jsonDecode(row.data) as Map; + final userData = (data['UserData'] is Map) + ? (data['UserData'] as Map) + : {}; + userData['Played'] = isWatched; + final positionTicks = viewOffsetMs != null ? viewOffsetMs * 10000 : 0; + if (isWatched) { + final current = (userData['PlayCount'] as num?)?.toInt() ?? 0; + userData['PlayCount'] = current < 1 ? 1 : current; + userData['PlaybackPositionTicks'] = positionTicks; + userData['LastPlayedDate'] = lastViewedAt != null + ? DateTime.fromMillisecondsSinceEpoch(lastViewedAt * 1000, isUtc: true).toIso8601String() + : DateTime.now().toUtc().toIso8601String(); + } else { + userData['PlayCount'] = 0; + userData['PlaybackPositionTicks'] = positionTicks; + if (lastViewedAt != null) { + userData['LastPlayedDate'] = DateTime.fromMillisecondsSinceEpoch( + lastViewedAt * 1000, + isUtc: true, + ).toIso8601String(); + } + } + data['UserData'] = userData; + final encoded = jsonEncode(data); + await (database.update(database.apiCache)..where((t) => t.cacheKey.equals(row.cacheKey))).write( + ApiCacheCompanion(data: Value(encoded), cachedAt: Value(DateTime.now())), + ); + } catch (_) { + // Skip malformed entries. + } + } + } + + /// Load all pinned Jellyfin metadata in a single query. + /// + /// Returns a map keyed by `buildGlobalKey(serverId, itemId)` for O(1) + /// lookups, mirroring [PlexApiCache.getAllPinnedMetadata] so callers can + /// spread-merge the two results. + @override + Future> getAllPinnedMetadata() async { + final entries = await listPinnedRowsByPattern(_itemKeyPattern); + if (entries.isEmpty) return {}; + + // 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 = {}; + final absolutizers = {}; + for (final id in entries.map((e) => e.serverId).toSet()) { + final ctx = await _serverContext(id); + if (ctx != null) { + contexts[id] = ctx; + absolutizers[id] = JellyfinImageAbsolutizer(baseUrl: ctx.baseUrl, accessToken: ctx.accessToken); + } + } + + return await tryIsolateRun(() { + final result = {}; + for (final entry in entries) { + final ctx = contexts[entry.serverId]; + final absolutizer = absolutizers[entry.serverId]; + if (ctx == null || absolutizer == null) continue; + try { + final data = jsonDecode(entry.data) as Map; + final mapped = JellyfinMappers.mediaItem( + data, + serverId: ctx.machineId, + serverName: ctx.name, + absolutizer: absolutizer, + ); + if (mapped != null) { + result[buildGlobalKey(entry.serverId, entry.id)] = mapped; + } + } catch (_) { + // Skip malformed entries + } + } + return result; + }); + } + + /// Resolve the connection context (server name + base URL + 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. + /// + /// 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(String serverId) async { + // Match either the bare machineId (Plex) or the compound + // `{machineId}/{userId}` (Jellyfin). The compound match uses a + // [substr]-based prefix check so any `_` / `%` in the runtime + // [serverId] is treated literally — `LIKE '$serverId/%'` would + // interpret those chars as wildcards. + final prefix = '$serverId/'; + final row = + await (database.select(database.connections) + ..where((t) => t.id.equals(serverId) | t.id.substr(1, prefix.length).equals(prefix)) + ..limit(1)) + .getSingleOrNull(); + if (row == null) return null; + String? configName; + String? machineId; + String baseUrl = ''; + String accessToken = ''; + try { + final rawConfig = jsonDecode(row.configJson) as Map; + final config = (await CredentialVault.revealConnectionConfig(row.kind, rawConfig)).config; + configName = config['serverName'] as String?; + machineId = config['serverMachineId'] as String?; + baseUrl = config['baseUrl'] as String? ?? ''; + accessToken = config['accessToken'] as String? ?? ''; + } catch (_) { + // Fall through with the values defaulted above. + } + if (baseUrl.isEmpty) return null; + machineId ??= row.id.contains('/') ? row.id.substring(0, row.id.indexOf('/')) : row.id; + final name = (configName != null && configName.isNotEmpty) ? configName : row.displayName; + return (machineId: machineId, name: name, baseUrl: baseUrl, accessToken: accessToken); + } +} diff --git a/lib/services/jellyfin_auth_header.dart b/lib/services/jellyfin_auth_header.dart new file mode 100644 index 00000000..74b4f3b0 --- /dev/null +++ b/lib/services/jellyfin_auth_header.dart @@ -0,0 +1,19 @@ +/// Build the `MediaBrowser` Authorization header value the way the Jellyfin +/// SDK formats it. Used at auth time and on every authenticated request so +/// the server sees a consistent client identity. +String buildJellyfinAuthHeader({ + required String clientName, + required String clientVersion, + required String deviceName, + required String deviceId, + String? accessToken, +}) { + final parts = [ + 'Client="$clientName"', + 'Device="$deviceName"', + 'DeviceId="$deviceId"', + 'Version="$clientVersion"', + if (accessToken != null && accessToken.isNotEmpty) 'Token="$accessToken"', + ]; + return 'MediaBrowser ${parts.join(', ')}'; +} diff --git a/lib/services/jellyfin_auth_service.dart b/lib/services/jellyfin_auth_service.dart new file mode 100644 index 00000000..ec5aeb87 --- /dev/null +++ b/lib/services/jellyfin_auth_service.dart @@ -0,0 +1,462 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:http/http.dart' as http; + +import '../connection/connection.dart'; +import '../connection/connection_auth_service.dart'; +import '../exceptions/media_server_exceptions.dart'; +import '../utils/app_logger.dart'; +import '../utils/media_server_http_client.dart'; +import '../utils/media_server_timeouts.dart'; +import '../utils/log_redaction_manager.dart'; +import '../utils/poll_with_backoff.dart'; +import '../utils/url_utils.dart'; +import 'jellyfin_auth_header.dart'; + +/// Result of a successful Jellyfin URL probe (`/System/Info/Public`). +class JellyfinServerInfo { + final String serverName; + + /// Server's `Id` field — Jellyfin's machine identifier (UUID hex). + final String machineId; + + /// Server's reported version string. + final String version; + + const JellyfinServerInfo({required this.serverName, required this.machineId, required this.version}); +} + +/// 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 +/// the opaque polling/exchange handle. +class JellyfinQuickConnectInitiation { + final String code; + final String secret; + const JellyfinQuickConnectInitiation({required this.code, required this.secret}); +} + +/// Auth flow for adding or refreshing a [JellyfinConnection]. +/// +/// Lifecycle for adding a server: +/// 1. [probe] — validates the URL responds as a Jellyfin 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]. +/// 3. (later) [validate] / [refresh] / [signOut] for the [ConnectionAuthService] +/// contract. +class JellyfinConnectionAuthService implements ConnectionAuthService { + JellyfinConnectionAuthService({ + required this.clientName, + required this.clientVersion, + required this.deviceName, + @visibleForTesting http.Client Function()? testHttpClientFactory, + }) : _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. + final String clientName; + final String clientVersion; + final String deviceName; + + /// Test-only HTTP client factory. When non-null, every internal + /// [MediaServerHttpClient] is built with a fresh client from this factory + /// instead of the platform default — lets unit tests intercept requests + /// via `package:http/testing`'s [http.MockClient]. Returns a factory rather + /// than a single instance because each [MediaServerHttpClient] closes its + /// underlying client on `close()`. + final http.Client Function()? _testHttpClientFactory; + + MediaServerHttpClient _buildHttpClient({required String baseUrl, Map headers = const {}}) { + LogRedactionManager.registerServerUrl(baseUrl); + return MediaServerHttpClient(baseUrl: baseUrl, defaultHeaders: headers, client: _testHttpClientFactory?.call()); + } + + /// 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. + Future probe(String baseUrl) async { + final normalised = _normaliseBaseUrl(baseUrl); + final client = _buildHttpClient(baseUrl: normalised); + try { + final response = await client.get('/System/Info/Public').timeout(MediaServerTimeouts.jellyfinProbe); + throwIfHttpError(response); + final data = response.data; + if (data is! Map) { + throw MediaServerUrlException('Server response was not JSON'); + } + final id = data['Id']; + final name = data['ServerName'] ?? data['LocalAddress']; + if (id is! String || name is! String) { + throw MediaServerUrlException('Server response missing Id/ServerName — not a Jellyfin server?'); + } + return JellyfinServerInfo(serverName: name, machineId: id, version: data['Version'] as String? ?? ''); + } on MediaServerUrlException { + // Already the right shape — propagate without re-wrapping. + rethrow; + } on MediaServerHttpException catch (e) { + throw MediaServerUrlException('Server probe failed: ${e.message}'); + } on TimeoutException { + // The `.timeout(...)` above throws raw [TimeoutException]; wrap so + // callers can `catch (MediaServerUrlException)` uniformly. + throw MediaServerUrlException('Server did not respond in time'); + } catch (e) { + // Catch-all for transport errors that bypass the http client wrap + // (DNS failures, TLS handshake errors, etc.). + throw MediaServerUrlException('Server probe failed: $e'); + } finally { + client.close(); + } + } + + /// Authenticate against [baseUrl] with [username]/[password] and return a + /// fully-formed [JellyfinConnection]. Throws [MediaServerAuthException] for + /// 401/403 responses; other transport errors propagate. + Future authenticateByName({ + required String baseUrl, + required String username, + required String password, + required String deviceId, + JellyfinServerInfo? serverInfo, + }) async { + final normalised = _normaliseBaseUrl(baseUrl); + final info = serverInfo ?? await probe(normalised); + + final authHeader = buildJellyfinAuthHeader( + clientName: clientName, + clientVersion: clientVersion, + deviceName: deviceName, + deviceId: deviceId, + ); + final client = _buildHttpClient( + baseUrl: normalised, + headers: {'Authorization': authHeader, 'Content-Type': 'application/json'}, + ); + try { + final response = await client + .post('/Users/AuthenticateByName', body: jsonEncode({'Username': username, 'Pw': password})) + // Bound the auth POST so a hanging server can't freeze the auth + // screen indefinitely; mirrors the timeout on [probe]. + .timeout(MediaServerTimeouts.jellyfinProbe); + if (response.statusCode == 401 || response.statusCode == 403) { + throw MediaServerAuthException('Invalid username or password', statusCode: response.statusCode); + } + throwIfHttpError(response); + final data = response.data; + if (data is! Map) { + throw MediaServerAuthException('Authentication response was not JSON'); + } + final accessToken = data['AccessToken'] as String?; + final user = data['User'] as Map?; + if (accessToken == null || user == null) { + throw MediaServerAuthException('Authentication response missing AccessToken or User'); + } + final userId = user['Id'] as String?; + final userName = user['Name'] as String?; + if (userId == null || userName == null) { + throw MediaServerAuthException('Authentication response missing User.Id or User.Name'); + } + final policy = user['Policy'] as Map?; + final isAdmin = policy?['IsAdministrator'] as bool? ?? false; + + return _buildConnection( + info: info, + normalisedBaseUrl: normalised, + userId: userId, + userName: userName, + accessToken: accessToken, + deviceId: deviceId, + isAdministrator: isAdmin, + ); + } on TimeoutException { + // The auth POST's `.timeout(...)` throws raw [TimeoutException]; surface + // as a URL-level error so the auth screen shows a normal "couldn't + // reach server" message instead of a stack trace. + throw MediaServerUrlException('Server did not respond in time'); + } on MediaServerHttpException catch (e) { + if (e.statusCode == 401 || e.statusCode == 403) { + throw MediaServerAuthException('Invalid username or password', statusCode: e.statusCode); + } + rethrow; + } finally { + client.close(); + } + } + + /// 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. + Future isQuickConnectEnabled(String baseUrl) async { + final normalised = _normaliseBaseUrl(baseUrl); + final client = _buildHttpClient(baseUrl: normalised); + try { + final response = await client.get('/QuickConnect/Enabled').timeout(MediaServerTimeouts.jellyfinProbe); + if (response.statusCode != 200) return false; + final data = response.data; + // The endpoint returns a bare JSON `true`/`false`, not an object. + return data is bool ? data : false; + } catch (_) { + return false; + } finally { + client.close(); + } + } + + /// Initiate a Quick Connect session: returns the user-facing code and the + /// polling secret. The Authorization header carries the device identity + /// only — there's no token until the secret is exchanged after approval. + Future initiateQuickConnect({ + required String baseUrl, + required String deviceId, + }) async { + final normalised = _normaliseBaseUrl(baseUrl); + final authHeader = buildJellyfinAuthHeader( + clientName: clientName, + clientVersion: clientVersion, + deviceName: deviceName, + deviceId: deviceId, + ); + final client = _buildHttpClient(baseUrl: normalised, headers: {'Authorization': authHeader}); + try { + // Current Jellyfin (10.7+) accepts GET; older builds required POST. + // Try GET first, fall back on 405. + var response = await client.get('/QuickConnect/Initiate').timeout(MediaServerTimeouts.jellyfinProbe); + if (response.statusCode == 405) { + response = await client.post('/QuickConnect/Initiate').timeout(MediaServerTimeouts.jellyfinProbe); + } + if (response.statusCode == 401 || response.statusCode == 403) { + throw MediaServerAuthException('Quick Connect rejected by server', statusCode: response.statusCode); + } + throwIfHttpError(response); + final data = response.data; + if (data is! Map) { + throw MediaServerAuthException('Quick Connect response was not JSON'); + } + final code = data['Code'] as String?; + final secret = data['Secret'] as String?; + if (code == null || secret == null) { + throw MediaServerAuthException('Quick Connect response missing Code or Secret'); + } + return JellyfinQuickConnectInitiation(code: code, secret: secret); + } on MediaServerHttpException catch (e) { + if (e.statusCode == 401 || e.statusCode == 403) { + throw MediaServerAuthException('Quick Connect rejected by server', statusCode: e.statusCode); + } + rethrow; + } finally { + client.close(); + } + } + + /// Poll `/QuickConnect/Connect?secret=…` until the user approves the code + /// 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). + Future authenticateByQuickConnect({ + required String baseUrl, + required String secret, + required String deviceId, + JellyfinServerInfo? serverInfo, + Duration timeout = const Duration(minutes: 5), + bool Function()? shouldCancel, + }) async { + final normalised = _normaliseBaseUrl(baseUrl); + final info = serverInfo ?? await probe(normalised); + LogRedactionManager.registerCustomValue(secret); + + final authHeader = buildJellyfinAuthHeader( + clientName: clientName, + clientVersion: clientVersion, + deviceName: deviceName, + deviceId: deviceId, + ); + // Reuse a single client across the polling loop — opening one per tick + // would churn TCP connections needlessly on a 5-minute window. + final pollClient = _buildHttpClient(baseUrl: normalised, headers: {'Authorization': authHeader}); + bool? approved; + try { + approved = await pollWithBackoff( + endTime: DateTime.now().add(timeout), + shouldCancel: shouldCancel, + probe: () async { + try { + final response = await pollClient.get( + '/QuickConnect/Connect', + queryParameters: {'secret': secret}, + timeout: MediaServerTimeouts.jellyfinProbe, + ); + // 404 mid-poll = secret expired or revoked server-side. Terminal. + if (response.statusCode == 404) throw const PollTerminatedSignal(); + if (response.statusCode == 401 || response.statusCode == 403) { + throw MediaServerAuthException('Quick Connect poll rejected by server', statusCode: response.statusCode); + } + throwIfHttpError(response); + final data = response.data; + if (data is Map && data['Authenticated'] == true) { + return true; + } + return null; + } on MediaServerHttpException catch (e) { + if (e.statusCode == 404) throw const PollTerminatedSignal(); + if (e.statusCode == 401 || e.statusCode == 403) { + throw MediaServerAuthException('Quick Connect poll rejected by server', statusCode: e.statusCode); + } + // Transient network blip — let the backoff handle it. The outer + // timeout is the safety net if the server is durably broken. + return null; + } + }, + ); + } finally { + pollClient.close(); + } + + if (approved != true) return null; + + // Exchange the approved secret for an access token. Response shape + // matches /Users/AuthenticateByName. + final exchangeClient = _buildHttpClient( + baseUrl: normalised, + headers: {'Authorization': authHeader, 'Content-Type': 'application/json'}, + ); + try { + final response = await exchangeClient.post( + '/Users/AuthenticateWithQuickConnect', + body: jsonEncode({'Secret': secret}), + ); + if (response.statusCode == 400) { + throw MediaServerAuthException('Quick Connect exchange rejected by server', statusCode: response.statusCode); + } + if (response.statusCode == 401 || response.statusCode == 403) { + throw MediaServerAuthException('Quick Connect exchange rejected by server', statusCode: response.statusCode); + } + throwIfHttpError(response); + final data = response.data; + if (data is! Map) { + throw MediaServerAuthException('Quick Connect exchange response was not JSON'); + } + final accessToken = data['AccessToken'] as String?; + final user = data['User'] as Map?; + if (accessToken == null || user == null) { + throw MediaServerAuthException('Quick Connect exchange missing AccessToken or User'); + } + final userId = user['Id'] as String?; + final userName = user['Name'] as String?; + if (userId == null || userName == null) { + throw MediaServerAuthException('Quick Connect exchange missing User.Id or User.Name'); + } + final policy = user['Policy'] as Map?; + final isAdmin = policy?['IsAdministrator'] as bool? ?? false; + + return _buildConnection( + info: info, + normalisedBaseUrl: normalised, + userId: userId, + userName: userName, + accessToken: accessToken, + deviceId: deviceId, + isAdministrator: isAdmin, + ); + } on MediaServerHttpException catch (e) { + if (e.statusCode == 400 || e.statusCode == 401 || e.statusCode == 403) { + throw MediaServerAuthException('Quick Connect exchange rejected by server', statusCode: e.statusCode); + } + rethrow; + } finally { + exchangeClient.close(); + } + } + + @override + Future validate(Connection connection) async { + if (connection is! JellyfinConnection) return false; + final client = _authenticatedClient(connection); + try { + final response = await client.get('/Users/Me').timeout(MediaServerTimeouts.jellyfinProbe); + return response.statusCode == 200; + } on MediaServerHttpException catch (e) { + if (e.statusCode == 401 || e.statusCode == 403) return false; + rethrow; + } finally { + client.close(); + } + } + + @override + Future refresh(Connection connection) async { + if (connection is! JellyfinConnection) return connection; + final ok = await validate(connection); + if (!ok) { + return connection.copyWith(status: ConnectionStatus.authError); + } + return connection.copyWith(status: ConnectionStatus.online, lastAuthenticatedAt: DateTime.now()); + } + + @override + Future signOut(Connection connection) async { + if (connection is! JellyfinConnection) return; + final client = _authenticatedClient(connection); + try { + // Best-effort: server may already have invalidated the session. + await client.post('/Sessions/Logout').timeout(MediaServerTimeouts.jellyfinSignOut); + } catch (e) { + appLogger.d('JellyfinConnectionAuthService: signOut best-effort failed: $e'); + } finally { + client.close(); + } + } + + MediaServerHttpClient _authenticatedClient(JellyfinConnection connection) { + LogRedactionManager.registerToken(connection.accessToken); + return _buildHttpClient( + baseUrl: connection.baseUrl, + headers: { + 'X-Emby-Token': connection.accessToken, + 'Authorization': buildJellyfinAuthHeader( + clientName: clientName, + clientVersion: clientVersion, + deviceName: deviceName, + deviceId: connection.deviceId, + accessToken: connection.accessToken, + ), + }, + ); + } + + /// Strip any trailing slash so subsequent path joins (`/Users/...`) don't + /// produce double slashes. Delegates to the shared [stripTrailingSlash]. + static String _normaliseBaseUrl(String input) => stripTrailingSlash(input); + + /// 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({ + required JellyfinServerInfo info, + required String normalisedBaseUrl, + required String userId, + required String userName, + required String accessToken, + required String deviceId, + required bool isAdministrator, + }) { + final now = DateTime.now(); + return JellyfinConnection( + id: '${info.machineId}/$userId', + baseUrl: normalisedBaseUrl, + serverName: info.serverName, + serverMachineId: info.machineId, + userId: userId, + userName: userName, + accessToken: accessToken, + deviceId: deviceId, + isAdministrator: isAdministrator, + status: ConnectionStatus.online, + createdAt: now, + lastAuthenticatedAt: now, + ); + } +} diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart new file mode 100644 index 00000000..659fef21 --- /dev/null +++ b/lib/services/jellyfin_client.dart @@ -0,0 +1,2255 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../connection/connection.dart'; +import '../media/library_filter_result.dart'; +import '../media/library_first_character.dart'; +import '../media/library_query.dart'; +import 'favorite_channels_repository.dart'; +import 'file_info_parser.dart'; +import 'library_query_translator.dart'; +import '../media/media_filter.dart'; +import '../media/live_tv_support.dart'; +import '../media/media_backend.dart'; +import '../media/media_file_info.dart'; +import '../media/media_hub.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_library.dart'; +import '../media/media_playlist.dart'; +import '../media/media_server_client.dart'; +import '../media/server_capabilities.dart'; +import '../models/jellyfin/jellyfin_user_profile.dart'; +import '../models/livetv_channel.dart'; +import '../models/livetv_dvr.dart'; +import '../models/livetv_program.dart'; +import '../media/media_source_info.dart'; +import '../media/media_sort.dart'; +import '../utils/app_logger.dart'; +import '../utils/log_redaction_manager.dart'; +import '../utils/external_ids.dart'; +import '../utils/media_server_http_client.dart'; +import '../utils/resolution_label.dart'; +import '../utils/watch_state_notifier.dart'; +import '../exceptions/media_server_exceptions.dart'; +import '../i18n/strings.g.dart'; +import '../utils/jellyfin_time.dart'; +import 'jellyfin_auth_header.dart'; +import '../media/download_resolution.dart'; +import 'api_cache.dart'; +import 'download_artwork_helpers.dart'; +import 'jellyfin_api_cache.dart'; +import 'jellyfin_mappers.dart'; +import 'jellyfin_media_info_mapper.dart'; +import 'jellyfin_playback_bundle.dart'; +import 'jellyfin_trickplay_service.dart'; +import 'playback_initialization_types.dart'; +import 'scrub_preview_source.dart'; +import '../mpv/mpv.dart'; + +/// [MediaServerClient] over a Jellyfin server. +/// +/// 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]. +class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, ScopedMediaServerClient { + JellyfinClient._({ + required JellyfinConnection connection, + required MediaServerHttpClient http, + FavoriteChannelsRepository? favoritesRepository, + }) : _connection = connection, + _http = http, + _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository(); + + /// Build a fully-initialised [JellyfinClient]. The factory probes + /// `/System/Info/Public` to confirm the server is reachable; callers can + /// catch a [MediaServerHttpException] to surface a clean "unavailable" UI. + /// + /// Sends the full `Authorization: MediaBrowser …, Token="…"` header on + /// every request — that's what the official Jellyfin SDK (and Findroid by + /// extension) does. Modern Jellyfin servers behind reverse proxies often + /// 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. + static Future create( + JellyfinConnection connection, { + FavoriteChannelsRepository? favoritesRepository, + }) async { + // Register before any HTTP traffic so the very first probe URL doesn't + // leak the token verbatim. `LogRedactionManager.redact()` also has + // pattern-based fallbacks for `api_key=`, `X-Emby-Token`, and the + // `Authorization: MediaBrowser ... Token="..."` header. + LogRedactionManager.registerServerUrl(connection.baseUrl); + LogRedactionManager.registerToken(connection.accessToken); + String version = '1.0'; + try { + final pkg = await PackageInfo.fromPlatform(); + if (pkg.version.isNotEmpty) version = pkg.version; + } catch (_) { + // Tests / non-platform contexts — keep the fallback version. + } + final authHeader = buildJellyfinAuthHeader( + clientName: 'Plezy', + clientVersion: version, + deviceName: 'Plezy', + deviceId: connection.deviceId, + accessToken: connection.accessToken, + ); + final headers = { + 'Authorization': authHeader, + 'X-Emby-Token': connection.accessToken, + 'Accept': 'application/json', + // Jellyfin's session reporting endpoints (`/Sessions/Playing*`) reject + // any content-type carrying a `; charset=utf-8` suffix with 415 — + // pin to the SDK's exact wire format up-front. + 'Content-Type': 'application/json', + }; + final http = MediaServerHttpClient(baseUrl: connection.baseUrl, defaultHeaders: headers); + final client = JellyfinClient._(connection: connection, http: http, favoritesRepository: favoritesRepository); + return client; + } + + /// Test-only factory that injects an [http.Client] so URL-builder tests + /// can capture the request URI without spinning up a real Jellyfin server. + @visibleForTesting + static JellyfinClient forTesting({ + required JellyfinConnection connection, + required http.Client httpClient, + FavoriteChannelsRepository? favoritesRepository, + }) { + final mediaHttp = MediaServerHttpClient( + baseUrl: connection.baseUrl, + defaultHeaders: {'X-Emby-Token': connection.accessToken, 'Accept': 'application/json'}, + client: httpClient, + ); + return JellyfinClient._(connection: connection, http: mediaHttp, favoritesRepository: favoritesRepository); + } + + /// Mutable so [isHealthy] can refresh `Policy.IsAdministrator` from the + /// `/Users/Me` probe response — admin status changed server-side should + /// propagate without forcing the user to re-auth. + JellyfinConnection _connection; + JellyfinConnection get connection => _connection; + final MediaServerHttpClient _http; + final FavoriteChannelsRepository _favoritesRepository; + bool _offlineMode = false; + + /// Fired when the live `connection` snapshot diverges from the cached one + /// (currently only on admin-status change). [MultiServerManager] uses this + /// to re-broadcast status so admin-gated UI rebuilds. + FutureOr Function(JellyfinConnection connection)? onConnectionUpdated; + + /// Per-collection cache for [fetchCollectionPage]. Jellyfin's API doesn't + /// paginate collection children, so the first call materialises the full + /// list and subsequent paged calls slice from the same in-memory copy. + /// Lifetime is the client's lifetime — collections rarely change in a + /// single session, and a stale-but-bounded list is acceptable. + final Map> _collectionItemsCache = {}; + + /// Read-only view of the headers attached to every outgoing request. + /// Test-only entry point for asserting the SDK-style `MediaBrowser` + /// Authorization shape — Findroid (and the official SDK) sends the same + /// thing. + @visibleForTesting + Map get defaultHeadersForTesting => Map.unmodifiable(_http.defaultHeaders); + + /// Image-path absolutizer scoped to this client's [connection]. Shared with + /// [JellyfinApiCache] (which constructs its own from the connection row's + /// `configJson`) so cache reads carry the same absolute URLs as live API + /// reads — see [JellyfinImageAbsolutizer]. + JellyfinImageAbsolutizer get _absolutizer => + JellyfinImageAbsolutizer(baseUrl: connection.baseUrl, accessToken: connection.accessToken); + + String? _absolutizeImagePath(String? path) => _absolutizer.absolutize(path); + + MediaItem? _mapItem(Map json) => + JellyfinMappers.mediaItem(json, serverId: serverId, serverName: serverName, absolutizer: _absolutizer); + + List _mapItems(Iterable> items) => + items.map(_mapItem).whereType().toList(); + + // ── Identity ───────────────────────────────────────────────────── + + @override + String get serverId => connection.serverMachineId; + + @override + String get scopedServerId => connection.id; + + @override + String? get serverName => connection.serverName; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + ServerCapabilities get capabilities => ServerCapabilities.jellyfin; + + /// Jellyfin doesn't expose a per-server played-threshold pref, so we mirror + /// Plex's default of 90%. + @override + double get watchedThreshold => 0.9; + + // ── Lifecycle ──────────────────────────────────────────────────── + + @override + void close() => _http.close(); + + /// Reachable *and* token-valid. We probe `/Users/Me` (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. + /// + /// Side-effect: when the response body carries a fresh + /// `Policy.IsAdministrator` that differs from the cached one, refresh the + /// connection so admin-gated UI catches the server-side change without + /// requiring re-auth (see [onConnectionUpdated]). + /// + /// 401/403 surfaces as [HealthStatus.authError] so the manager can + /// distinguish a revoked token from a generic transport failure. + @override + Future checkHealth() async { + try { + final response = await _http.get('/Users/Me').timeout(const Duration(seconds: 8)); + final ok = response.statusCode >= 200 && response.statusCode < 300; + if (ok) { + final data = response.data; + if (data is Map) { + final policy = data['Policy']; + if (policy is Map) { + final fresh = policy['IsAdministrator'] as bool?; + if (fresh != null && fresh != _connection.isAdministrator) { + _connection = _connection.copyWith(isAdministrator: fresh); + final listener = onConnectionUpdated; + if (listener != null) { + try { + await Future.sync(() => listener(_connection)); + } catch (e, st) { + appLogger.w('Failed to handle Jellyfin connection update', error: e, stackTrace: st); + } + } + } + } + } + return HealthStatus.online; + } + if (response.statusCode == 401 || response.statusCode == 403) { + return HealthStatus.authError; + } + return HealthStatus.offline; + } on MediaServerHttpException catch (e) { + if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError; + return HealthStatus.offline; + } catch (_) { + return HealthStatus.offline; + } + } + + @override + Future isHealthy() async => (await checkHealth()) == HealthStatus.online; + + /// Fetch the authenticated user's `Configuration` (audio/subtitle language + /// prefs, auto-select flag) so the player can apply per-user defaults. + /// Returns null on transport failures — caller treats as "no preference". + Future fetchUserProfile() async { + try { + final response = await _http.get('/Users/Me'); + throwIfHttpError(response); + final data = response.data; + if (data is! Map) return null; + return JellyfinUserProfile.fromUserDto(data); + } catch (e, st) { + appLogger.w('JellyfinClient.fetchUserProfile failed', error: e, stackTrace: st); + return null; + } + } + + @override + Future getMachineIdentifier() async { + try { + final response = await _http.get('/System/Info/Public'); + throwIfHttpError(response); + final data = response.data; + if (data is Map) { + return data['Id'] as String?; + } + return connection.serverMachineId; + } catch (e) { + appLogger.w('JellyfinClient: getMachineIdentifier failed: $e'); + return connection.serverMachineId; + } + } + + @override + bool get isOfflineMode => _offlineMode; + + @override + void setOfflineMode(bool offline) { + _offlineMode = offline; + } + + /// Expose the Jellyfin cache through the [MediaServerClient] interface so + /// the shared `fetchWithCacheFallback` / `fetchWithCacheFirst` helpers + /// route through the correct backend's cache substrate. + @override + ApiCache get cache => JellyfinApiCache.instance; + + // ── Browse: libraries ──────────────────────────────────────────── + // + // 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. + + @override + Future> fetchLibraries() async { + final response = await _http.get('/Users/${_segment(connection.userId)}/Views'); + throwIfHttpError(response); + final items = _itemsArray(response.data); + // Jellyfin surfaces the user's 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. + return items + .where((view) { + final ct = (view['CollectionType'] as String?)?.toLowerCase(); + return ct != 'boxsets' && ct != 'playlists'; + }) + .map((view) => JellyfinMappers.library(view, serverId: serverId, serverName: serverName)) + .whereType() + .toList(); + } + + @override + Future> fetchLibraryContent( + String libraryId, + LibraryQuery query, { + AbortController? abort, + }) async { + final translator = JellyfinLibraryQueryTranslator( + userId: connection.userId, + parentId: libraryId, + fields: _browseFields, + ); + final params = translator.toQueryParameters(query); + + final response = await _http.get('/Items', queryParameters: params, abort: abort); + throwIfHttpError(response); + final data = response.data; + final items = _itemsArray(data); + final total = (data is Map ? data['TotalRecordCount'] as int? : null) ?? items.length; + return LibraryPage(items: _mapItems(items), totalCount: total, offset: query.offset); + } + + /// Jellyfin's `/Items/Filters` returns Genres / OfficialRatings / Tags / + /// Categories + values from `/Items/Filters` in a single call. 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. + @override + Future fetchLibraryFiltersWithValues(String libraryId) async { + final response = await _http.get( + '/Items/Filters', + queryParameters: {'userId': connection.userId, 'ParentId': libraryId}, + ); + throwIfHttpError(response); + final data = response.data; + if (data is! Map) return LibraryFilterResult.empty; + List stringList(Object? raw) { + if (raw is! List) return const []; + return raw.whereType().where((s) => s.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 [], + }; + + const order = ['genre', 'year', 'contentRating', 'tag']; + final titles = { + 'genre': t.libraries.filterCategories.genre, + 'year': t.libraries.filterCategories.year, + 'contentRating': t.libraries.filterCategories.contentRating, + 'tag': t.libraries.filterCategories.tag, + }; + final filters = []; + final values = >{}; + for (final key in order) { + final entries = raw[key]; + if (entries == null || entries.isEmpty) continue; + filters.add( + MediaFilter(filter: key, filterType: 'string', key: 'jellyfin:$key', title: titles[key] ?? key, type: 'filter'), + ); + final sorted = List.from(entries); + if (key == 'year') { + sorted.sort((a, b) => (int.tryParse(b) ?? 0).compareTo(int.tryParse(a) ?? 0)); + } else { + sorted.sort(); + } + values[key] = sorted.map((v) => MediaFilterValue(key: v, title: v)).toList(); + } + return LibraryFilterResult(filters: filters, cachedValues: values); + } + + /// Jellyfin has no `/sorts` listing endpoint, so this returns a hardcoded + /// list mirroring the Plex fallback set. Keys are the backend-neutral names + /// understood by [JellyfinLibraryQueryTranslator] (`title`, `addedAt`, …); + /// `_buildFilterParams` emits them as `addedAt:desc` etc., and + /// [LibraryQueryTranslator.parseSortParam] turns them back into a + /// [LibrarySort] before the translator maps them to Jellyfin's + /// `SortBy`/`SortOrder`. + @override + Future> fetchSortOptions(String libraryId, {String? libraryType}) async { + return [ + MediaSort(key: 'title', descKey: 'title:desc', title: t.libraries.sortLabels.title, defaultDirection: 'asc'), + MediaSort( + key: 'addedAt', + descKey: 'addedAt:desc', + title: t.libraries.sortLabels.dateAdded, + defaultDirection: 'desc', + ), + MediaSort( + key: 'originallyAvailableAt', + descKey: 'originallyAvailableAt:desc', + title: t.libraries.sortLabels.releaseDate, + defaultDirection: 'desc', + ), + MediaSort(key: 'rating', descKey: 'rating:desc', title: t.libraries.sortLabels.rating, defaultDirection: 'desc'), + MediaSort( + key: 'lastViewedAt', + descKey: 'lastViewedAt:desc', + title: t.libraries.sortLabels.lastPlayed, + defaultDirection: 'desc', + ), + MediaSort( + key: 'viewCount', + descKey: 'viewCount:desc', + title: t.libraries.sortLabels.playCount, + defaultDirection: 'desc', + ), + MediaSort(key: 'random', title: t.libraries.sortLabels.random, defaultDirection: 'asc'), + ]; + } + + /// Jellyfin internalisation of the Plex-style filter map → [LibraryQuery] + /// translation that previously lived in [DataAggregationService]. Routes + /// through the existing [fetchLibraryContent] so the + /// [JellyfinLibraryQueryTranslator] handles the actual `/Items` query. + /// + /// [libraryKind] threads through so a "Shows" library returns Series rows + /// rather than the recursive episode expansion Jellyfin would otherwise + /// produce. + @override + Future> fetchLibraryPagedContent( + String libraryId, { + required LibraryQuery query, + MediaKind? libraryKind, + AbortController? abort, + }) async { + // [libraryKind] takes priority over any kind already on [query] — the + // browse tab passes the library's actual kind (Series, Movie) to override + // a less specific value. + final effective = (libraryKind != null && libraryKind != MediaKind.unknown) + ? query.copyWith(kind: libraryKind) + : query; + return fetchLibraryContent(libraryId, effective, abort: abort); + } + + /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin only exposes + /// chapters at the item level (`raw['Chapters']`); markers don't exist + /// in the API so [PlaybackExtras.markers] is always empty. Chapter end + /// offsets are backfilled from the next chapter's start so the UI can + /// render duration ranges (Plex serves explicit ends). + @override + Future fetchPlaybackExtras( + String itemId, { + String? introPattern, + String? creditsPattern, + bool forceRefresh = false, + }) async { + final item = await fetchItem(itemId); + return _playbackExtrasFromRaw(item?.raw, itemId); + } + + @override + Future fetchPlaybackExtrasFromCacheOnly( + String itemId, { + String? introPattern, + String? creditsPattern, + }) async { + final item = await cache.getMetadata(cacheServerId, itemId); + if (item == null) return null; + return _playbackExtrasFromRaw(item.raw, itemId); + } + + @override + Future fetchCachedMediaSourceInfo(String itemId) async { + final item = await cache.getMetadata(cacheServerId, itemId); + final raw = item?.raw; + if (raw is! Map) return null; + final sources = raw['MediaSources']; + if (sources is! List || sources.isEmpty) return null; + final first = sources.first; + if (first is! Map) return null; + return jellyfinMediaSourceToMediaSourceInfo(first, chapters: raw['Chapters'], trickplay: raw['Trickplay']); + } + + @override + Future createScrubPreviewSource({ + required MediaItem item, + required MediaSourceInfo mediaSource, + }) async { + if (!capabilities.scrubThumbnails) return null; + final manifest = mediaSource.trickplayByWidth; + if (manifest == null || manifest.isEmpty) return null; + return JellyfinTrickplayService.create( + client: this, + itemId: item.id, + mediaSourceId: mediaSource.mediaSourceId, + manifest: manifest, + ); + } + + /// Parse Jellyfin chapter list from the `raw` payload of a [MediaItem] + /// or a fresh `BaseItemDto` map. End offsets are backfilled from the + /// next chapter's start so the seek-bar tick UI has duration ranges. + PlaybackExtras _playbackExtrasFromRaw(dynamic raw, String itemId) => jellyfinPlaybackExtrasFromRaw(raw, itemId); + + static String _segment(String value) => Uri.encodeComponent(value); + + String _withApiKey(String urlOrPath) { + final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: urlOrPath); + final params = Map.from(uri.queryParameters)..['api_key'] = connection.accessToken; + return uri.replace(queryParameters: params).toString(); + } + + /// Jellyfin playback URL resolution. + /// + /// Two paths: + /// * `qualityPreset.isOriginal` → direct stream + /// (`/Videos/{id}/stream?Static=true&api_key=...`). + /// * non-original preset → POST `/Items/{id}/PlaybackInfo` with the + /// preset's bitrate and use the server-computed `TranscodingUrl` + /// from the returned `MediaSources` entry. Falls back to direct stream + /// when the server didn't provide a transcode URL (e.g. direct play + /// fits the cap) or the negotiation request failed. + /// + /// The returned `MediaSourceInfo` is what the player uses for track-picker + /// labels and auto-track selection by language. + /// + /// Throws [PlaybackException] when the item is missing or has no + /// `MediaSources`. + @override + Future getPlaybackInitialization(PlaybackInitializationOptions options) async { + final metadata = options.metadata; + final bundle = await fetchPlaybackBundle(metadata.id, sourceIndex: options.selectedMediaIndex); + if (bundle == null) { + throw PlaybackException('Item ${metadata.id} returned no MediaSources'); + } + final mediaInfo = jellyfinMediaSourceToMediaSourceInfo( + bundle.selectedSource, + chapters: bundle.chapters, + trickplay: bundle.trickplay, + ); + + final externalSubtitles = []; + for (final track in mediaInfo.subtitleTracks) { + if (track.isExternal) { + final path = track.key ?? _jellyfinSubtitleFallbackPath(metadata.id, bundle.selectedSourceId, track); + if (path == null) continue; + // Jellyfin's subtitle URL is a path relative to baseUrl; build the + // absolute URL with the api_key query param. + final url = _withApiKey(path); + externalSubtitles.add( + SubtitleTrack.uri( + url, + title: track.displayTitle ?? track.title ?? track.language, + language: track.languageCode, + ), + ); + } + } + + // Only forward MediaSourceId when there's actually more than one source — + // single-source items have `MediaSourceId == itemId` so the param is a + // no-op there but adds clutter to logs. + final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != metadata.id + ? bundle.selectedSourceId + : null; + + String? videoUrl; + String? playSessionId; + var playMethod = 'DirectPlay'; + var isTranscoding = false; + TranscodeFallbackReason? fallbackReason; + + final preset = options.qualityPreset; + if (!preset.isOriginal && preset.videoBitrateKbps != null) { + final maxBps = preset.videoBitrateKbps! * 1000; + final negotiation = await getPlaybackInfo( + metadata.id, + maxStreamingBitrate: maxBps, + mediaSourceId: bundle.selectedSourceId, + audioStreamIndex: options.selectedAudioStreamId, + ); + if (negotiation == null) { + fallbackReason = TranscodeFallbackReason.decisionFailed; + } else { + final sources = negotiation['MediaSources']; + Map? chosenSource; + if (sources is List && sources.isNotEmpty) { + for (final src in sources) { + if (src is Map && src['Id'] == bundle.selectedSourceId) { + chosenSource = src; + break; + } + } + chosenSource ??= sources.first is Map ? sources.first as Map : null; + } + final transcodingUrl = chosenSource?['TranscodingUrl']; + if (transcodingUrl is String && transcodingUrl.isNotEmpty) { + // TranscodingUrl is server-relative and already encodes container, + // codecs, MediaSourceId, and PlaySessionId; we just append the + // api_key for auth. + playSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId']; + final negotiatedPlaySessionId = negotiation['PlaySessionId']; + if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) { + playSessionId = negotiatedPlaySessionId; + } + videoUrl = _withApiKey(transcodingUrl); + playMethod = 'Transcode'; + isTranscoding = true; + } else { + final directStreamUrl = chosenSource?['DirectStreamUrl']; + if (directStreamUrl is String && directStreamUrl.isNotEmpty) { + playSessionId = Uri.tryParse(directStreamUrl)?.queryParameters['PlaySessionId']; + final negotiatedPlaySessionId = negotiation['PlaySessionId']; + if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) { + playSessionId = negotiatedPlaySessionId; + } + videoUrl = _withApiKey(directStreamUrl); + playMethod = 'DirectStream'; + } else { + fallbackReason = TranscodeFallbackReason.directPlayOnly; + } + } + } + } + + videoUrl ??= buildDirectStreamUrl(metadata.id, container: bundle.container, mediaSourceId: pinnedSourceId); + + return PlaybackInitializationResult( + availableVersions: bundle.availableVersions, + videoUrl: videoUrl, + mediaInfo: mediaInfo, + externalSubtitles: externalSubtitles, + isOffline: false, + isTranscoding: isTranscoding, + fallbackReason: fallbackReason, + activeAudioStreamId: isTranscoding ? options.selectedAudioStreamId : null, + playSessionId: playSessionId, + playMethod: playMethod, + ); + } + + String? _jellyfinSubtitleFallbackPath(String itemId, String? mediaSourceId, MediaSubtitleTrack track) { + final sourceId = mediaSourceId; + final streamIndex = track.index ?? track.id; + final codec = track.codec; + if (sourceId == null || codec == null || codec.isEmpty) return null; + final path = Uri( + pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'], + ).path; + return path.startsWith('/') ? path : '/$path'; + } + + /// Internal accessor for [PlaybackInitializationService]. Returns the + /// chosen `MediaSource` JSON, every available source's [MediaVersion], + /// and the item's `Chapters` array. One round-trip vs. fetchItem + raw + /// extraction at the call site. + /// + /// Returns `null` when the item doesn't exist or has no `MediaSources`. + /// [sourceIndex] is clamped to the valid range — out-of-bounds requests + /// fall back to source 0 to mirror Plex's `parseVideoPlaybackDataFromJson`. + Future fetchPlaybackBundle(String itemId, {int sourceIndex = 0}) async { + final item = await fetchItem(itemId); + final raw = item?.raw; + if (raw is! Map) return null; + final sources = raw['MediaSources']; + if (sources is! List || sources.isEmpty) return null; + final availableVersions = jellyfinSourcesToVersions(sources); + var index = sourceIndex; + if (index < 0 || index >= sources.length) index = 0; + final source = sources[index]; + if (source is! Map) return null; + final chapters = raw['Chapters']; + return JellyfinPlaybackBundle( + availableVersions: availableVersions, + selectedSource: source, + chapters: chapters is List ? chapters : const [], + container: source['Container'] as String?, + selectedSourceId: source['Id'] as String?, + trickplay: raw['Trickplay'], + ); + } + + /// Synthesised 27-letter alphabet — Jellyfin has no equivalent of Plex's + /// `/firstCharacter` endpoint, so the UI treats the bar as a name-prefix + /// filter instead of a scroll affordance. Each entry has `size: 1` so + /// the alpha-jump helper renders it without trying to do offset math. + @override + Future> fetchFirstCharacters(String libraryId, {Map? filters}) async { + const letters = [ + '#', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + ]; + return [for (final l in letters) LibraryFirstCharacter(key: l, title: l, size: 1)]; + } + + /// Queue a metadata refresh for the library. Jellyfin treats a library + /// view as an item, so we POST to `/Items/{id}/Refresh`. `FullRefresh` + /// re-pulls metadata from configured providers; `replaceAllMetadata=false` + /// preserves user edits — same UX as Plex's `refresh?force=1`. + @override + Future refreshLibraryMetadata(String libraryId) async { + final response = await _http.post( + '/Items/${_segment(libraryId)}/Refresh', + queryParameters: { + 'metadataRefreshMode': 'FullRefresh', + 'imageRefreshMode': 'Default', + 'replaceAllMetadata': 'false', + 'replaceAllImages': 'false', + }, + ); + throwIfHttpError(response); + } + + // ── Browse: items ──────────────────────────────────────────────── + + /// Jellyfin has no single-round-trip equivalent of Plex's + /// `?includeOnDeck=1`. We approximate it for shows by chaining a second + /// request to `/Shows/NextUp` filtered by `seriesId`. NextUp's defaults + /// (`enableResumable=true`, `disableFirstEpisode=false`) match Plex + /// OnDeck semantics: returns the resume episode when one exists, or S1E1 + /// when the user hasn't started. Movies and other kinds short-circuit. + @override + Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async { + final item = await fetchItem(id); + if (item == null || item.kind != MediaKind.show) { + return (item: item, onDeckEpisode: null); + } + final nextUp = await _safeFetchItemsArray('/Shows/NextUp', { + 'seriesId': id, + 'userId': connection.userId, + 'Limit': '1', + 'Fields': _browseFields, + }); + final onDeckEpisode = nextUp.isEmpty ? null : _mapItem(nextUp.first); + return (item: item, onDeckEpisode: onDeckEpisode); + } + + @override + Future fetchItem(String id) async { + final endpoint = '/Users/${_segment(connection.userId)}/Items/${_segment(id)}'; + // Contract: + // - 200 with parseable Map → MediaItem + // - 200 with non-Map body (HTML/text proxy page, empty) → null + // - 404 → null (item doesn't exist server-side) + // - 401/403/5xx → throw [MediaServerHttpException] so the UI can + // surface "auth required" / "server unavailable". Falling back to + // a cached row here would mislead the user into thinking they're + // still connected — explicit cache reads belong to the offline path. + // - Pure transport errors (no HTTP response) → fall back to cached row + // when present, otherwise rethrow. + if (_offlineMode) { + final cached = await cache.get(cacheServerId, endpoint); + if (cached is Map) return _mapItem(cached); + return null; + } + try { + final response = await _http.get(endpoint, queryParameters: {'Fields': _detailFields}); + throwIfHttpError(response); + final data = response.data; + if (data is! Map) return null; + try { + await cache.put(cacheServerId, endpoint, data); + } catch (e, st) { + appLogger.w('JellyfinClient.fetchItem cache write failed', error: e, stackTrace: st); + } + return _mapItem(data); + } on MediaServerHttpException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } catch (e) { + // Transport-layer failure: socket error, DNS, TLS, etc. Try cache. + appLogger.w('JellyfinClient.fetchItem network call failed', error: e); + try { + final cached = await cache.get(cacheServerId, endpoint); + if (cached is Map) return _mapItem(cached); + } catch (cacheError, st) { + appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st); + } + rethrow; + } + } + + @override + Future> fetchChildren(String parentId) async { + // Cache keys include userId so two users on the same server don't share + // per-user UserData (watched state) baked into the response. + final seasonsKey = '/Shows/$parentId/Seasons?userId=${connection.userId}'; + final childrenKey = '/Items?ParentId=$parentId&userId=${connection.userId}'; + + if (_offlineMode) { + final cachedSeasons = await cache.get(cacheServerId, seasonsKey); + if (cachedSeasons != null) { + final items = _itemsArray(cachedSeasons); + if (items.isNotEmpty) return _mapItems(items); + } + final cachedChildren = await cache.get(cacheServerId, childrenKey); + if (cachedChildren != null) { + return _mapItems(_itemsArray(cachedChildren)); + } + return const []; + } + + // For a series, the direct children are SEASONS (not the recursive + // episode expansion). Match Findroid: showsApi.getSeasons(seriesId) + // → /Shows/{seriesId}/Seasons. If the parent isn't a series this + // returns an empty list (or 404), so we fall through. + try { + final seasons = await _http.get( + '/Shows/${_segment(parentId)}/Seasons', + queryParameters: {'userId': connection.userId, 'Fields': _browseFields}, + ); + if (seasons.statusCode == 200) { + final data = seasons.data; + final items = _itemsArray(data); + if (items.isNotEmpty && data is Map) { + await cache.put(cacheServerId, seasonsKey, data); + return _mapItems(items); + } + } + } on MediaServerHttpException { + // Not a series — fall through to the generic ParentId query. + } + // Generic direct-children query: works for season → episodes, + // collection → items, etc. + final response = await _http.get( + '/Items', + queryParameters: {'userId': connection.userId, 'ParentId': parentId, 'Fields': _browseFields, 'Limit': '500'}, + ); + throwIfHttpError(response); + final data = response.data; + if (data is Map) { + await cache.put(cacheServerId, childrenKey, data); + } + return _mapItems(_itemsArray(data)); + } + + /// All directly-playable descendants of [parentId] (Movies + Episodes), + /// recursively expanded. Used by the playback launcher so a collection + /// containing a Series plays its episodes instead of the unplayable + /// Series entry, and a playlist mixing both comes through the same path. + /// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems] + /// since those preserve the container shape (Series rows, PlaylistItemId). + /// + /// No `Limit` — Jellyfin returns the entire list for this endpoint by + /// default, same precedent as [fetchClientSideEpisodeQueue]. + @override + Future> fetchPlayableDescendants(String parentId) async { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'ParentId': parentId, + 'Recursive': 'true', + 'IncludeItemTypes': 'Movie,Episode', + 'Fields': _browseFields, + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + + /// All episodes of a series in air order, optimised for queue-building. + /// Uses [_queueFields] (only `UserData`) instead of the browse field + /// set so the response stays small even for shows with thousands of + /// episodes. + /// + /// Paged in [_episodeQueuePageSize] chunks so long-running shows still get + /// a complete client-side next/previous queue without one huge response. + @override + Future?> fetchClientSideEpisodeQueue(String seriesId) async { + final all = []; + var startIndex = 0; + int? totalRecordCount; + + while (totalRecordCount == null || startIndex < totalRecordCount) { + final response = await _http.get( + '/Shows/${_segment(seriesId)}/Episodes', + queryParameters: { + 'userId': connection.userId, + 'Fields': _queueFields, + 'StartIndex': '$startIndex', + 'Limit': '$_episodeQueuePageSize', + }, + ); + throwIfHttpError(response); + final data = response.data; + final page = _mapItems(_itemsArray(data)); + all.addAll(page); + if (data is Map) { + final rawTotal = data['TotalRecordCount']; + if (rawTotal is int) totalRecordCount = rawTotal; + } + if (page.length < _episodeQueuePageSize) break; + startIndex += page.length; + } + + return all; + } + + @override + Future> searchItems(String query, {int limit = 30}) async { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'SearchTerm': query, + 'Recursive': 'true', + 'Limit': limit.toString(), + 'IncludeItemTypes': 'Movie,Series,Episode', + 'Fields': _browseFields, + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + + @override + Future> fetchRecentlyAdded({int limit = 50}) async { + // Matches userLibraryApi.getLatestMedia in the Jellyfin SDK. + final response = await _http.get( + '/Users/${_segment(connection.userId)}/Items/Latest', + queryParameters: {'Limit': limit.toString(), 'Fields': _browseFields, 'IncludeItemTypes': 'Movie,Series,Episode'}, + ); + throwIfHttpError(response); + final data = response.data; + // Latest returns a bare array, not an Items wrapper. + if (data is List) { + return _mapItems(data.whereType>()); + } + return _mapItems(_itemsArray(data)); + } + + @override + Future> fetchContinueWatching({int count = 20}) async { + final response = await _http.get( + '/UserItems/Resume', + queryParameters: { + 'userId': connection.userId, + 'Limit': count.toString(), + 'Fields': _browseFields, + 'MediaTypes': 'Video', + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + + // ── Browse: hubs ───────────────────────────────────────────────── + + @override + Future> fetchGlobalHubs({int limit = 10}) async { + // Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the + // home rows from three separate calls. The richer Plex Discover surface + // is intentionally left untranslated — see ServerCapabilities.richHubs. + final results = await Future.wait([ + _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'IncludeItemTypes': 'Movie,Series,Episode', + }), + _safeFetchItemsArray('/UserItems/Resume', { + 'userId': connection.userId, + 'Limit': limit.toString(), + 'Fields': _browseFields, + }), + _safeFetchItemsArray('/Shows/NextUp', { + 'userId': connection.userId, + 'Limit': limit.toString(), + 'Fields': _browseFields, + }), + ]); + + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'home.continue', + title: t.discover.continueWatching, + type: 'mixed', + items: results[1], + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'home.nextup', + title: t.discover.nextUp, + type: 'episode', + items: results[2], + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'home.recent', + title: t.discover.recentlyAdded, + type: 'mixed', + items: results[0], + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + + @override + Future> fetchLibraryHubs(String libraryId, {int limit = 10}) async { + // Mirror the Jellyfin web client's per-library "Suggestions" tab: + // Continue Watching + Next Up (TV libraries) + Recently Added. + // + // Issued in parallel so the recommended tab loads in one round-trip. + // We probe the library kind first to decide whether to ask for NextUp + // — querying it for a movie library is harmless (returns []), but + // skipping the request keeps the wire chatter tighter. + final results = await Future.wait([ + _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { + 'Limit': limit.toString(), + 'ParentId': libraryId, + 'Fields': _browseFields, + }), + _safeFetchItemsArray('/UserItems/Resume', { + 'userId': connection.userId, + 'ParentId': libraryId, + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'MediaTypes': 'Video', + }), + _safeFetchItemsArray('/Shows/NextUp', { + 'userId': connection.userId, + 'ParentId': libraryId, + 'Limit': limit.toString(), + 'Fields': _browseFields, + }), + ]); + + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.continue', + title: t.discover.continueWatching, + type: 'mixed', + items: results[1], + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.nextup', + title: t.discover.nextUp, + type: 'episode', + items: results[2], + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.recent', + title: t.discover.recentlyAdded, + type: 'mixed', + items: results[0], + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + + /// Re-run the synthetic hub query without the preview limit so the + /// hub-detail screen can render the full list. Branches on the + /// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]: + /// `home.recent` / `library.{id}.recent` → Latest, `*.continue` → Resume, + /// `*.nextup` → NextUp. Unknown ids return an empty list. + @override + Future> fetchMoreHubItems(String hubId, {int? limit}) async { + final effectiveLimit = (limit ?? 50).toString(); + String? parentId; + if (hubId.startsWith('library.')) { + final rest = hubId.substring('library.'.length); + final dot = rest.lastIndexOf('.'); + if (dot > 0) parentId = rest.substring(0, dot); + } + final tail = hubId.split('.').last; + final List> items; + switch (tail) { + case 'recent': + items = await _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { + 'Limit': effectiveLimit, + 'Fields': _browseFields, + if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode', + }); + break; + case 'continue': + items = await _safeFetchItemsArray('/UserItems/Resume', { + 'userId': connection.userId, + 'Limit': effectiveLimit, + 'Fields': _browseFields, + if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video', + }); + break; + case 'nextup': + items = await _safeFetchItemsArray('/Shows/NextUp', { + 'userId': connection.userId, + 'Limit': effectiveLimit, + 'Fields': _browseFields, + 'ParentId': ?parentId, + }); + break; + default: + return const []; + } + return _mapItems(items); + } + + @override + Future> fetchRelatedHubs(String id, {int count = 10}) async { + final response = await _http.get( + '/Items/${_segment(id)}/Similar', + queryParameters: {'userId': connection.userId, 'Limit': count.toString(), 'Fields': _browseFields}, + ); + throwIfHttpError(response); + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'item.$id.similar', + title: 'More Like This', + type: 'mixed', + items: _itemsArray(response.data), + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + + // ── Watch state ────────────────────────────────────────────────── + + @override + Future markWatched(MediaItem item) async { + final response = await _http.post( + '/UserPlayedItems/${_segment(item.id)}', + queryParameters: {'userId': connection.userId}, + ); + throwIfHttpError(response); + WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId); + } + + @override + Future markUnwatched(MediaItem item) async { + final response = await _http.delete( + '/UserPlayedItems/${_segment(item.id)}', + queryParameters: {'userId': connection.userId}, + ); + throwIfHttpError(response); + WatchStateNotifier().notifyWatched(item: item, isNowWatched: false, cacheServerId: cacheServerId); + } + + @override + Future removeFromContinueWatching(MediaItem item) async { + // Jellyfin uses a `Hide` endpoint to remove items from Continue Watching. + final response = await _http.post( + '/UserItems/${_segment(item.id)}/HideFromResume', + queryParameters: {'userId': connection.userId, 'Hide': 'true'}, + ); + throwIfHttpError(response); + } + + @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 + // (POST Likes=true), and the rest as a dislike (POST Likes=false). + final response = rating < 0 + ? await _http.delete('/UserItems/${_segment(item.id)}/Rating', queryParameters: {'userId': connection.userId}) + : await _http.post( + '/UserItems/${_segment(item.id)}/Rating', + queryParameters: {'userId': connection.userId, 'Likes': (rating >= 6.0).toString()}, + ); + throwIfHttpError(response); + } + + // ── Playlist read ──────────────────────────────────────────────── + + @override + Future> fetchPlaylists({String playlistType = 'video', bool? smart}) async { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'IncludeItemTypes': 'Playlist', + 'Recursive': 'true', + 'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags', + }, + ); + throwIfHttpError(response); + final requestedType = playlistType.toLowerCase(); + return _itemsArray(response.data).map(_playlistFromJson).where((playlist) { + if (requestedType.isNotEmpty && playlist.playlistType.toLowerCase() != requestedType) return false; + if (smart != null && playlist.smart != smart) return false; + return true; + }).toList(); + } + + @override + Future fetchPlaylistMetadata(String id) async { + final item = await fetchItem(id); + if (item == null) return null; + return MediaPlaylist( + id: item.id, + backend: MediaBackend.jellyfin, + title: item.title ?? 'Playlist', + summary: item.summary, + smart: false, + playlistType: _playlistMediaType(item), + durationMs: item.durationMs, + leafCount: item.leafCount, + thumbPath: item.thumbPath, + addedAt: item.addedAt, + updatedAt: item.updatedAt, + serverId: serverId, + serverName: serverName, + ); + } + + @override + Future> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async { + final response = await _http.get( + '/Playlists/${_segment(id)}/Items', + queryParameters: { + 'userId': connection.userId, + 'StartIndex': offset.toString(), + 'Limit': limit.toString(), + 'Fields': _browseFields, + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + + // ── Playlist write ─────────────────────────────────────────────── + + @override + Future createPlaylist({required String title, required List items}) async { + final response = await _http.post( + '/Playlists', + queryParameters: { + 'Name': title, + 'Ids': items.map((i) => i.id).join(','), + 'UserId': connection.userId, + 'MediaType': 'Video', + }, + ); + throwIfHttpError(response); + final data = response.data; + final newId = data is Map ? data['Id'] as String? : null; + if (newId == null || newId.isEmpty) return null; + return fetchPlaylistMetadata(newId); + } + + @override + Future addToPlaylist({required String playlistId, required List items}) async { + if (items.isEmpty) return true; + final response = await _http.post( + '/Playlists/${_segment(playlistId)}/Items', + queryParameters: {'Ids': items.map((i) => i.id).join(','), 'UserId': connection.userId}, + ); + throwIfHttpError(response); + return true; + } + + @override + Future deletePlaylist(MediaPlaylist playlist) async { + // Jellyfin treats 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 + /// 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 + /// optimistic UI changes; an HTTP error throws like the rest of the + /// write surface. + @override + Future movePlaylistItem({ + required String playlistId, + required MediaItem item, + required int newIndex, + required MediaItem? afterItem, + }) async { + if (item is! JellyfinMediaItem) { + appLogger.e('movePlaylistItem: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})'); + return false; + } + if (item.playlistItemId == null) { + appLogger.e('movePlaylistItem: item ${item.id} ("${item.title}") has no playlistItemId'); + return false; + } + final response = await _http.post( + '/Playlists/${_segment(playlistId)}/Items/${_segment(item.playlistItemId!)}/Move/$newIndex', + ); + throwIfHttpError(response); + return true; + } + + @override + Future removeFromPlaylist({required String playlistId, required MediaItem item}) async { + if (item is! JellyfinMediaItem) { + appLogger.e('removeFromPlaylist: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})'); + return false; + } + if (item.playlistItemId == null) { + appLogger.e('removeFromPlaylist: item ${item.id} ("${item.title}") has no playlistItemId'); + return false; + } + final response = await _http.delete( + '/Playlists/${_segment(playlistId)}/Items', + queryParameters: {'entryIds': item.playlistItemId}, + ); + throwIfHttpError(response); + return true; + } + + // ── Collections ────────────────────────────────────────────────── + + @override + Future> fetchCollections(String libraryId) async { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'ParentId': libraryId, + 'IncludeItemTypes': 'BoxSet', + 'Recursive': 'true', + 'Fields': _browseFields, + }, + ); + throwIfHttpError(response); + return _mapItems(_itemsArray(response.data)); + } + + /// Jellyfin has no pagination knob for collection children, so the first + /// call materialises the full list via [fetchChildren] and subsequent + /// paged calls slice from the same in-memory copy ([_collectionItemsCache]). + /// The [abort] hook is unused on this backend — the slice path is + /// synchronous and the underlying fetch is short-lived. + @override + Future> fetchCollectionPage( + String collectionId, { + int? start, + int? size, + AbortController? abort, + }) async { + final cached = _collectionItemsCache[collectionId] ?? await _loadAndCacheCollectionItems(collectionId); + final s = start ?? 0; + final fullSize = cached.length; + final from = s.clamp(0, fullSize); + final to = (size == null) ? fullSize : (s + size).clamp(0, fullSize); + return LibraryPage(items: cached.sublist(from, to), totalCount: fullSize, offset: s); + } + + Future> _loadAndCacheCollectionItems(String collectionId) async { + final items = await fetchChildren(collectionId); + _collectionItemsCache[collectionId] = items; + return items; + } + + @override + Future createCollection({ + required String libraryId, + required String title, + required List items, + MediaKind? itemKind, + }) async { + // ParentId is optional on Jellyfin's `/Collections` endpoint — when + // omitted the server picks a default BoxSet root. We pass libraryId so + // the new collection lives in the same library as the seeded items. + final response = await _http.post( + '/Collections', + queryParameters: { + 'Name': title, + if (items.isNotEmpty) 'Ids': items.map((i) => i.id).join(','), + if (libraryId.isNotEmpty) 'ParentId': libraryId, + }, + ); + throwIfHttpError(response); + final data = response.data; + return data is Map ? data['Id'] as String? : null; + } + + @override + Future addToCollection({required String collectionId, required List items}) async { + if (items.isEmpty) return true; + final response = await _http.post( + '/Collections/${_segment(collectionId)}/Items', + queryParameters: {'Ids': items.map((i) => i.id).join(',')}, + ); + throwIfHttpError(response); + return true; + } + + @override + Future removeFromCollection({required String collectionId, required MediaItem item}) async { + final response = await _http.delete( + '/Collections/${_segment(collectionId)}/Items', + queryParameters: {'Ids': item.id}, + ); + throwIfHttpError(response); + return true; + } + + @override + Future deleteCollection(MediaItem collection) async { + final response = await _http.delete('/Items/${_segment(collection.id)}'); + throwIfHttpError(response); + return true; + } + + // ── Item write ─────────────────────────────────────────────────── + + @override + Future deleteMediaItem(MediaItem item) async { + final response = await _http.delete('/Items/${_segment(item.id)}'); + throwIfHttpError(response); + return true; + } + + // ── File info ──────────────────────────────────────────────────── + + @override + Future getFileInfo(MediaItem item) async { + // Browse responses already include `MediaSources` (see [_browseFields]). + // Re-fetch via [fetchItem] only if the inline data isn't available. + final raw = item.raw is Map ? item.raw as Map : null; + Map? itemJson = raw; + if (itemJson == null || itemJson['MediaSources'] is! List) { + final fresh = await fetchItem(item.id); + itemJson = fresh?.raw is Map ? fresh!.raw as Map : null; + } + if (itemJson == null) return null; + return _buildFileInfoFromJellyfinItem(itemJson); + } + + MediaFileInfo? _buildFileInfoFromJellyfinItem(Map json) { + final sources = json['MediaSources']; + if (sources is! List || sources.isEmpty) return null; + final source = sources.first; + if (source is! Map) return null; + + final parsed = walkStreams(source['MediaStreams'] as List?, const JellyfinFileInfoStreamReader()); + final videoStream = parsed.videoStream; + final audioStream = parsed.audioStream; + final audioTracks = parsed.audioTracks; + final subtitleTracks = parsed.subtitleTracks; + + final width = videoStream?['Width'] as int?; + final height = videoStream?['Height'] as int?; + final aspectRatioString = videoStream?['AspectRatio'] as String?; + double? aspectRatio; + if (aspectRatioString != null && aspectRatioString.contains(':')) { + final parts = aspectRatioString.split(':'); + final num = double.tryParse(parts[0]); + final den = double.tryParse(parts[1]); + if (num != null && den != null && den != 0) aspectRatio = num / den; + } + aspectRatio ??= (width != null && height != null && height != 0) ? width / height : null; + + final runtimeTicks = source['RunTimeTicks'] as int?; + final durationMs = runtimeTicks != null ? (runtimeTicks ~/ 10000) : null; + + final bitrateBps = source['Bitrate'] as int?; + final videoBitrateBps = videoStream?['BitRate'] as int?; + + return MediaFileInfo( + container: source['Container'] as String?, + videoCodec: videoStream?['Codec'] as String?, + videoResolution: resolutionLabelFromDimensions(width, height), + videoFrameRate: videoStream?['RealFrameRate']?.toString() ?? videoStream?['AverageFrameRate']?.toString(), + videoProfile: videoStream?['Profile'] as String?, + width: width, + height: height, + aspectRatio: aspectRatio, + // Plex stores bitrate as kbps; Jellyfin returns bps. Normalise to kbps. + bitrate: bitrateBps != null ? bitrateBps ~/ 1000 : null, + duration: durationMs, + audioCodec: audioStream?['Codec'] as String?, + audioProfile: audioStream?['Profile'] as String?, + audioChannels: audioStream?['Channels'] as int?, + filePath: source['Path'] as String?, + fileSize: source['Size'] as int?, + colorSpace: videoStream?['ColorSpace'] as String?, + colorRange: videoStream?['ColorRange'] as String?, + colorPrimaries: videoStream?['ColorPrimaries'] as String?, + chromaSubsampling: null, + frameRate: + (videoStream?['RealFrameRate'] as num?)?.toDouble() ?? (videoStream?['AverageFrameRate'] as num?)?.toDouble(), + bitDepth: videoStream?['BitDepth'] as int?, + videoBitrate: videoBitrateBps != null ? videoBitrateBps ~/ 1000 : null, + audioChannelLayout: audioStream?['ChannelLayout'] as String?, + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + ); + } + + // ── Playback (stream URL building + session reporting) ────────── + + /// Direct-stream URL for [itemId]. Best for files the device can play + /// natively. Adds `?Static=true` to skip the transcoder and + /// `&api_key=...` so the request authenticates without a header. + /// + /// Pass [mediaSourceId] to stream a non-default alternate version. When the + /// item only has a single MediaSource, [mediaSourceId] equals [itemId] and + /// can be omitted; for items with multiple versions Jellyfin uses the + /// param to pick which file to serve. + String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) { + final params = { + 'Static': 'true', + 'api_key': connection.accessToken, + 'DeviceId': connection.deviceId, + 'Container': ?container, + 'MediaSourceId': ?mediaSourceId, + }; + final query = params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); + final encodedItem = Uri.encodeComponent(itemId); + return '${connection.baseUrl}/Videos/$encodedItem/stream?$query'; + } + + /// Trickplay sprite-sheet URL. [width] picks one of the resolutions + /// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based + /// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails). + /// Pass [mediaSourceId] when the item has more than one source so the + /// server returns the matching version's tiles. + String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) { + final params = { + 'api_key': connection.accessToken, + 'DeviceId': connection.deviceId, + 'MediaSourceId': ?mediaSourceId, + }; + final query = params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); + final encodedItem = Uri.encodeComponent(itemId); + return '${connection.baseUrl}/Videos/$encodedItem/Trickplay/$width/$sheetIndex.jpg?$query'; + } + + /// HLS master playlist URL for transcoded playback. Use when the file + /// container/codecs don't match the player's capabilities. The exact + /// negotiation (which streams, what bitrate) is server-driven via the + /// `PlaybackInfo` POST that should precede this call when fidelity + /// matters; this method assumes the caller already knows what they want. + String buildHlsStreamUrl( + String itemId, { + required String mediaSourceId, + int? videoBitrate, + int? audioStreamIndex, + int? subtitleStreamIndex, + String? playSessionId, + }) { + final params = { + 'DeviceId': connection.deviceId, + 'MediaSourceId': mediaSourceId, + 'api_key': connection.accessToken, + 'VideoBitrate': ?videoBitrate?.toString(), + 'AudioStreamIndex': ?audioStreamIndex?.toString(), + 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), + 'PlaySessionId': ?playSessionId, + }; + final query = params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); + final encodedItem = Uri.encodeComponent(itemId); + return '${connection.baseUrl}/Videos/$encodedItem/master.m3u8?$query'; + } + + /// Negotiate playback: returns the parsed `MediaSources[]` array and the + /// server's recommended `PlaySessionId`. Caller decides which media source + /// to use and feeds the result into [buildHlsStreamUrl] / direct play. + /// + /// [maxStreamingBitrate] is forwarded as both the top-level field and inside + /// the `DeviceProfile` so the server caps direct-stream and transcode bitrate + /// against the same ceiling. [mediaSourceId] pins the negotiation to a + /// specific version when the item has multiple sources. [audioStreamIndex] + /// / [subtitleStreamIndex] tell the server which streams to pick for the + /// transcode profile (Jellyfin's negotiation factors them in when picking + /// codec compatibility). + Future?> getPlaybackInfo( + String itemId, { + int maxStreamingBitrate = 100000000, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + try { + final query = { + 'userId': connection.userId, + 'MaxStreamingBitrate': maxStreamingBitrate.toString(), + 'MediaSourceId': ?mediaSourceId, + 'AudioStreamIndex': ?audioStreamIndex?.toString(), + 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), + }; + final response = await _http.post( + '/Items/${_segment(itemId)}/PlaybackInfo', + queryParameters: query, + body: { + 'UserId': connection.userId, + 'MaxStreamingBitrate': maxStreamingBitrate, + 'DeviceProfile': { + 'Name': 'Plezy', + 'MaxStreamingBitrate': maxStreamingBitrate, + 'CodecProfiles': const >[], + // Comma-separated codec lists are order-sensitive — first entry + // wins when the server picks an output codec. HEVC is listed + // ahead of H.264 so a server that has "Allow encoding in HEVC + // format" enabled will actually emit HEVC instead of falling + // back to H.264. + 'TranscodingProfiles': const >[ + { + 'Type': 'Video', + 'Container': 'ts', + 'Protocol': 'hls', + 'VideoCodec': 'hevc,h264', + 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus', + }, + ], + // Declaring HEVC in DirectPlayProfile.VideoCodec stops the server + // from forcing a transcode for HEVC sources whose container we + // already accept — mpv decodes HEVC natively on every platform + // we ship. + 'DirectPlayProfiles': const >[ + { + 'Type': 'Video', + 'Container': 'mp4,mkv,m4v,webm,mov,ts', + 'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4', + 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,vorbis,dts', + }, + ], + }, + }, + ); + throwIfHttpError(response); + final data = response.data; + return data is Map ? data : null; + } catch (e, st) { + appLogger.w('JellyfinClient: getPlaybackInfo failed', error: e, stackTrace: st); + return null; + } + } + + @override + Future fetchExternalIds(String itemId) async { + final item = await fetchItem(itemId); + final raw = item?.raw; + final providerIds = raw is Map ? raw['ProviderIds'] : null; + if (providerIds is Map) { + return ExternalIds.fromJellyfinProviderIds(providerIds); + } + return const ExternalIds(); + } + + /// Jellyfin embeds the access token in the URL query string (`api_key=...`) + /// rather than relying on headers, so the player needs no extra headers + /// for direct streams. + @override + Map get streamHeaders => const {}; + + /// Tell the server the user has started playing [itemId]. Body shape + /// mirrors the Jellyfin SDK's [PlaybackStartInfo] — Findroid sends the + /// same fields, and Jellyfin's session tracker drops events that omit + /// `PlayMethod` because it has no way to associate progress with an + /// active session row. + /// + /// [duration] is accepted for interface symmetry with Plex but ignored — + /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes + /// are still sent so the active session reflects the chosen tracks. + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + final response = await _http.post( + '/Sessions/Playing', + body: { + 'ItemId': itemId, + 'MediaSourceId': ?mediaSourceId, + 'AudioStreamIndex': ?audioStreamIndex, + 'SubtitleStreamIndex': ?subtitleStreamIndex, + 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), + 'CanSeek': true, + 'IsPaused': false, + 'IsMuted': false, + 'PlayMethod': playMethod ?? 'DirectPlay', + 'RepeatMode': 'RepeatNone', + 'PlaybackOrder': 'Default', + 'PlaySessionId': ?playSessionId, + }, + ); + throwIfHttpError(response); + } + + /// Periodic progress ping (5–10s cadence is typical). Server uses this to + /// drive the resume position, detect idle sessions, and save remembered + /// audio/subtitle stream indexes when enabled in Jellyfin user settings. + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + final response = await _http.post( + '/Sessions/Playing/Progress', + body: { + 'ItemId': itemId, + 'MediaSourceId': ?mediaSourceId, + 'AudioStreamIndex': ?audioStreamIndex, + 'SubtitleStreamIndex': ?subtitleStreamIndex, + 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), + 'CanSeek': true, + 'IsPaused': isPaused, + 'IsMuted': false, + 'PlayMethod': playMethod ?? 'DirectPlay', + 'RepeatMode': 'RepeatNone', + 'PlaybackOrder': 'Default', + 'PlaySessionId': ?playSessionId, + }, + ); + throwIfHttpError(response); + } + + /// End-of-playback signal. Final position becomes the resume bookmark. + /// [duration] is accepted for interface symmetry with Plex but ignored. + @override + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + }) async { + final response = await _http.post( + '/Sessions/Playing/Stopped', + body: { + 'ItemId': itemId, + 'MediaSourceId': ?mediaSourceId, + 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), + 'Failed': false, + 'PlaySessionId': ?playSessionId, + }, + ); + throwIfHttpError(response); + } + + // ── Live TV ────────────────────────────────────────────────────── + + /// Returns `true` when this server has Live TV configured (channels + /// available). Probes `/LiveTv/Channels?limit=1`. Used by [MultiServerProvider] + /// to gate the Live TV menu. + Future hasLiveTv() async { + try { + final response = await _http.get( + '/LiveTv/Channels', + queryParameters: {'limit': '1', 'userId': connection.userId}, + ); + if (response.statusCode != 200) return false; + final data = response.data; + if (data is Map) { + final total = data['TotalRecordCount']; + if (total is int) return total > 0; + final items = data['Items']; + if (items is List) return items.isNotEmpty; + } + return false; + } catch (e) { + appLogger.d('Jellyfin Live TV probe failed', error: e); + return false; + } + } + + /// Fetch the user's Live TV channel list. Each `BaseItemDto` of type + /// `TvChannel` is mapped to a [LiveTvChannel]. + Future> fetchLiveTvChannels() async { + final items = await _safeFetchItemsArray('/LiveTv/Channels', { + 'userId': connection.userId, + 'enableImages': 'true', + 'enableUserData': 'true', + 'sortBy': 'SortName', + 'sortOrder': 'Ascending', + }); + return items.map(_channelFromJson).toList(); + } + + /// 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. + Future> fetchLiveTvPrograms({ + List channelIds = const [], + int? beginsAt, + int? endsAt, + }) async { + DateTime? toDt(int? epoch) => epoch == null ? null : DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true); + final params = { + 'userId': connection.userId, + 'enableImages': 'true', + 'sortBy': 'StartDate', + 'sortOrder': 'Ascending', + if (channelIds.isNotEmpty) 'channelIds': channelIds.join(','), + if (beginsAt != null) 'minStartDate': toDt(beginsAt)!.toIso8601String(), + if (endsAt != null) 'maxStartDate': toDt(endsAt)!.toIso8601String(), + }; + final items = await _safeFetchItemsArray('/LiveTv/Programs', params); + return items.map(_programFromJson).toList(); + } + + LiveTvProgram _programFromJson(Map json) { + final id = json['Id'] as String?; + int? toEpochSec(dynamic raw) { + if (raw is! String || raw.isEmpty) return null; + final ms = DateTime.tryParse(raw)?.toUtc().millisecondsSinceEpoch; + return ms != null ? ms ~/ 1000 : null; + } + + final tags = json['ImageTags']; + String? primaryTag; + if (tags is Map) { + primaryTag = tags['Primary'] as String?; + } + final thumbPath = (id != null && primaryTag != null) + ? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}') + : null; + return LiveTvProgram( + key: id, + ratingKey: id, + guid: null, + title: json['Name'] as String? ?? 'Unknown Program', + summary: json['Overview'] as String?, + type: 'episode', + year: (json['ProductionYear'] as num?)?.toInt(), + beginsAt: toEpochSec(json['StartDate']), + endsAt: toEpochSec(json['EndDate']), + grandparentTitle: json['SeriesName'] as String?, + parentTitle: json['SeasonName'] as String?, + index: (json['IndexNumber'] as num?)?.toInt(), + parentIndex: (json['ParentIndexNumber'] as num?)?.toInt(), + thumb: thumbPath, + art: null, + channelIdentifier: json['ChannelId'] as String?, + channelCallSign: json['ChannelCallSign'] as String? ?? json['ChannelName'] as String?, + live: json['IsLive'] as bool?, + premiere: json['IsPremiere'] as bool?, + ); + } + + LiveTvChannel _channelFromJson(Map json) { + final id = json['Id'] as String? ?? ''; + final name = json['Name'] as String?; + final number = json['Number'] as String? ?? json['ChannelNumber'] as String?; + final tags = json['ImageTags']; + String? primaryTag; + if (tags is Map) { + primaryTag = tags['Primary'] as String?; + } + final thumbPath = primaryTag != null + ? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}') + : null; + return LiveTvChannel( + key: id, + identifier: id, + callSign: json['CallSign'] as String?, + title: name, + thumb: thumbPath, + art: null, + number: number, + hd: false, + lineup: null, + slug: null, + drm: null, + serverId: serverId, + serverName: serverName, + ); + } + + // ── Images ─────────────────────────────────────────────────────── + + @override + String thumbnailUrl(String? path, {int? width, int? height}) { + if (path == null || path.isEmpty) return ''; + final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: path); + final params = Map.from(uri.queryParameters); + if (width != null && !params.containsKey('maxWidth') && !params.containsKey('MaxWidth')) { + params['maxWidth'] = '$width'; + } + if (height != null && !params.containsKey('maxHeight') && !params.containsKey('MaxHeight')) { + params['maxHeight'] = '$height'; + } + params.putIfAbsent('api_key', () => connection.accessToken); + return uri.replace(queryParameters: params).toString(); + } + + /// Jellyfin doesn't expose an external-URL proxy endpoint comparable to + /// Plex's `/photo/:/transcode?url=...`. External URLs pass through. + @override + String externalImageUrl(String url, {int? width, int? height}) => url; + + /// Toggle the per-user `IsFavorite` flag for [itemId]. Used by the live-TV + /// favorite-channel adapter; works on any Jellyfin item. + Future _setItemFavorite(String itemId, bool isFavorite) async { + final path = '/Users/${_segment(connection.userId)}/FavoriteItems/${_segment(itemId)}'; + final response = isFavorite ? await _http.post(path) : await _http.delete(path); + throwIfHttpError(response); + } + + // ── Private helpers ────────────────────────────────────────────── + + Future>> _safeFetchItemsArray(String path, Map queryParameters) async { + try { + final response = await _http.get(path, queryParameters: queryParameters); + throwIfHttpError(response); + final data = response.data; + if (data is List) { + return data.whereType>().toList(); + } + return _itemsArray(data); + } catch (e, st) { + appLogger.w('JellyfinClient: $path failed (treating as empty)', error: e, stackTrace: st); + return const []; + } + } + + static List> _itemsArray(Object? data) { + if (data is Map) { + final items = data['Items']; + if (items is List) return items.whereType>().toList(); + } + if (data is List) return data.whereType>().toList(); + return const []; + } + + /// Slim field set for grid/list browsing — what the card UI actually + /// renders (title, year, watched badge, episode count for series), + /// plus `MediaSources` so the long-press "Play Version" gate matches + /// Plex's flow (Plex always inlines `Media[]`). + /// + /// The real Jellyfin web client + Findroid skip explicit `Fields` for + /// list calls; we ask for the minimum extras needed to drive the + /// MediaItem mapper: + /// - `RecursiveItemCount`/`ChildCount` for series leaf count + /// - `UserData` is included in defaults but pinned for safety + /// - `PremiereDate` for sort-by-release-date and episode metadata + /// - `OriginalTitle`/`SortName` for sort + alphabetised display + /// - `Overview` so episode-list rows show their description + /// - `MediaSources` so the context menu can hide `Play Version` when + /// there's nothing to pick (cost: ~40ms per 50-item page) + /// + /// Heavier fields (`People`, `Genres`, `Tags`, `Studios`, `Taglines`, + /// `ProviderIds`, `Chapters`) stay in [_detailFields] — together they + /// added ~6s to a 100-item Series page on a small home server. + static const _browseFields = + 'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview,MediaSources'; + + /// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows + /// only need title, thumbnail (`ImageTags['Primary']`), season/episode + /// index, and watched state. Title + indices come back without any + /// `Fields` request; we only need to ask for `UserData` for the + /// watched indicator. Drops `Overview` etc. so that even a thousand- + /// episode shounen show fits comfortably in one response. + static const _queueFields = 'UserData'; + + /// Page size for [fetchClientSideEpisodeQueue]. Keeps each server response + /// bounded while still returning the full series queue. + static const _episodeQueuePageSize = 200; + + /// Full field set for the detail screen and the resume / next-up + /// pre-fetch paths. Mirrors what the Jellyfin web detail view requests. + static 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,' + // Trickplay: per-resolution sprite-sheet manifest. The scrub-thumbnail + // loader reads `raw['Trickplay']` and computes tile URLs from it. + 'Trickplay,' + // ProviderIds carries Tmdb/Imdb/Tvdb keys — required for Trakt + the + // unified tracker coordinator to scrobble Jellyfin items without + // any extra round-trip. + 'ProviderIds'; + + MediaPlaylist _playlistFromJson(Map json) { + final id = json['Id'] as String? ?? ''; + return MediaPlaylist( + id: id, + backend: MediaBackend.jellyfin, + title: json['Name'] as String? ?? 'Playlist', + summary: json['Overview'] as String?, + smart: false, + playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? 'video', + leafCount: json['ChildCount'] as int?, + addedAt: _epochSecondsFromJson(json['DateCreated'] as String?), + updatedAt: _epochSecondsFromJson(json['DateLastSaved'] as String?), + thumbPath: _absolutizeImagePath(_imageTagPath(id, json['ImageTags'])), + serverId: serverId, + serverName: serverName, + ); + } + + String _playlistMediaType(MediaItem item) { + if (item.kind == MediaKind.track || item.kind == MediaKind.album) return 'audio'; + if (item.kind == MediaKind.photo) return 'photo'; + return 'video'; + } + + static int? _epochSecondsFromJson(String? iso) { + if (iso == null || iso.isEmpty) return null; + final dt = DateTime.tryParse(iso); + return dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; + } + + static String? _imageTagPath(String id, Object? tags) { + if (tags is! Map) return null; + final tag = tags['Primary']; + if (tag is! String) return null; + return '/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(tag)}'; + } + + @override + LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this); + + // ── Downloads ──────────────────────────────────────────────────── + + @override + Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async { + final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); + if (bundle == null) return buildDirectStreamUrl(item.id); + final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != item.id + ? bundle.selectedSourceId + : null; + return buildDirectStreamUrl(item.id, container: bundle.container, mediaSourceId: pinnedSourceId); + } + + @override + Future resolveDownload(MediaItem item, {int mediaIndex = 0}) async { + final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); + final selectedSourceId = bundle?.selectedSourceId; + final pinnedSourceId = selectedSourceId != null && selectedSourceId != item.id ? selectedSourceId : null; + // Direct-stream the selected original file. Jellyfin's `Static=true` + // skips the transcoder so the byte-for-byte source lands on disk. + final videoUrl = buildDirectStreamUrl(item.id, container: bundle?.container, mediaSourceId: pinnedSourceId); + + // External subtitle sidecars are listed in the per-source MediaStreams. + // PlaybackInfo gives us the canonical view including DeliveryUrl when + // the server has pre-computed one; fall back to the documented stream + // URL pattern otherwise. + final subtitles = []; + final pbInfo = await getPlaybackInfo(item.id); + if (pbInfo != null) { + final sources = pbInfo['MediaSources']; + if (sources is List && sources.length > mediaIndex) { + final source = sources[mediaIndex]; + if (source is Map) { + final mediaSourceId = (source['Id'] as String?) ?? item.id; + final streams = source['MediaStreams']; + if (streams is List) { + for (final raw in streams) { + if (raw is! Map) continue; + if (raw['Type'] != 'Subtitle') continue; + final isExternal = raw['IsExternal'] == true; + if (!isExternal) continue; + final index = raw['Index']; + if (index is! int) continue; + final codec = (raw['Codec'] as String?)?.toLowerCase(); + final delivery = raw['DeliveryUrl'] as String?; + final url = _withApiKey( + delivery != null && delivery.isNotEmpty + ? delivery + : '/Videos/${_segment(item.id)}/${_segment(mediaSourceId)}/Subtitles/$index/${_segment('Stream.${codec ?? 'srt'}')}', + ); + subtitles.add( + DownloadSubtitleSpec( + id: index, + url: url, + codec: codec, + language: raw['Language'] as String?, + languageCode: raw['Language'] as String?, + forced: raw['IsForced'] == true, + displayTitle: raw['DisplayTitle'] as String?, + ), + ); + } + } + } + } + } + + return DownloadResolution(videoUrl: videoUrl, externalSubtitles: subtitles); + } + + @override + List resolveDownloadArtwork(MediaItem item) { + // Jellyfin paths flow through `_absolutizeImagePath` at the mapper + // boundary, so artwork fields on the [MediaItem] are already absolute + // URLs. buildArtworkSpecs strips auth query params from localKey so the + // storage layer never hashes or persists access tokens. + return buildArtworkSpecs(item, (path) => path); + } +} + +/// Jellyfin implementation of [LiveTvSupport]. Wraps the existing +/// `fetchLiveTvChannels` / `fetchLiveTvPrograms` / `buildDirectStreamUrl`. +class _JellyfinLiveTvSupport implements LiveTvSupport { + final JellyfinClient _client; + _JellyfinLiveTvSupport(this._client); + + @override + Future isAvailable() => _client.hasLiveTv(); + + @override + Future> fetchDvrs() async => const []; + + @override + Future> fetchChannels({String? lineup}) => _client.fetchLiveTvChannels(); + + @override + Future> fetchSchedule({DateTime? from, DateTime? to}) { + int? toEpoch(DateTime? dt) => dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; + return _client.fetchLiveTvPrograms(beginsAt: toEpoch(from), endsAt: toEpoch(to)); + } + + @override + Future resolveStreamUrl(String channelKey, {String? dvrKey}) async { + final info = await _client.getPlaybackInfo(channelKey); + final sources = info?['MediaSources']; + final source = sources is List && sources.isNotEmpty && sources.first is Map + ? sources.first as Map + : null; + if (source == null) return null; + + final rawUrl = source['TranscodingUrl'] ?? source['DirectStreamUrl']; + final url = rawUrl is String && rawUrl.isNotEmpty + ? _client._withApiKey(rawUrl) + : _client.buildDirectStreamUrl(channelKey); + var playSessionId = info?['PlaySessionId'] as String?; + playSessionId ??= Uri.tryParse(url)?.queryParameters['PlaySessionId']; + return LiveTvStreamResolution(url: url, playSessionId: playSessionId); + } + + /// 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. + String get _favoritesPrefsKey => 'jellyfin_fav_channels:${_client.connection.id}'; + + /// Legacy bare-machineId key, kept for one-shot migration. + String get _legacyFavoritesPrefsKey => 'jellyfin_fav_channels:${_client.serverId}'; + + @override + Future buildFavoriteChannelSource({String? lineup}) async => 'server://${_client.serverId}/jellyfin'; + + @override + String get favoriteStoreKey => 'jellyfin:${_client.connection.id}'; + + @override + FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.serverSlice; + + /// Local list is the source of truth (preserves order + display fields). + /// Server-side `IsFavorite` is mirrored on writes via [setFavoriteChannels]. + @override + Future> fetchFavoriteChannels() async { + try { + return await _client._favoritesRepository.read(key: _favoritesPrefsKey, legacyKey: _legacyFavoritesPrefsKey); + } catch (e) { + appLogger.e('Failed to read Jellyfin favorite channels', error: e); + return const []; + } + } + + @override + Future setFavoriteChannels(List channels) async { + try { + final previous = await fetchFavoriteChannels(); + final previousIds = previous.map((c) => c.id).toSet(); + final newIds = channels.map((c) => c.id).toSet(); + + for (final id in newIds.difference(previousIds)) { + try { + await _client._setItemFavorite(id, true); + } catch (e) { + appLogger.w('Failed to mark Jellyfin channel $id favorite: $e'); + } + } + for (final id in previousIds.difference(newIds)) { + try { + await _client._setItemFavorite(id, false); + } catch (e) { + appLogger.w('Failed to unmark Jellyfin channel $id favorite: $e'); + } + } + + await _client._favoritesRepository.write(_favoritesPrefsKey, channels); + } catch (e) { + appLogger.e('Failed to save Jellyfin favorite channels', error: e); + } + } +} diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart new file mode 100644 index 00000000..9a032044 --- /dev/null +++ b/lib/services/jellyfin_mappers.dart @@ -0,0 +1,525 @@ +import '../media/media_backend.dart'; +import '../media/media_hub.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_library.dart'; +import '../media/media_part.dart'; +import '../media/media_role.dart'; +import '../media/media_stream.dart'; +import '../media/media_version.dart'; +import '../utils/jellyfin_time.dart'; +import '../utils/json_utils.dart'; +import '../utils/resolution_label.dart'; + +// Re-export so existing callers that pulled `resolutionLabelFromHeight` +// from this file keep compiling without a bulk import rewrite. +export '../utils/resolution_label.dart' show resolutionLabelFromHeight; + +/// Normalised projection of a single entry in Jellyfin's `MediaStreams` array. +/// Both [JellyfinMappers._mediaStreams] and `jellyfinMediaSourceToMediaSourceInfo` +/// build their own typed output (neutral [MediaStream] vs Plex-shaped +/// `MediaAudioTrack`/`MediaSubtitleTrack`) from this shared extraction so the +/// field-name parsing only lives in one place. +typedef JellyfinStreamFields = ({ + String? type, + int index, + String? codec, + String? language, + String? languageCode, + String? title, + String? displayTitle, + bool isDefault, + bool isForced, + bool isExternal, + String? deliveryUrl, + int? channels, + double? frameRate, +}); + +JellyfinStreamFields parseJellyfinStreamFields(Map s, {int fallbackIndex = 0}) { + return ( + type: (s['Type'] as String?)?.toLowerCase(), + index: flexibleInt(s['Index']) ?? fallbackIndex, + codec: s['Codec'] as String?, + language: s['DisplayLanguage'] as String? ?? s['Language'] as String?, + languageCode: s['Language'] as String?, + title: s['Title'] as String?, + displayTitle: s['DisplayTitle'] as String?, + isDefault: s['IsDefault'] as bool? ?? false, + isForced: s['IsForced'] as bool? ?? false, + isExternal: s['IsExternal'] as bool? ?? false, + deliveryUrl: s['DeliveryUrl'] as String?, + channels: flexibleInt(s['Channels']), + frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']), + ); +} + +Map? jellyfinFirstVideoStream(Object? streams) { + if (streams is! List) return null; + for (final stream in streams) { + if (stream is Map && (stream['Type'] as String?)?.toLowerCase() == 'video') { + return stream; + } + } + return null; +} + +MediaVersion jellyfinMediaSourceToVersion( + Map source, { + required String versionId, + required String partId, + String? streamPath, + List streams = const [], + bool includePartDuration = false, + bool requireParsedVideoStreamForDimensions = false, + String? name, +}) { + final rawVideo = jellyfinFirstVideoStream(source['MediaStreams']); + final parsedVideo = streams.firstWhere( + (stream) => stream.kind == MediaStreamKind.video, + orElse: () => const MediaStream(id: '', kind: MediaStreamKind.unknown), + ); + final hasParsedVideo = parsedVideo.kind == MediaStreamKind.video; + final width = flexibleInt(source['Width']) ?? flexibleInt(rawVideo?['Width']); + final height = flexibleInt(source['Height']) ?? flexibleInt(rawVideo?['Height']); + final exposeDimensions = !requireParsedVideoStreamForDimensions || hasParsedVideo; + return MediaVersion( + id: versionId, + width: exposeDimensions ? width : null, + height: exposeDimensions ? height : null, + videoResolution: resolutionLabelFromHeight(height), + videoCodec: hasParsedVideo ? parsedVideo.codec : rawVideo?['Codec'] as String?, + bitrate: bitrateKbpsFromBps(flexibleInt(source['Bitrate'])), + container: source['Container'] as String?, + parts: [ + MediaPart( + id: partId, + streamPath: streamPath, + sizeBytes: flexibleInt(source['Size']), + container: source['Container'] as String?, + durationMs: includePartDuration ? jellyfinTicksToMs(source['RunTimeTicks']) : null, + streams: streams, + ), + ], + name: name, + ); +} + +/// Turns relative Jellyfin image paths (e.g. `/Items/{id}/Images/Primary?tag=…`) +/// into fully-qualified, self-authenticated URLs by prepending the server's +/// [baseUrl] and appending `&api_key=`. Pure string ops — safe +/// to use from worker isolates and from the cache layer that doesn't hold a +/// [JellyfinClient]. +class JellyfinImageAbsolutizer { + final String baseUrl; + final String accessToken; + const JellyfinImageAbsolutizer({required this.baseUrl, required this.accessToken}); + + static Uri joinUri({required String baseUrl, required String urlOrPath}) { + final raw = Uri.parse(urlOrPath); + if (raw.hasScheme) return raw; + final cleanBase = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; + final cleanPath = urlOrPath.startsWith('/') ? urlOrPath : '/$urlOrPath'; + return Uri.parse('$cleanBase$cleanPath'); + } + + String? absolutize(String? path) { + if (path == null || path.isEmpty) return path; + if (path.startsWith('http://') || path.startsWith('https://')) return path; + final uri = joinUri(baseUrl: baseUrl, urlOrPath: path); + final params = Map.from(uri.queryParameters)..['api_key'] = accessToken; + return uri.replace(queryParameters: params).toString(); + } + + /// Walk a [MediaItem] and replace every relative image path with the + /// absolute, self-authenticated form. Cheap — touches a handful of + /// nullable strings and reuses the existing [MediaItem.copyWith]. + MediaItem applyTo(MediaItem item) { + return item.copyWith( + thumbPath: absolutize(item.thumbPath), + artPath: absolutize(item.artPath), + clearLogoPath: absolutize(item.clearLogoPath), + backgroundSquarePath: absolutize(item.backgroundSquarePath), + parentThumbPath: absolutize(item.parentThumbPath), + grandparentThumbPath: absolutize(item.grandparentThumbPath), + grandparentArtPath: absolutize(item.grandparentArtPath), + // Cast headshots come from the same /Items/{personId}/Images/Primary + // endpoint and need the same absolutize+api_key treatment, otherwise + // they get routed through Plex's photo proxy and 404. + roles: item.roles + ?.map((r) => MediaRole(id: r.id, tag: r.tag, role: r.role, thumbPath: absolutize(r.thumbPath))) + .toList(), + ); + } +} + +/// Pure mapping functions from Jellyfin's `BaseItemDto` JSON shape into the +/// neutral [MediaItem] / [MediaLibrary] domain types. +/// +/// Kept as top-level functions (no class) so they're trivially testable +/// against canned JSON fixtures and don't need a [JellyfinClient] instance. +class JellyfinMappers { + JellyfinMappers._(); + + static String _segment(String value) => Uri.encodeComponent(value); + + static String _query(String value) => Uri.encodeComponent(value); + + /// Map a Jellyfin `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()`. + static MediaItem? mediaItem( + Map item, { + required String serverId, + String? serverName, + required JellyfinImageAbsolutizer? absolutizer, + }) { + final id = item['Id'] as String?; + if (id == null || id.isEmpty) return null; + final type = item['Type'] as String?; + final kind = MediaKind.fromString(type); + + final mapped = JellyfinMediaItem( + id: id, + kind: kind, + guid: id, + title: item['Name'] as String?, + titleSort: item['SortName'] as String?, + summary: item['Overview'] as String?, + tagline: _firstString(item['Taglines']), + originalTitle: item['OriginalTitle'] as String?, + studio: _firstStudioName(item['Studios']), + year: item['ProductionYear'] as int?, + originallyAvailableAt: jellyfinIsoToYmd(item['PremiereDate'] as String?), + contentRating: item['OfficialRating'] as String?, + parentId: item['SeasonId'] as String? ?? item['ParentId'] as String?, + parentTitle: item['SeasonName'] as String?, + parentThumbPath: _imagePath(item, 'SeasonId', 'SeasonPrimaryImageTag', 'Primary'), + parentIndex: item['ParentIndexNumber'] as int?, + index: item['IndexNumber'] as int?, + grandparentId: item['SeriesId'] as String?, + grandparentTitle: item['SeriesName'] as String?, + grandparentThumbPath: _seriesPrimaryImage(item), + grandparentArtPath: _parentBackdropImage(item) ?? _seriesBackdropImage(item), + thumbPath: _selfImagePath(id, item, 'Primary'), + artPath: _selfImagePath(id, item, 'Backdrop'), + // Episodes/seasons don't carry their own logo — Jellyfin exposes the + // parent's logo via ParentLogoItemId/ParentLogoImageTag, which is + // what JF web renders on the hero card. + clearLogoPath: _selfImagePath(id, item, 'Logo') ?? _parentLogoImage(item), + durationMs: jellyfinTicksToMs(item['RunTimeTicks']), + viewOffsetMs: jellyfinTicksToMs(_userData(item)?['PlaybackPositionTicks']), + viewCount: _userData(item)?['PlayCount'] as int?, + lastViewedAt: jellyfinIsoToEpochSeconds(_userData(item)?['LastPlayedDate'] as String?), + // Plex semantics: `leafCount` = total leaf items (episodes for series). + // Jellyfin's `ChildCount` is direct children (seasons for a series), + // while `RecursiveItemCount` is the recursive total (episodes). Prefer + // the recursive count so series show episode counts, not season counts. + leafCount: (item['RecursiveItemCount'] as int?) ?? (item['ChildCount'] as int?), + viewedLeafCount: _viewedLeafCount(item), + childCount: item['ChildCount'] as int?, + addedAt: jellyfinIsoToEpochSeconds(item['DateCreated'] as String?), + updatedAt: jellyfinIsoToEpochSeconds(item['DateLastSaved'] as String? ?? item['DateModified'] as String?), + rating: (item['CommunityRating'] as num?)?.toDouble(), + // Jellyfin stores a binary `Likes` flag rather than a numeric rating. + // Map true → 10 / false → 0 so the existing UI's `userRating > 0` + // check renders the chip as filled for liked items. + userRating: switch (_userData(item)?['Likes']) { + true => 10.0, + false => 0.0, + _ => null, + }, + genres: _stringList(item['Genres']), + 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']), + styles: null, + moods: null, + roles: _actors(item['People']), + mediaVersions: _mediaVersions(item['MediaSources']), + libraryId: item['ParentLibraryId'] as String? ?? item['ParentId'] as String?, + libraryTitle: item['ParentLibraryName'] as String? ?? item['SeriesStudio'] as String?, + audioLanguage: item['PreferredMetadataLanguage'] as String?, + // Only present when the item came out of `/Playlists/{id}/Items`; the + // playlist write endpoints address rows by this id, not the media id. + playlistItemId: item['PlaylistItemId'] as String?, + serverId: serverId, + serverName: serverName, + raw: item, + ); + return absolutizer == null ? mapped : absolutizer.applyTo(mapped); + } + + /// Map a Jellyfin "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 String serverId, String? serverName}) { + final id = view['Id'] as String?; + if (id == null || id.isEmpty) return null; + final collectionType = view['CollectionType'] as String?; + return MediaLibrary( + id: id, + backend: MediaBackend.jellyfin, + title: view['Name'] as String? ?? 'Library', + kind: _libraryKindFromCollectionType(collectionType, view['Type'] as String?), + updatedAt: jellyfinIsoToEpochSeconds(view['DateLastSaved'] as String? ?? view['DateModified'] as String?), + createdAt: jellyfinIsoToEpochSeconds(view['DateCreated'] as String?), + hidden: false, + isShared: false, + serverId: serverId, + serverName: serverName, + ); + } + + /// Build a [MediaHub] from a list of items pre-fetched for a synthesized + /// home-screen row (Jellyfin doesn't have a single hub endpoint). + /// + /// [mapItem] lets the caller (typically [JellyfinClient]) inject its own + /// mapping pipeline so per-instance concerns like absolutizing image paths + /// against the connection's baseUrl/token can run. The mapper may return + /// `null` (matching [mediaItem]'s contract for missing-`Id` rows); those + /// entries are dropped from the hub. + static MediaHub syntheticHub({ + required String identifier, + required String title, + required String type, + required List> items, + required String serverId, + String? serverName, + MediaItem? Function(Map)? mapItem, + }) { + final mapper = mapItem ?? ((it) => mediaItem(it, serverId: serverId, serverName: serverName, absolutizer: null)); + final mappedItems = items.map(mapper).whereType().toList(); + return MediaHub( + id: identifier, + identifier: identifier, + title: title, + type: type, + items: mappedItems, + size: mappedItems.length, + more: items.length >= 20, + serverId: serverId, + serverName: serverName, + ); + } + + // ── private helpers ────────────────────────────────────────────── + + static MediaKind _libraryKindFromCollectionType(String? collectionType, String? type) { + final ct = collectionType?.toLowerCase(); + if (ct != null) { + return switch (ct) { + 'movies' => MediaKind.movie, + 'tvshows' => MediaKind.show, + 'music' => MediaKind.artist, + 'musicvideos' => MediaKind.clip, + 'homevideos' => MediaKind.clip, + 'photos' => MediaKind.photo, + 'boxsets' => MediaKind.collection, + 'playlists' => MediaKind.playlist, + 'mixed' => MediaKind.unknown, + _ => MediaKind.unknown, + }; + } + return MediaKind.fromString(type); + } + + static Map? _userData(Map item) { + final ud = item['UserData']; + return ud is Map ? ud : null; + } + + static int? _viewedLeafCount(Map item) { + final ud = _userData(item); + final unplayed = ud?['UnplayedItemCount'] as int?; + // Pair with `leafCount` semantics — episodes recursively, not seasons. + final total = (item['RecursiveItemCount'] as int?) ?? (item['ChildCount'] as int?); + if (total == null || unplayed == null) return null; + final v = total - unplayed; + return v < 0 ? 0 : v; + } + + static String? _firstString(Object? list) { + if (list is List && list.isNotEmpty && list.first is String) return list.first as String; + return null; + } + + static String? _firstStudioName(Object? list) { + if (list is List && list.isNotEmpty) { + final first = list.first; + if (first is Map) return first['Name'] as String?; + } + return null; + } + + static List? _stringList(Object? list) { + return stringListFromRaw(list); + } + + static List? _peopleByType(Object? list, String type) { + if (list is! List) return null; + final result = []; + for (final entry in list) { + if (entry is Map && entry['Type'] == type) { + final name = entry['Name'] as String?; + if (name != null) result.add(name); + } + } + return nullIfEmptyList(result); + } + + static List? _actors(Object? list) { + if (list is! List) return null; + final result = []; + for (final entry in list) { + if (entry is Map && (entry['Type'] == 'Actor' || entry['Type'] == 'GuestStar')) { + result.add( + MediaRole( + id: entry['Id'] as String?, + tag: entry['Name'] as String? ?? '', + role: entry['Role'] as String?, + thumbPath: _personImage(entry), + ), + ); + } + } + return nullIfEmptyList(result); + } + + static String? _personImage(Map person) { + final id = person['Id'] as String?; + final tag = person['PrimaryImageTag'] as String?; + if (id == null) return null; + final tagPart = tag != null ? '?tag=${_query(tag)}' : ''; + return '/Items/${_segment(id)}/Images/Primary$tagPart'; + } + + static List? _mediaVersions(Object? sources) { + if (sources is! List) return null; + final result = []; + for (final src in sources) { + if (src is! Map) continue; + final id = src['Id'] as String?; + if (id == null || id.isEmpty) continue; + final streams = _mediaStreams(src['MediaStreams']); + result.add( + jellyfinMediaSourceToVersion( + src, + versionId: id, + partId: id, + streamPath: '/Videos/${_segment(id)}/stream', + streams: streams, + includePartDuration: true, + requireParsedVideoStreamForDimensions: true, + name: src['Name'] as String?, + ), + ); + } + return nullIfEmptyList(result); + } + + static List _mediaStreams(Object? raw) { + if (raw is! List) return const []; + final result = []; + for (final s in raw) { + if (s is! Map) continue; + final f = parseJellyfinStreamFields(s, fallbackIndex: result.length); + final kind = switch (f.type) { + 'video' => MediaStreamKind.video, + 'audio' => MediaStreamKind.audio, + 'subtitle' => MediaStreamKind.subtitle, + _ => MediaStreamKind.unknown, + }; + result.add( + MediaStream( + id: '${f.index}', + kind: kind, + index: f.index, + codec: f.codec, + language: f.language, + languageCode: f.languageCode, + title: f.title, + displayTitle: f.displayTitle, + selected: f.isDefault, + channels: f.channels, + frameRate: f.frameRate, + forced: f.isForced, + sidecarPath: f.isExternal ? f.deliveryUrl : null, + ), + ); + } + return result; + } + + static String? _selfImagePath(String id, Map item, String type) { + final tags = item['ImageTags']; + final backdropTags = item['BackdropImageTags']; + String? tag; + if (type == 'Backdrop' && backdropTags is List && backdropTags.isNotEmpty) { + tag = backdropTags.first as String?; + return tag != null ? '/Items/${_segment(id)}/Images/Backdrop/0?tag=${_query(tag)}' : null; + } + if (tags is Map) { + final value = tags[type]; + if (value is String) tag = value; + } + if (tag == null) return null; + return '/Items/${_segment(id)}/Images/$type?tag=${_query(tag)}'; + } + + static String? _seriesPrimaryImage(Map item) { + final seriesId = item['SeriesId'] as String?; + if (seriesId == null) return null; + final tag = item['SeriesPrimaryImageTag'] as String?; + final tagPart = tag != null ? '?tag=${_query(tag)}' : ''; + return '/Items/${_segment(seriesId)}/Images/Primary$tagPart'; + } + + static String? _seriesBackdropImage(Map item) { + final seriesId = item['SeriesId'] as String?; + if (seriesId == null) return null; + return '/Items/${_segment(seriesId)}/Images/Backdrop/0'; + } + + /// Parent backdrop helper — works for episodes (parent = series) and + /// seasons (parent = series). Pulls the explicit + /// `ParentBackdropItemId`/`ParentBackdropImageTags` pair Jellyfin + /// inherits onto child items, falling back to a tagless URL when only + /// the id is present. + static String? _parentBackdropImage(Map item) { + final parentId = item['ParentBackdropItemId'] as String?; + if (parentId == null) return null; + final tags = item['ParentBackdropImageTags']; + if (tags is List && tags.isNotEmpty) { + final tag = tags.first as String?; + if (tag != null) return '/Items/${_segment(parentId)}/Images/Backdrop/0?tag=${_query(tag)}'; + } + return '/Items/${_segment(parentId)}/Images/Backdrop/0'; + } + + /// Parent logo helper — episodes/seasons inherit the series' logo via + /// `ParentLogoItemId`/`ParentLogoImageTag`. Match Jellyfin web's hero + /// card which always falls back to this for child items. + static String? _parentLogoImage(Map item) { + final parentId = item['ParentLogoItemId'] as String?; + if (parentId == null) return null; + final tag = item['ParentLogoImageTag'] as String?; + final tagPart = tag != null ? '?tag=${_query(tag)}' : ''; + return '/Items/${_segment(parentId)}/Images/Logo$tagPart'; + } + + static String? _imagePath(Map item, String idField, String tagField, String type) { + final id = item[idField] as String?; + if (id == null) return null; + final tag = item[tagField] as String?; + final tagPart = tag != null ? '?tag=${_query(tag)}' : ''; + return '/Items/${_segment(id)}/Images/$type$tagPart'; + } +} diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart new file mode 100644 index 00000000..101560a3 --- /dev/null +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -0,0 +1,243 @@ +import '../media/media_version.dart'; +import '../media/media_source_info.dart'; +import '../utils/jellyfin_time.dart'; +import '../utils/json_utils.dart'; +import 'jellyfin_mappers.dart'; + +/// Translate a Jellyfin `MediaSource` JSON object into [MediaSourceInfo] so the +/// existing Plex-shaped track picker can render readable labels and the +/// auto-track-selection has a `selected` flag to honour. Exposed as a +/// top-level function for unit testing the field mapping without spinning +/// up a [PlaybackInitializationService] or a [JellyfinClient]. +/// +/// [chapters] is Jellyfin's item-level `Chapters` array (the JSON list). +/// Each entry has `{Name, StartPositionTicks, ImageDateModified}`. Pass it +/// in here — Jellyfin doesn't nest chapters inside MediaSource so the +/// caller has to thread the field through. +/// +/// [trickplay] is `BaseItemDto.Trickplay` — the item-level trickplay manifest. +/// Parsed defensively because two shapes appear in the wild: a flat +/// `Map` (per Jellyfin OpenAPI) and a nested +/// `Map>` (Streamyfin reports this). +MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo( + Map source, { + Object? chapters, + Object? trickplay, +}) { + final streams = source['MediaStreams']; + final audioTracks = []; + final subtitleTracks = []; + // partId stays null for Jellyfin because Plex's `/library/parts/{id}` + // select-stream endpoint has no Jellyfin equivalent. Jellyfin track + // persistence is driven by `/Sessions/Playing/Progress` stream indexes. + const int? partId = null; + final defaultAudioStreamIndex = flexibleInt(source['DefaultAudioStreamIndex']); + final defaultSubtitleStreamIndex = flexibleInt(source['DefaultSubtitleStreamIndex']); + double? frameRate; + + if (streams is List) { + for (final s in streams) { + if (s is! Map) continue; + final f = parseJellyfinStreamFields(s); + switch (f.type) { + case 'video': + frameRate ??= f.frameRate; + break; + case 'audio': + audioTracks.add( + MediaAudioTrack( + id: f.index, + index: f.index, + codec: f.codec, + language: f.language, + languageCode: f.languageCode, + title: f.title, + displayTitle: f.displayTitle, + channels: f.channels, + selected: defaultAudioStreamIndex != null ? f.index == defaultAudioStreamIndex : f.isDefault, + ), + ); + break; + case 'subtitle': + subtitleTracks.add( + MediaSubtitleTrack( + id: f.index, + index: f.index, + codec: f.codec, + language: f.language, + languageCode: f.languageCode, + title: f.title, + displayTitle: f.displayTitle, + selected: defaultSubtitleStreamIndex != null + ? f.index == defaultSubtitleStreamIndex + : f.isDefault || f.isForced, + forced: f.isForced, + key: f.isExternal ? f.deliveryUrl : null, + external: f.isExternal, + ), + ); + break; + } + } + } + + final mappedChapters = []; + if (chapters is List) { + for (var i = 0; i < chapters.length; i++) { + final entry = chapters[i]; + if (entry is! Map) continue; + final startMs = jellyfinTicksToMs(entry['StartPositionTicks']) ?? 0; + mappedChapters.add(MediaChapter(id: i, index: i, startTimeOffset: startMs, title: entry['Name'] as String?)); + } + MediaChapter.backfillEndOffsets(mappedChapters); + } + + final mediaSourceId = source['Id'] as String?; + final trickplayByWidth = _parseTrickplayManifest(trickplay, mediaSourceId); + + return MediaSourceInfo( + videoUrl: '', + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: mappedChapters, + partId: partId, + frameRate: frameRate, + mediaSourceId: mediaSourceId, + defaultAudioStreamIndex: defaultAudioStreamIndex, + defaultSubtitleStreamIndex: defaultSubtitleStreamIndex, + trickplayByWidth: trickplayByWidth, + ); +} + +/// Parse Jellyfin chapters from the raw `BaseItemDto` payload into neutral +/// playback extras. Markers are not exposed by Jellyfin, so the list is empty. +PlaybackExtras jellyfinPlaybackExtrasFromRaw(dynamic raw, String itemId) { + String segment(String value) => Uri.encodeComponent(value); + String query(String value) => Uri.encodeComponent(value); + + final chapters = raw is Map ? raw['Chapters'] : null; + final mapped = []; + if (chapters is List) { + for (var i = 0; i < chapters.length; i++) { + final entry = chapters[i]; + if (entry is! Map) continue; + final startMs = jellyfinTicksToMs(entry['StartPositionTicks']) ?? 0; + // Jellyfin chapter image path: `/Items/{itemId}/Images/Chapter/{i}?tag=...`. + final imageTag = entry['ImageTag']; + final thumb = imageTag is String && imageTag.isNotEmpty + ? '/Items/${segment(itemId)}/Images/Chapter/$i?tag=${query(imageTag)}' + : null; + mapped.add( + MediaChapter(id: i, index: i, startTimeOffset: startMs, title: entry['Name']?.toString(), thumb: thumb), + ); + } + MediaChapter.backfillEndOffsets(mapped); + } + return PlaybackExtras(chapters: mapped, markers: const []); +} + +/// Coerce a Jellyfin trickplay manifest to `Map`, +/// tolerating both the flat OpenAPI shape (`{ "320": {...} }`) and the nested +/// Streamyfin shape (`{ "": { "320": {...} } }`). +/// +/// Returns `null` when [raw] is missing, malformed, or contains no usable +/// entries — callers treat that as "no scrub thumbnails". +Map? _parseTrickplayManifest(Object? raw, String? sourceId) { + if (raw is! Map) return null; + if (raw.isEmpty) return null; + + // Discriminate FLAT vs NESTED by inspecting the values: + // FLAT → at least one value is itself a TrickplayInfoDto-like map + // (has both `Width` and `Height` keys). + // NESTED → values are themselves resolution-keyed maps; the inner + // values are the TrickplayInfoDto-like ones. + final Map resolutionMap; + if (raw.values.any(_looksLikeTrickplayInfo)) { + resolutionMap = raw; + } else { + final byId = sourceId != null ? raw[sourceId] : null; + if (byId is Map) { + resolutionMap = byId; + } else { + // Source id not in the manifest — fall back to the first nested + // entry so the user still gets *something*. The caller already + // chose the right source; this is best-effort recovery. + final first = raw.values.cast().firstWhere((v) => v is Map, orElse: () => null); + if (first is! Map) return null; + resolutionMap = first; + } + } + + final result = {}; + resolutionMap.forEach((key, value) { + if (value is! Map) return; + final width = flexibleInt(key) ?? flexibleInt(value['Width']); + final height = flexibleInt(value['Height']); + final tileWidth = flexibleInt(value['TileWidth']); + final tileHeight = flexibleInt(value['TileHeight']); + final thumbnailCount = flexibleInt(value['ThumbnailCount']); + final interval = flexibleInt(value['Interval']); + if (width == null || + height == null || + tileWidth == null || + tileHeight == null || + thumbnailCount == null || + interval == null) { + return; + } + if (width <= 0 || height <= 0 || tileWidth <= 0 || tileHeight <= 0 || thumbnailCount <= 0 || interval <= 0) { + return; + } + final bandwidth = flexibleInt(value['Bandwidth']) ?? 0; + result[width] = TrickplayInfo( + width: width, + height: height, + tileWidth: tileWidth, + tileHeight: tileHeight, + thumbnailCount: thumbnailCount, + interval: interval, + bandwidth: bandwidth, + ); + }); + + return result.isEmpty ? null : result; +} + +bool _looksLikeTrickplayInfo(Object? v) => v is Map && v.containsKey('Width') && v.containsKey('Height'); + +/// Build a [MediaVersion] list from a Jellyfin item's `MediaSources` array so +/// the existing version picker UI renders labels for alternate versions. +/// Resolution comes from the video stream inside `MediaStreams` — Jellyfin +/// doesn't surface Width/Height on the source itself. +/// +/// Names are only attached when they actually disambiguate (i.e. the +/// sources have different `Name` values). For the common single-source +/// case the Name equals the item title and adds noise to the technical +/// label, so we skip it. +/// +/// Exposed as a top-level function for unit testing the field mapping +/// without spinning up a [PlaybackInitializationService] or a [JellyfinClient]. +List jellyfinSourcesToVersions(List sources) { + final names = []; + for (final src in sources) { + names.add(src is Map ? src['Name'] as String? : null); + } + final useName = names.where((n) => n != null && n.isNotEmpty).toSet().length > 1; + + final versions = []; + for (var i = 0; i < sources.length; i++) { + final src = sources[i]; + if (src is! Map) continue; + final sourceId = (src['Id'] as String?) ?? ''; + versions.add( + jellyfinMediaSourceToVersion( + src, + versionId: i.toString(), + partId: i.toString(), + streamPath: sourceId, + name: useName ? src['Name'] as String? : null, + ), + ); + } + return versions; +} diff --git a/lib/services/jellyfin_playback_bundle.dart b/lib/services/jellyfin_playback_bundle.dart new file mode 100644 index 00000000..eee28694 --- /dev/null +++ b/lib/services/jellyfin_playback_bundle.dart @@ -0,0 +1,45 @@ +import '../media/media_version.dart'; + +/// Bundle returned by [JellyfinClient.fetchPlaybackBundle]. +/// +/// Threads the data [PlaybackInitializationService] needs out of a single +/// Jellyfin item fetch — the chosen `MediaSource` JSON, the parsed +/// [MediaVersion] list (so the version picker can disambiguate alternate +/// cuts), the item-level `Chapters` array, and a couple of convenience +/// fields lifted off the selected source. Replaces the previous pattern +/// of reaching into [MediaItem.raw] from outside the client. +class JellyfinPlaybackBundle { + /// One [MediaVersion] per `MediaSource`. The selected version's id + /// matches [selectedSourceId]. + final List availableVersions; + + /// Raw `MediaSource` JSON the caller should feed to + /// `jellyfinMediaSourceToMediaSourceInfo` for track parsing. + final Map selectedSource; + + /// Item-level `Chapters` array (raw JSON list). Empty when the item + /// has no chapters. + final List chapters; + + /// `Container` field on the selected source — passed to + /// `buildDirectStreamUrl` so the player gets the right extension hint. + final String? container; + + /// `Id` of the selected source. Forwarded as `MediaSourceId=` only when + /// there's more than one source on the item; single-source items have + /// `Id == itemId` so the param adds noise without changing behaviour. + final String? selectedSourceId; + + /// Item-level `Trickplay` manifest (raw JSON object). `null` when the + /// server hasn't run trickplay extraction for this item. + final Object? trickplay; + + const JellyfinPlaybackBundle({ + required this.availableVersions, + required this.selectedSource, + required this.chapters, + this.container, + this.selectedSourceId, + this.trickplay, + }); +} diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart new file mode 100644 index 00000000..1f4c6021 --- /dev/null +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -0,0 +1,197 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../i18n/strings.g.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; +import '../media/play_queue.dart'; +import '../providers/multi_server_provider.dart'; +import '../providers/playback_state_provider.dart'; +import '../utils/snackbar_helper.dart'; +import 'media_list_playback_launcher.dart'; +import 'playlist_items_loader.dart'; + +/// Backend-neutral launcher for Jellyfin collections and playlists. +/// +/// 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). +class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { + final BuildContext context; + + /// Hook for tests — bypasses [Provider.of] so callers can inject a + /// fake [MediaServerClient]. Production callers leave this null and + /// the launcher resolves the client through [MultiServerProvider]. + final MediaServerClient? clientForTesting; + + /// Hook for tests — bypasses [Provider.of] so callers can inject a + /// fake [PlaybackStateProvider]. Production callers leave this null. + final PlaybackStateProvider? playbackStateForTesting; + + /// Hook for tests — replaces the real player navigation so the unit + /// test doesn't need a Navigator/route stack. + final Future Function(MediaItem item)? navigateForTesting; + + JellyfinSequentialLauncher({ + required this.context, + this.clientForTesting, + this.playbackStateForTesting, + this.navigateForTesting, + }); + + @override + Future launchFromCollectionOrPlaylist({ + required Object item, + required bool shuffle, + MediaItem? startItem, + bool showLoadingIndicator = true, + }) async { + final facts = MediaListPlaybackLauncher.classifyItem(item); + if (facts == null) { + return PlayQueueError(Exception('Item must be a collection or playlist')); + } + final serverId = facts.serverId; + if (serverId == null) { + return PlayQueueError(Exception('Item is missing serverId')); + } + + return executeWithLoading( + context: context, + showLoading: showLoadingIndicator, + actionLabel: shuffle ? t.common.shuffle : t.common.play, + execute: (dismissLoading) async { + final client = clientForTesting ?? _resolveClient(serverId); + if (client == null) { + await dismissLoading(); + if (context.mounted) { + showErrorSnackBar(context, t.errors.noClientAvailable); + } + return PlayQueueError(Exception('No client for server $serverId')); + } + + // Playlists go through the dedicated `/Playlists/{id}/Items` endpoint + // so playlist-defined order is preserved; collections fall back to + // recursive descendant expansion (which skips unplayable Series + // containers and surfaces Movies + Episodes flat). + List items; + if (facts.isPlaylist) { + items = await fetchAllPlaylistItems(client, facts.id); + } else { + items = await client.fetchPlayableDescendants(facts.id); + } + + if (items.isEmpty) return const PlayQueueEmpty(); + + if (shuffle) { + items = List.of(items)..shuffle(Random()); + } + + // When a startItem is given (and we're not shuffling), keep the full + // original order and move the local queue cursor to that item. + var startIndex = 0; + if (!shuffle && startItem != null) { + startIndex = items.indexWhere((it) => it.id == startItem.id); + if (startIndex < 0) startIndex = 0; + } + + await dismissLoading(); + if (!context.mounted && navigateForTesting == null) { + return const PlayQueueError('Context not mounted'); + } + + final playbackState = playbackStateForTesting ?? context.read(); + return launchLocalQueuePlayback( + context: context, + playbackState: playbackState, + queue: LocalPlayQueue( + id: 'jellyfin:${facts.id}', + items: items, + currentIndex: startIndex, + shuffled: shuffle, + backendId: client.backend.id, + ), + contextKey: facts.id, + navigateForTesting: navigateForTesting, + ); + }, + ); + } + + @override + Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async { + final kind = metadata.kind; + if (kind != MediaKind.show && kind != MediaKind.season) { + return PlayQueueError(Exception('Shuffle play only works for shows and seasons')); + } + final serverId = metadata.serverId; + if (serverId == null) { + return PlayQueueError(Exception('Item is missing serverId')); + } + final String seriesId; + if (kind == MediaKind.show) { + seriesId = metadata.id; + } else { + final parent = metadata.parentId; + if (parent == null) { + return PlayQueueError(Exception('Season is missing parentId')); + } + seriesId = parent; + } + + return executeWithLoading( + context: context, + showLoading: showLoadingIndicator, + actionLabel: t.common.shuffle, + execute: (dismissLoading) async { + final client = clientForTesting ?? _resolveClient(serverId); + if (client == null) { + await dismissLoading(); + if (context.mounted) { + showErrorSnackBar(context, t.errors.noClientAvailable); + } + return PlayQueueError(Exception('No client for server $serverId')); + } + + final raw = await client.fetchClientSideEpisodeQueue(seriesId); + if (raw == null || raw.isEmpty) return const PlayQueueEmpty(); + + final shuffled = List.of(raw)..shuffle(Random()); + final items = shuffled.map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName)).toList(); + + await dismissLoading(); + if (!context.mounted && navigateForTesting == null) { + return const PlayQueueError('Context not mounted'); + } + + final playbackState = playbackStateForTesting ?? context.read(); + return launchLocalQueuePlayback( + context: context, + playbackState: playbackState, + queue: LocalPlayQueue( + id: 'jellyfin:$seriesId', + items: items, + currentIndex: 0, + shuffled: true, + backendId: client.backend.id, + ), + contextKey: seriesId, + navigateForTesting: navigateForTesting, + ); + }, + ); + } + + /// Resolve the [MediaServerClient] for [serverId] through + /// [MultiServerProvider]. Returns null when the server isn't online or + /// the provider isn't in scope. + MediaServerClient? _resolveClient(String serverId) { + final provider = Provider.of(context, listen: false); + return provider.serverManager.getClient(serverId); + } +} diff --git a/lib/services/jellyfin_trickplay_service.dart b/lib/services/jellyfin_trickplay_service.dart new file mode 100644 index 00000000..61718aed --- /dev/null +++ b/lib/services/jellyfin_trickplay_service.dart @@ -0,0 +1,201 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/painting.dart' show ImageConfiguration, ImageProvider, ImageStreamListener; + +import '../media/media_source_info.dart'; +import 'image_cache_service.dart'; +import 'jellyfin_client.dart'; +import 'scrub_preview_source.dart'; + +/// Builds the [ImageProvider] for a sprite-sheet URL. Production uses +/// [CachedNetworkImageProvider] backed by [PlexImageCacheManager]; tests +/// inject a stub to avoid touching path_provider / platform channels. +typedef TrickplaySheetImageBuilder = ImageProvider Function(String url); + +ImageProvider _defaultSheetImageBuilder(String url) => + CachedNetworkImageProvider(url, cacheManager: PlexImageCacheManager.instance); + +/// Jellyfin sprite-sheet scrub thumbnails. Picks the best width from the +/// per-source manifest at construction, then computes +/// `(thumbnailIndex → sheetIndex, tileX, tileY)` on each [getFrame] call. +/// +/// Sheets are loaded lazily via [PlexImageCacheManager], so the second hover +/// over the same sheet hits the cache. Adjacent sheets are pre-fetched in +/// the direction of motion to keep fast scrubs smooth. +class JellyfinTrickplayService implements ScrubPreviewSource { + final JellyfinClient _client; + final String _itemId; + final String? _mediaSourceId; + final TrickplayInfo _info; + final TrickplaySheetImageBuilder _sheetImageBuilder; + + int? _lastSheetIndex; + bool _disposed = false; + + /// Provider cache by sheet index: scrubbing typically dwells on one sheet + /// for many hover events, so we reuse the same wrapper rather than + /// reallocating each frame. cached_network_image's own cache is keyed by + /// URL, but the wrapper object itself isn't free. + final Map _providerCache = {}; + + JellyfinTrickplayService._({ + required JellyfinClient client, + required String itemId, + required String? mediaSourceId, + required TrickplayInfo info, + required TrickplaySheetImageBuilder sheetImageBuilder, + }) : _client = client, + _itemId = itemId, + _mediaSourceId = mediaSourceId, + _info = info, + _sheetImageBuilder = sheetImageBuilder; + + /// Picks the best width from [manifest] (smallest >= [targetTooltipWidth], + /// largest available otherwise). Returns `null` when [manifest] is empty. + /// + /// [sheetImageBuilder] defaults to [CachedNetworkImageProvider] + + /// [PlexImageCacheManager]; tests can inject a stub to avoid touching + /// the platform image-cache plumbing. + static JellyfinTrickplayService? create({ + required JellyfinClient client, + required String itemId, + required String? mediaSourceId, + required Map manifest, + int targetTooltipWidth = 160, + TrickplaySheetImageBuilder? sheetImageBuilder, + }) { + if (manifest.isEmpty) return null; + final widths = manifest.keys.toList()..sort(); + final preferred = widths.firstWhere((w) => w >= targetTooltipWidth, orElse: () => widths.last); + final info = manifest[preferred]; + if (info == null) return null; + return JellyfinTrickplayService._( + client: client, + itemId: itemId, + mediaSourceId: mediaSourceId, + info: info, + sheetImageBuilder: sheetImageBuilder ?? _defaultSheetImageBuilder, + ); + } + + /// Pure math helper exposed for unit tests: maps a timestamp to the + /// sheet index, tile coordinates within the sheet, and the sheet's + /// (possibly partial) row/column count. Returns `null` only when the + /// manifest is degenerate. + TrickplayTileLocation? tileLocationFor(Duration time) { + if (_disposed) return null; + final info = _info; + if (info.thumbnailCount <= 0 || info.interval <= 0) return null; + + final rawIndex = time.inMilliseconds ~/ info.interval; + final thumbnailIndex = rawIndex.clamp(0, info.thumbnailCount - 1); + final tilesPerSheet = info.tileWidth * info.tileHeight; + if (tilesPerSheet <= 0) return null; + final sheetIndex = thumbnailIndex ~/ tilesPerSheet; + final tileInSheet = thumbnailIndex - sheetIndex * tilesPerSheet; + final tileColumn = tileInSheet % info.tileWidth; + final tileRow = tileInSheet ~/ info.tileWidth; + + final firstThumbInSheet = sheetIndex * tilesPerSheet; + final thumbsInSheet = math.min(tilesPerSheet, info.thumbnailCount - firstThumbInSheet); + final sheetColumns = thumbsInSheet >= info.tileWidth ? info.tileWidth : thumbsInSheet; + final sheetRows = (thumbsInSheet + info.tileWidth - 1) ~/ info.tileWidth; + + return TrickplayTileLocation( + sheetIndex: sheetIndex, + tileColumn: tileColumn, + tileRow: tileRow, + sheetColumns: sheetColumns, + sheetRows: sheetRows, + sourceTileSize: Size(info.width.toDouble(), info.height.toDouble()), + ); + } + + /// Sheet URL for [sheetIndex] using the chosen width and source id. + /// Exposed for tests; production callers use [getFrame]. + String sheetUrlFor(int sheetIndex) => + _client.buildTrickplayTileUrl(_itemId, _info.width, sheetIndex, mediaSourceId: _mediaSourceId); + + @override + bool get isAvailable => !_disposed && _info.thumbnailCount > 0; + + @override + ScrubFrame? getFrame(Duration time) { + final loc = tileLocationFor(time); + if (loc == null) return null; + final sheet = _providerFor(loc.sheetIndex); + _maybePrefetchAdjacent(loc.sheetIndex); + return SheetScrubFrame( + sheet: sheet, + tileColumn: loc.tileColumn, + tileRow: loc.tileRow, + sheetColumns: loc.sheetColumns, + sheetRows: loc.sheetRows, + sourceTileSize: loc.sourceTileSize, + ); + } + + ImageProvider _providerFor(int sheetIndex) => + _providerCache.putIfAbsent(sheetIndex, () => _sheetImageBuilder(sheetUrlFor(sheetIndex))); + + void _maybePrefetchAdjacent(int currentSheet) { + final last = _lastSheetIndex; + if (currentSheet == last) return; + _lastSheetIndex = currentSheet; + final tilesPerSheet = _info.tileWidth * _info.tileHeight; + if (tilesPerSheet <= 0) return; + final lastSheetIndex = (_info.thumbnailCount - 1) ~/ tilesPerSheet; + + final int? candidate; + if (last == null || currentSheet > last) { + candidate = currentSheet + 1 <= lastSheetIndex ? currentSheet + 1 : null; + } else { + candidate = currentSheet - 1 >= 0 ? currentSheet - 1 : null; + } + if (candidate != null) _kickOff(_providerFor(candidate)); + } + + /// Trigger a network fetch without holding a `BuildContext`. The cache + /// manager picks up the response, so the next [getFrame] for the same + /// sheet renders without a round-trip. Errors are absorbed via the + /// listener's `onError` so a failed prefetch doesn't propagate. + void _kickOff(ImageProvider provider) { + final stream = provider.resolve(ImageConfiguration.empty); + late ImageStreamListener listener; + listener = ImageStreamListener( + (image, synchronous) => stream.removeListener(listener), + onError: (_, _) => stream.removeListener(listener), + ); + stream.addListener(listener); + } + + @override + void dispose() { + _disposed = true; + _lastSheetIndex = null; + _providerCache.clear(); + } +} + +/// Pure data: which sheet to fetch, which tile within it to display, the +/// sheet's (possibly partial) row/column count, and the source tile's +/// pixel dimensions for aspect-correct scaling at render time. Returned +/// by [JellyfinTrickplayService.tileLocationFor] for unit tests. +class TrickplayTileLocation { + final int sheetIndex; + final int tileColumn; + final int tileRow; + final int sheetColumns; + final int sheetRows; + final Size sourceTileSize; + const TrickplayTileLocation({ + required this.sheetIndex, + required this.tileColumn, + required this.tileRow, + required this.sheetColumns, + required this.sheetRows, + required this.sourceTileSize, + }); +} diff --git a/lib/services/library_query_translator.dart b/lib/services/library_query_translator.dart new file mode 100644 index 00000000..0375310d --- /dev/null +++ b/lib/services/library_query_translator.dart @@ -0,0 +1,282 @@ +import '../media/library_query.dart'; +import '../media/media_kind.dart'; +import 'plex_constants.dart'; + +/// Translates a backend-neutral [LibraryQuery] into the per-backend +/// query-parameter map that the corresponding `/library/sections/{id}/all` +/// (Plex) or `/Items` (Jellyfin) endpoint expects. +/// +/// Pulled out of the clients so the translation can be unit-tested without +/// spinning up an HTTP layer, and so the per-backend filter/sort name +/// mappings live in one place. +abstract class LibraryQueryTranslator { + Map toQueryParameters(LibraryQuery query); + + /// Parse a Plex-style sort string (`field` or `field:desc`) into the + /// backend-neutral [LibrarySort] consumed by the translators. Returns + /// `null` when the input is empty or the field portion is missing. + static LibrarySort? parseSortParam(String? raw) { + if (raw == null || raw.isEmpty) return null; + const descSuffix = ':desc'; + const ascSuffix = ':asc'; + final descending = raw.endsWith(descSuffix); + final ascending = raw.endsWith(ascSuffix); + final field = descending + ? raw.substring(0, raw.length - descSuffix.length) + : ascending + ? raw.substring(0, raw.length - ascSuffix.length) + : raw; + if (field.isEmpty) return null; + return LibrarySort( + field: field, + direction: descending ? LibrarySortDirection.descending : LibrarySortDirection.ascending, + ); + } +} + +/// Plex's `/library/sections/{id}/all` accepts a flat `key=value` map. +/// Numeric `type=` selects the result class (1=movie, 2=show, …); +/// `sort=titleSort:asc` chains field+direction; filters are passed +/// verbatim under their original Plex names. +class PlexLibraryQueryTranslator implements LibraryQueryTranslator { + const PlexLibraryQueryTranslator(); + + @override + Map toQueryParameters(LibraryQuery query) { + final filters = {}; + final kindNumber = _plexTypeNumberFor(query.kind); + if (kindNumber != null) { + filters['type'] = kindNumber.toString(); + } + final sort = query.sort; + if (sort != null) { + final dir = sort.direction == LibrarySortDirection.descending ? ':desc' : ':asc'; + filters['sort'] = '${sort.field}$dir'; + } + if (query.search != null && query.search!.isNotEmpty) { + filters['title'] = query.search!; + } + if (!query.includeWatched) { + filters['unwatched'] = '1'; + } + // Typed slots: emit under the Plex API names so a `LibraryQuery` built + // from the FiltersBottomSheet (which still hands the browse tab a + // `Map`) round-trips back to the same wire query that the + // legacy `plexStyleFilters` parameter used to carry. + if (query.genres != null && query.genres!.isNotEmpty) { + filters['genre'] = query.genres!.join(','); + } + if (query.officialRatings != null && query.officialRatings!.isNotEmpty) { + filters['contentRating'] = query.officialRatings!.join(','); + } + if (query.years != null && query.years!.isNotEmpty) { + filters['year'] = query.years!.join(','); + } + if (query.tags != null && query.tags!.isNotEmpty) { + filters['tag'] = query.tags!.join(','); + } + if (query.nameStartsWith != null && query.nameStartsWith!.isNotEmpty) { + filters['alphaPrefix'] = query.nameStartsWith!; + } + for (final f in query.filters) { + filters[f.field] = f.values.join(','); + } + return filters; + } + + static int? _plexTypeNumberFor(MediaKind? kind) { + if (kind == null) return null; + return switch (kind) { + MediaKind.movie => PlexMetadataType.movie, + MediaKind.show => PlexMetadataType.show, + MediaKind.season => PlexMetadataType.season, + MediaKind.episode => PlexMetadataType.episode, + MediaKind.artist => PlexMetadataType.artist, + MediaKind.album => PlexMetadataType.album, + MediaKind.track => PlexMetadataType.track, + _ => null, + }; + } +} + +/// Inverse of [PlexLibraryQueryTranslator.toQueryParameters]: build a neutral +/// [LibraryQuery] from the legacy Plex-style `Map` filter map. +/// +/// Lives here so the round-trip stays in one file and the test that pins +/// equivalence (`map → LibraryQuery → Plex map` byte-for-byte) can import a +/// single symbol. +/// +/// Recognised keys map to their typed [LibraryQuery] slots (genre/year/ +/// contentRating/tag/unwatched/sort/type/alphaPrefix). Anything else carries +/// over as a generic [LibraryFilter] entry so Plex's verbatim-pass-through +/// behaviour for ad-hoc keys (director, writer, label, …) is preserved. +/// +/// `libraryKind` overrides any `type=` entry — both can be sources of truth +/// in the existing browse tab and the explicit argument wins. +LibraryQuery libraryQueryFromPlexMap({ + required Map map, + MediaKind? libraryKind, + int offset = 0, + int limit = 50, +}) { + const knownKeys = { + 'genre', + 'year', + 'contentRating', + 'tag', + 'unwatched', + 'sort', + 'type', + 'alphaPrefix', + 'includeCollections', + 'title', + }; + + String? nonEmpty(String? raw) => (raw == null || raw.isEmpty) ? null : raw; + + // libraryKind has priority; otherwise derive from `type` (single numeric + // value only — multi-value `type` like "1,4" stays in the generic filter + // bucket so Plex still receives it verbatim). + final typeRaw = nonEmpty(map['type']); + final kindFromMap = (typeRaw != null && !typeRaw.contains(',')) ? _plexTypeMediaKind(typeRaw) : null; + final kind = libraryKind ?? kindFromMap; + + final unknownFilters = []; + for (final entry in map.entries) { + if (knownKeys.contains(entry.key) || entry.value.isEmpty) continue; + unknownFilters.add(LibraryFilter(field: entry.key, values: entry.value.split(','))); + } + // Multi-value `type` couldn't fold into `kind`; preserve it as a generic + // filter entry so Plex still gets it on the wire. + if (typeRaw != null && typeRaw.contains(',')) { + unknownFilters.add(LibraryFilter(field: 'type', values: typeRaw.split(','))); + } + + List? singleton(String? raw) => raw == null ? null : [raw]; + + final yearRaw = nonEmpty(map['year']); + final years = yearRaw?.split(',').map(int.tryParse).whereType().toList(); + + return LibraryQuery( + kind: (kind == null || kind == MediaKind.unknown) ? null : kind, + offset: offset, + limit: limit, + includeWatched: nonEmpty(map['unwatched']) != '1', + nameStartsWith: nonEmpty(map['alphaPrefix']), + search: nonEmpty(map['title']), + genres: singleton(nonEmpty(map['genre'])), + officialRatings: singleton(nonEmpty(map['contentRating'])), + tags: singleton(nonEmpty(map['tag'])), + years: (years == null || years.isEmpty) ? null : years, + sort: LibraryQueryTranslator.parseSortParam(nonEmpty(map['sort'])), + filters: unknownFilters, + ); +} + +MediaKind? _plexTypeMediaKind(String typeNumber) { + return switch (typeNumber) { + '1' => MediaKind.movie, + '2' => MediaKind.show, + '3' => MediaKind.season, + '4' => MediaKind.episode, + '8' => MediaKind.artist, + '9' => MediaKind.album, + '10' => MediaKind.track, + _ => null, + }; +} + +/// Jellyfin's `/Items` accepts a richer parameter set with separate keys +/// for filters (`Genres`, `OfficialRatings`, `Tags`, `Years`), sort +/// (`SortBy`/`SortOrder`), pagination (`StartIndex`/`Limit`), and +/// item-type narrowing (`IncludeItemTypes`). +/// +/// The translator needs the calling user's id (every Jellyfin browse +/// query is user-scoped) and the parent library id; both are passed in +/// at construction time so the resulting map round-trips through +/// `_http.get('/Items', queryParameters: ...)` without further mutation. +class JellyfinLibraryQueryTranslator implements LibraryQueryTranslator { + final String userId; + final String parentId; + final String fields; + + const JellyfinLibraryQueryTranslator({required this.userId, required this.parentId, required this.fields}); + + @override + Map toQueryParameters(LibraryQuery query) { + final params = { + 'userId': userId, + 'ParentId': parentId, + 'Recursive': 'true', + 'StartIndex': query.offset.toString(), + 'Limit': query.limit.toString(), + 'IncludeItemTypes': _includeTypesFor(query.kind), + 'Fields': fields, + }; + if (!query.includeWatched) { + params['Filters'] = 'IsUnplayed'; + } + if (query.genres != null && query.genres!.isNotEmpty) { + // Jellyfin uses `|` as the multi-value separator for Genres. + params['Genres'] = query.genres!.join('|'); + } + if (query.officialRatings != null && query.officialRatings!.isNotEmpty) { + params['OfficialRatings'] = query.officialRatings!.join('|'); + } + if (query.years != null && query.years!.isNotEmpty) { + params['Years'] = query.years!.join(','); + } + if (query.tags != null && query.tags!.isNotEmpty) { + params['Tags'] = query.tags!.join('|'); + } + final sort = query.sort; + if (sort != null) { + params['SortBy'] = _sortFieldFor(sort.field); + params['SortOrder'] = sort.direction == LibrarySortDirection.descending ? 'Descending' : 'Ascending'; + } + if (query.search != null && query.search!.isNotEmpty) { + params['SearchTerm'] = query.search; + } + final prefix = query.nameStartsWith; + if (prefix != null && prefix.isNotEmpty) { + // `#` is the alpha-bar sentinel for "non-alphabetic" — match the JF + // web client by asking for everything sorted before "A". + if (prefix == '#') { + params['NameLessThan'] = 'A'; + } else { + params['NameStartsWith'] = prefix; + } + } + return params; + } + + static String _includeTypesFor(MediaKind? kind) { + return switch (kind) { + MediaKind.movie => 'Movie', + MediaKind.show => 'Series', + MediaKind.season => 'Season', + MediaKind.episode => 'Episode', + MediaKind.artist => 'MusicArtist', + MediaKind.album => 'MusicAlbum', + MediaKind.track => 'Audio', + MediaKind.collection => 'BoxSet', + MediaKind.playlist => 'Playlist', + MediaKind.clip => 'Video,MusicVideo', + MediaKind.photo => 'Photo', + _ => 'Movie,Series,Episode,Audio', + }; + } + + static String _sortFieldFor(String neutral) { + return switch (neutral) { + 'addedAt' => 'DateCreated', + 'originallyAvailableAt' => 'PremiereDate', + 'lastViewedAt' => 'DatePlayed', + 'title' => 'SortName', + 'rating' => 'CommunityRating', + 'viewCount' => 'PlayCount', + 'random' => 'Random', + _ => neutral, + }; + } +} diff --git a/lib/services/live_session_tracker.dart b/lib/services/live_session_tracker.dart new file mode 100644 index 00000000..c7cf9148 --- /dev/null +++ b/lib/services/live_session_tracker.dart @@ -0,0 +1,38 @@ +import '../utils/app_logger.dart'; +import '../utils/session_identifier.dart'; +import 'jellyfin_client.dart'; +import 'playback_report_session.dart'; + +/// Lightweight state machine for Jellyfin live TV playback heartbeats. +/// +/// Delegates start/progress/stop ordering to [PlaybackReportSession] so live +/// heartbeats follow the same terminal-state rules as normal playback. The +/// Plex live path keeps its bespoke capture-buffer flow inline at the call +/// site; this tracker only covers Jellyfin's `/Sessions/Playing*` flow. +class JellyfinLiveSessionTracker { + JellyfinLiveSessionTracker({String? playSessionId}) : _playSessionId = playSessionId ?? generateSessionIdentifier(); + + final String _playSessionId; + PlaybackReportSession? _session; + + /// Session id reused across all heartbeats for this playback. Exposed + /// for callers that need to thread it elsewhere (e.g. analytics logs). + String get playSessionId => _playSessionId; + + /// Send the appropriate heartbeat for [state] (`'playing'`, `'paused'`, + /// or `'stopped'`). Errors are swallowed — heartbeats are best-effort. + Future report({ + required JellyfinClient client, + required String itemId, + required String state, + required Duration position, + required Duration duration, + }) async { + try { + final session = _session ??= PlaybackReportSession(client: client, itemId: itemId, playSessionId: _playSessionId); + await session.report(PlaybackReportSnapshot(state: state, position: position, duration: duration)); + } catch (e) { + appLogger.d('Jellyfin live progress report failed', error: e); + } + } +} diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index 7248d156..87ec1158 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -1,9 +1,9 @@ import 'package:os_media_controls/os_media_controls.dart'; import 'package:rate_limiter/rate_limiter.dart'; -import 'plex_client.dart'; -import '../models/plex_metadata.dart'; -import '../utils/content_utils.dart'; +import '../media/media_server_client.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; import '../utils/app_logger.dart'; /// Manages OS media controls integration for video playback. @@ -36,14 +36,17 @@ class MediaControlsManager { /// Update media metadata displayed in OS media controls /// - /// This includes title, artist, artwork, and duration. - Future updateMetadata({required PlexMetadata metadata, PlexClient? client, Duration? duration}) async { + /// This includes title, artist, artwork, and duration. Backend-neutral — + /// the [MediaServerClient.thumbnailUrl] adapter handles per-backend URL + /// shape (Plex's `/photo/:/transcode` proxy vs. Jellyfin's + /// self-authenticated image URL). + Future updateMetadata({required MediaItem metadata, MediaServerClient? client, Duration? duration}) async { try { // Build artwork URL if client is available String? artworkUrl; - if (client != null && metadata.thumb != null) { + if (client != null && metadata.thumbPath != null) { try { - artworkUrl = client.getThumbnailUrl(metadata.thumb!); + artworkUrl = client.thumbnailUrl(metadata.thumbPath!); appLogger.d('Artwork URL for media controls: $artworkUrl'); } catch (e) { appLogger.w('Failed to build artwork URL', error: e); @@ -53,7 +56,7 @@ class MediaControlsManager { // Update OS media controls await OsMediaControls.setMetadata( MediaMetadata( - title: metadata.title!, + title: metadata.title ?? '', artist: _buildArtist(metadata), artworkUrl: artworkUrl, duration: duration, @@ -169,7 +172,7 @@ class MediaControlsManager { /// For episodes: "Show Name - Season X Episode Y" /// For movies: Director or studio /// For other content: Fallback to year or empty - String _buildArtist(PlexMetadata metadata) { + String _buildArtist(MediaItem metadata) { if (metadata.isEpisode) { final parts = []; @@ -188,7 +191,6 @@ class MediaControlsManager { return parts.join(' • '); } else if (metadata.isMovie) { // For movies, use director or studio - // Note: These fields may need to be added to PlexMetadata model if (metadata.year != null) { return metadata.year.toString(); } diff --git a/lib/services/media_list_playback_launcher.dart b/lib/services/media_list_playback_launcher.dart new file mode 100644 index 00000000..51b9449b --- /dev/null +++ b/lib/services/media_list_playback_launcher.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_playlist.dart'; +import '../media/play_queue.dart'; +import '../providers/playback_state_provider.dart'; +import '../utils/app_logger.dart'; +import '../utils/snackbar_helper.dart'; +import '../utils/video_player_navigation.dart'; +import 'jellyfin_sequential_launcher.dart'; +import 'play_queue_launcher.dart'; + +/// Result type for play queue launches. Same shape as the previous +/// [PlexPlayQueueLauncher] result so existing call sites can keep their +/// pattern matching unchanged. +sealed class PlayQueueResult { + const PlayQueueResult(); +} + +class PlayQueueSuccess extends PlayQueueResult { + const PlayQueueSuccess(); +} + +class PlayQueueEmpty extends PlayQueueResult { + const PlayQueueEmpty(); +} + +class PlayQueueError extends PlayQueueResult { + final Object error; + const PlayQueueError(this.error); +} + +/// 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 client builds an +/// in-memory queue from `fetchChildren` (collection) or +/// `fetchPlaylistItems` (playlist). [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]. + /// + /// [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 + /// true. + Future launchFromCollectionOrPlaylist({ + required Object item, + required bool shuffle, + MediaItem? startItem, + bool showLoadingIndicator = true, + }); + + /// 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`). + Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}); + + /// Pick the right implementation for [item]. Reads + /// [MediaItem.backend] / [MediaPlaylist.backend]. + static MediaListPlaybackLauncher forItem(BuildContext context, Object item) { + final backend = _backendOf(item); + if (backend == MediaBackend.jellyfin) { + return JellyfinSequentialLauncher(context: context); + } + return PlexPlayQueueLauncher.forContext(context, item); + } + + static MediaBackend _backendOf(Object item) { + if (item is MediaItem) return item.backend; + if (item is MediaPlaylist) return item.backend; + throw ArgumentError('Unsupported item type for MediaListPlaybackLauncher: ${item.runtimeType}'); + } + + /// Pull (kind, id, serverId, serverName) from an [item] that's a + /// [MediaItem] (collection-only) or a [MediaPlaylist]. Returns `null` for + /// any other type (including non-collection [MediaItem]) — caller turns + /// that into a [PlayQueueError] with whatever wording fits the call site. + static MediaListItemFacts? classifyItem(Object item) { + if (item is MediaItem) { + if (item.kind != MediaKind.collection) return null; + return MediaListItemFacts( + isCollection: true, + isPlaylist: false, + id: item.id, + serverId: item.serverId, + serverName: item.serverName, + ); + } + if (item is MediaPlaylist) { + return MediaListItemFacts( + isCollection: false, + isPlaylist: true, + id: item.id, + serverId: item.serverId, + serverName: item.serverName, + ); + } + return null; + } + + /// Show a loading dialog (when [showLoading] is true), invoke [execute], + /// dismiss the dialog, and translate exceptions into a localized snackbar + /// + [PlayQueueError]. [actionLabel] feeds the failure-snackbar copy. + /// + /// `dismissLoading` is passed into [execute] so the callback can hide the + /// dialog before navigating to the player; the wrapper dismisses + /// idempotently afterwards as a safety net. + /// + /// A [PlayQueueEmpty] result auto-emits the "no items" snackbar so each + /// backend doesn't have to remember. + @protected + Future executeWithLoading({ + required BuildContext context, + required bool showLoading, + required String actionLabel, + required Future Function(Future Function() dismissLoading) execute, + }) async { + BuildContext? loadingDialogContext; + var loadingVisible = false; + + if (showLoading && context.mounted) { + loadingVisible = true; + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + loadingDialogContext = dialogContext; + return const Center(child: CircularProgressIndicator()); + }, + ), + ); + } + + Future dismissLoading() async { + if (!showLoading || !loadingVisible) return; + final dialogContext = loadingDialogContext; + if (dialogContext == null) return; + // Only dismiss if the dialog is still the current route to avoid + // accidentally popping the player after navigation. + final route = ModalRoute.of(dialogContext); + if (route?.isCurrent ?? false) { + Navigator.of(dialogContext).pop(); + } + loadingVisible = false; + } + + try { + final result = await execute(dismissLoading); + + if (result is PlayQueueEmpty && context.mounted) { + showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems); + } + + await dismissLoading(); + return result; + } catch (e) { + appLogger.e('Failed to $actionLabel', error: e); + if (context.mounted) { + showErrorSnackBar(context, t.messages.failedPlayback(action: actionLabel, error: e.toString())); + } + await dismissLoading(); + return PlayQueueError(e); + } finally { + await dismissLoading(); + } + } + + /// Publish a client-side queue and navigate to its selected item. + @protected + Future launchLocalQueuePlayback({ + required BuildContext context, + required PlaybackStateProvider playbackState, + required LocalPlayQueue queue, + required String contextKey, + Future Function(MediaItem item)? navigateForTesting, + }) async { + if (queue.items.isEmpty) return const PlayQueueEmpty(); + if (!context.mounted && navigateForTesting == null) { + return const PlayQueueError('Context not mounted'); + } + + final currentIndex = queue.currentIndex ?? 0; + if (currentIndex < 0 || currentIndex >= queue.items.length) { + return PlayQueueError(RangeError.index(currentIndex, queue.items, 'currentIndex')); + } + + playbackState.setPlaybackFromLocalQueue(queue, contextKey: contextKey); + final itemToPlay = queue.items[currentIndex]; + if (navigateForTesting != null) { + await navigateForTesting(itemToPlay); + } else { + if (!context.mounted) return const PlayQueueError('Context not mounted'); + await navigateToVideoPlayer(context, metadata: itemToPlay); + } + return const PlayQueueSuccess(); + } +} + +/// Common shape extracted from [MediaItem] (collection) and [MediaPlaylist] +/// so both launcher backends share their classification preamble. +class MediaListItemFacts { + final bool isCollection; + final bool isPlaylist; + final String id; + final String? serverId; + final String? serverName; + + const MediaListItemFacts({ + required this.isCollection, + required this.isPlaylist, + required this.id, + required this.serverId, + required this.serverName, + }); +} diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 3d562ce8..8e8ba0b0 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -1,42 +1,78 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; +import '../connection/connection.dart'; +import '../media/media_server_client.dart'; +import 'jellyfin_client.dart'; import 'plex_client.dart'; -import '../models/plex_config.dart'; +import '../models/plex/plex_config.dart'; import '../utils/app_logger.dart'; -import '../utils/connection_constants.dart'; +import '../utils/media_server_timeouts.dart'; import '../utils/future_extensions.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'plex_auth_service.dart'; -import 'settings_service.dart'; import 'storage_service.dart'; -/// Manages multiple Plex server connections simultaneously +/// Manages multiple media-server connections simultaneously. +/// +/// 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. class MultiServerManager { - /// Map of serverId (clientIdentifier) to PlexClient instances - final Map _clients = {}; + FutureOr Function(JellyfinConnection connection)? onJellyfinConnectionUpdated; + + /// Map of serverId (clientIdentifier) to active client instances. + final Map _clients = {}; /// Map of serverId to server info - final Map _servers = {}; + final Map _plexServers = {}; /// Map of serverId to online status final Map _serverStatus = {}; + /// Servers whose last health probe rejected the auth token (HTTP 401/403). + /// These rows also have `_serverStatus[serverId] == false` — auth errors are + /// a *kind* of offline. Surfaces through [authErrorServerIds] so UI can + /// show a "Sign in again" banner instead of a generic offline state. + final Set _authErrorServers = {}; + /// Stream controller for server status changes final _statusController = StreamController>.broadcast(); /// Stream of server status changes Stream> get statusStream => _statusController.stream; + /// Servers whose authentication has failed (token rejected). A re-auth flow + /// should be offered for these — they will remain "offline" until the user + /// signs in again. Cleared once a probe succeeds. + Set get authErrorServerIds => Set.unmodifiable(_authErrorServers); + /// Connectivity subscription for network monitoring StreamSubscription>? _connectivitySubscription; /// Map of serverId to active optimization futures final Map> _activeOptimizations = {}; - /// Cached client identifier for reconnection without async storage lookup - String? _clientIdentifier; + /// Per-server clientIdentifier. Plex servers added via [addPlexAccount] + /// register their owning account's clientIdentifier here so reconnects + + /// endpoint optimization use the right identity (each account has its own + /// device row on plex.tv). + final Map _clientIdByServer = {}; + + String? _resolveClientIdentifier(String serverId) => _clientIdByServer[serverId]; + + /// 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). + final Map _jellyfinByCompoundId = {}; + final Map _activeJellyfinMachine = {}; + final Map _jellyfinHealthByCompoundId = {}; /// Debounce timers for endpoint-exhaustion-triggered reconnection (per server) final Map _reconnectDebounce = {}; @@ -50,8 +86,16 @@ class MultiServerManager { /// Debounce timer for connectivity events — collapses rapid network flapping Timer? _connectivityDebounce; - /// Get all registered server IDs - List get serverIds => _servers.keys.toList(); + /// Get all registered server IDs (Plex + Jellyfin). + /// + /// Sourced from [_clients] rather than [_plexServers] because + /// [_plexServers] only holds the Plex-specific [PlexServer] structs + /// (host/port metadata used for connection-racing). Jellyfin 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. + List get serverIds => _clients.keys.toList(); /// Get all online server IDs List get onlineServerIds => _serverStatus.entries.where((e) => e.value).map((e) => e.key).toList(); @@ -59,15 +103,71 @@ class MultiServerManager { /// Get all offline server IDs List get offlineServerIds => _serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList(); - /// Get client for specific server - PlexClient? getClient(String serverId) => _clients[serverId]; + /// Get client for specific server. + MediaServerClient? getClient(String serverId) => _clients[serverId]; - /// Get server info for specific server - PlexServer? getServer(String serverId) => _servers[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]. + PlexClient? getPlexClient(String serverId) { + final client = _clients[serverId]; + return client is PlexClient ? client : null; + } + + @visibleForTesting + void debugRegisterJellyfinClientForTesting(JellyfinClient client, {bool online = true}) { + _wireJellyfinConnectionUpdates(client); + final compoundId = client.connection.id; + final machineId = client.connection.serverMachineId; + _jellyfinByCompoundId[compoundId] = client; + _jellyfinHealthByCompoundId[compoundId] = online ? HealthStatus.online : HealthStatus.offline; + _clients[machineId] = client; + _activeJellyfinMachine[machineId] = compoundId; + _serverStatus[machineId] = online; + } + + @visibleForTesting + void debugRegisterClientForTesting(MediaServerClient client, {bool online = true}) { + _clients[client.serverId] = client; + _serverStatus[client.serverId] = online; + } + + @visibleForTesting + void debugMarkAuthErrorForTesting(String serverId) { + _serverStatus[serverId] = false; + _authErrorServers.add(serverId); + _statusController.add(Map.from(_serverStatus)); + } + + /// Plex-specific server config (name, machineId, connection candidates, + /// `owned` flag). Returns `null` for Jellyfin server ids — Jellyfin has no + /// `PlexServer` analogue. For "is this server registered?" use + /// [getClient] (works for both backends). + PlexServer? getPlexServer(String serverId) => _plexServers[serverId]; + + /// Backend-neutral "is this user an owner/admin on [serverId]?" probe used + /// by UI gates that hide destructive admin entries (delete, edit metadata, + /// match/unmatch). Returns: + /// - Plex: `PlexServer.owned` for the server (the matching profile-level + /// `plexAdmin` check stays at the call site so it can fold in + /// `ActiveProfileProvider`). + /// - Jellyfin: `JellyfinConnection.isAdministrator` captured at sign-in. + /// - Unknown server: `false`. + bool isOwnerOrAdmin(String serverId) { + final client = _clients[serverId]; + if (client is PlexClient) { + return _plexServers[serverId]?.owned == true; + } + if (client is JellyfinClient) { + return client.connection.isAdministrator; + } + return false; + } /// Get all online clients - Map get onlineClients { - final result = {}; + Map get onlineClients { + final result = {}; for (final serverId in onlineServerIds) { final client = _clients[serverId]; if (client != null) { @@ -77,12 +177,23 @@ class MultiServerManager { return result; } - /// Get all servers - Map get servers => Map.unmodifiable(_servers); + /// Plex servers known to the manager. Jellyfin servers are NOT included + /// here — they have no `PlexServer` analogue (single-URL connections, + /// not connection-raced multi-endpoint structs). For an all-backends + /// view of online servers use [serverIds] or [onlineClients]. + Map get plexServers => Map.unmodifiable(_plexServers); /// Check if a server is online bool isServerOnline(String serverId) => _serverStatus[serverId] ?? false; + /// Check whether the active or scoped client for [serverId] is online. + bool isClientOnline(String serverId, {String? clientScopeId}) { + if (clientScopeId != null && clientScopeId.isNotEmpty) { + return _jellyfinHealthByCompoundId[clientScopeId] == HealthStatus.online; + } + return isServerOnline(serverId); + } + /// Creates and initializes a PlexClient for a given server /// /// Handles finding working connection, loading cached endpoint, @@ -191,157 +302,285 @@ class MultiServerManager { }(); } - /// Connect to all available servers in parallel - /// Returns the number of successfully connected servers - Future connectToAllServers( - List servers, { - String? clientIdentifier, - Duration timeout = ConnectionTimeouts.perServerConnect, - Function(String serverId, PlexClient client)? onServerConnected, - Function(String serverId, Object error)? onServerFailed, - }) async { - if (servers.isEmpty) { - appLogger.w('No servers to connect to'); - return 0; - } - - appLogger.i('Connecting to ${servers.length} servers...'); - unawaited( - Sentry.addBreadcrumb(Breadcrumb(message: 'Connecting to ${servers.length} server(s)', category: 'servers')), - ); - - // Re-use the persisted client ID so Plex doesn't see a "new device" on - // every reconnect. - final effectiveClientId = - clientIdentifier ?? await (await StorageService.getInstance()).getOrCreateClientIdentifier(); - _clientIdentifier = effectiveClientId; - - // Create connection tasks for all servers (timeout is inside each task - // so a timed-out task cannot keep mutating manager state). - final connectionFutures = servers.map((server) async { - final serverId = server.clientIdentifier; - - try { - appLogger.d('Attempting connection to server: ${server.name}'); - - final client = await _createClientForServer( - server: server, - clientIdentifier: effectiveClientId, - ).namedTimeout(timeout, operation: 'connect to ${server.name}'); - - // Store the client and server info - _clients[serverId]?.close(); - _clients[serverId] = client; - _servers[serverId] = server; - _serverStatus[serverId] = true; - - onServerConnected?.call(serverId, client); - appLogger.i('Successfully connected to ${server.name}'); - - // Fire-and-forget: fetch server prefs and cache watched threshold - unawaited( - client - .fetchServerPrefs() - .then((_) { - final threshold = client.watchedThresholdPercent; - SettingsService.instanceOrNull?.write(SettingsService.watchedThresholdPref(serverId), threshold); - }) - .catchError((Object e, StackTrace st) { - appLogger.w('fetchServerPrefs failed for ${server.name}', error: e, stackTrace: st); - }), - ); - - return serverId; - } on TimeoutException { - appLogger.w('Server connection timed out for ${server.name}'); - _servers[serverId] = server; - _serverStatus[serverId] = false; - onServerFailed?.call(serverId, TimeoutException('Connection to ${server.name} timed out')); - return null; - } catch (e, stackTrace) { - appLogger.e('Failed to connect to ${server.name}', error: e, stackTrace: stackTrace); - - // Mark as offline - _servers[serverId] = server; - _serverStatus[serverId] = false; - - onServerFailed?.call(serverId, e); - return null; - } - }); - - final results = await Future.wait(connectionFutures); - - // Count successful connections - final successCount = results.where((id) => id != null).length; - - // Notify listeners of status change - _statusController.add(Map.from(_serverStatus)); - - appLogger.i('Connected to $successCount/${servers.length} servers successfully'); - - // Start network monitoring if we have any connected servers - if (successCount > 0) { - startNetworkMonitoring(); - } - - return successCount; - } - - /// Add a single server connection - Future addServer(PlexServer server, {String? clientIdentifier}) async { - final serverId = server.clientIdentifier; - final effectiveClientId = clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString(); - _clientIdentifier ??= effectiveClientId; - - try { - appLogger.d('Adding server: ${server.name}'); - - final client = await _createClientForServer(server: server, clientIdentifier: effectiveClientId); - - // Store - _clients[serverId]?.close(); - _clients[serverId] = client; - _servers[serverId] = server; - _serverStatus[serverId] = true; - - // Notify - _statusController.add(Map.from(_serverStatus)); - - appLogger.i('Successfully added server: ${server.name}'); - return true; - } catch (e, stackTrace) { - appLogger.e('Failed to add server ${server.name}', error: e, stackTrace: stackTrace); - - _servers[serverId] = server; - _serverStatus[serverId] = false; - _statusController.add(Map.from(_serverStatus)); - - return false; - } - } - /// Remove a server connection void removeServer(String serverId) { - _clients.remove(serverId)?.close(); - _servers.remove(serverId); + final jellyfinCompoundIds = _jellyfinByCompoundId.entries + .where((entry) => entry.value.connection.serverMachineId == serverId) + .map((entry) => entry.key) + .toList(); + if (jellyfinCompoundIds.isNotEmpty) { + final closed = {}; + _clients.remove(serverId); + _activeJellyfinMachine.remove(serverId); + for (final compoundId in jellyfinCompoundIds) { + final client = _jellyfinByCompoundId.remove(compoundId); + _jellyfinHealthByCompoundId.remove(compoundId); + if (client != null && closed.add(client)) { + client.close(); + } + } + } else { + _clients.remove(serverId)?.close(); + } + _plexServers.remove(serverId); _serverStatus.remove(serverId); + _authErrorServers.remove(serverId); _statusController.add(Map.from(_serverStatus)); appLogger.i('Removed server: $serverId'); } - /// Update server status (used for health monitoring) + /// Connect every server attached to a Plex account in parallel. Each + /// account has its own `clientIdentifier` (registered as a separate + /// device on plex.tv), and we keep that mapping per-server in + /// [_clientIdByServer] so subsequent reconnects + endpoint optimization + /// race connections from the right identity. + Future addPlexAccount( + PlexAccountConnection connection, { + Duration timeout = MediaServerTimeouts.perServerConnect, + Function(String serverId, bool success)? onServerStatus, + }) async { + if (connection.servers.isEmpty) return 0; + appLogger.i( + 'Connecting Plex account ${connection.accountLabel} ' + '(${connection.servers.length} server${connection.servers.length == 1 ? '' : 's'})', + ); + + int connected = 0; + final futures = connection.servers.map((server) async { + final serverId = server.clientIdentifier; + _clientIdByServer[serverId] = connection.clientIdentifier; + _plexServers[serverId] = server; + try { + final client = await _createClientForServer( + server: server, + clientIdentifier: connection.clientIdentifier, + ).namedTimeout(timeout, operation: 'connect to ${server.name}'); + _clients[serverId]?.close(); + _clients[serverId] = client; + _serverStatus[serverId] = true; + onServerStatus?.call(serverId, true); + connected++; + } catch (e, stackTrace) { + appLogger.e('Failed to connect ${server.name}', error: e, stackTrace: stackTrace); + _serverStatus[serverId] = false; + onServerStatus?.call(serverId, false); + } + }); + + await Future.wait(futures); + _statusController.add(Map.from(_serverStatus)); + if (connected > 0 && _connectivitySubscription == null) { + _startNetworkMonitoring(); + } + return connected; + } + + /// Apply a freshly-fetched [PlexAccountConnection] to the manager, + /// rotating per-server access tokens in place when possible. + /// + /// Used by [ActiveProfileBinder] on profile switch: after Plex hands us + /// the new home-user-scoped per-server tokens, we swap the [PlexConfig] + /// on existing healthy [PlexClient]s instead of tearing them down and + /// reconnecting. Auth-error clients can also be reused because the failure + /// was the old token; other offline servers fall through to the standard + /// [_createClientForServer] path so they get a fresh handshake. + /// + /// Returns the [clientIdentifier]s that ended up actually bound (token + /// reused or freshly connected). Failed servers are excluded so the + /// caller's visibility filter doesn't surface unreachable servers. + Future> refreshTokensForProfile( + PlexAccountConnection connection, { + Duration timeout = MediaServerTimeouts.perServerConnect, + }) async { + if (connection.servers.isEmpty) return const {}; + final bound = {}; + final futures = connection.servers.map((server) async { + final serverId = server.clientIdentifier; + _clientIdByServer[serverId] = connection.clientIdentifier; + _plexServers[serverId] = server; + final existing = _clients[serverId]; + if (existing is PlexClient && ((_serverStatus[serverId] ?? false) || _authErrorServers.contains(serverId))) { + // Rotate the X-Plex-Token in-place so the server treats requests + // as the new user. `applyTokenUpdate` updates both config and + // _http.defaultHeaders — leaving headers stale would silently + // keep authenticating as the previous user. + await existing.applyTokenUpdate(server.accessToken); + _authErrorServers.remove(serverId); + _serverStatus[serverId] = true; + bound.add(serverId); + return; + } + try { + final client = await _createClientForServer( + server: server, + clientIdentifier: connection.clientIdentifier, + ).namedTimeout(timeout, operation: 'connect to ${server.name}'); + _clients[serverId]?.close(); + _clients[serverId] = client; + _serverStatus[serverId] = true; + _authErrorServers.remove(serverId); + bound.add(serverId); + } catch (e, stackTrace) { + appLogger.e('refreshTokensForProfile: failed to connect ${server.name}', error: e, stackTrace: stackTrace); + _serverStatus[serverId] = false; + } + }); + await Future.wait(futures); + _statusController.add(Map.from(_serverStatus)); + if (bound.isNotEmpty && _connectivitySubscription == null) { + _startNetworkMonitoring(); + } + return bound; + } + + /// Tear down all servers belonging to the given Plex account. Called when + /// the user removes the account from the Connections screen. Idempotent — + /// servers already gone are silently skipped. + void removePlexAccount(PlexAccountConnection connection) { + for (final server in connection.servers) { + final id = server.clientIdentifier; + _clients.remove(id)?.close(); + _plexServers.remove(id); + _serverStatus.remove(id); + _authErrorServers.remove(id); + _clientIdByServer.remove(id); + } + _statusController.add(Map.from(_serverStatus)); + } + + /// Add a Jellyfin server backed by an authenticated [JellyfinConnection]. + /// Returns true on success. + /// + /// Jellyfin clients aren't part of the Plex connection-racing flow — they + /// have a single configured base URL — so they bypass the + /// [_createClientForServer] / [findBestWorkingConnection] logic. + /// + /// Two users on the same Jellyfin 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). + Future addJellyfinConnection(JellyfinConnection connection) async { + try { + final client = await JellyfinClient.create(connection); + // Admin status can change server-side; re-broadcast and persist so + // admin-gated UI survives app restarts without requiring re-auth. + _wireJellyfinConnectionUpdates(client); + final compoundId = connection.id; + final machineId = connection.serverMachineId; + + // Replace any prior client for this exact compound id (re-add of the + // same user — e.g., token refresh or settings re-add). + _jellyfinByCompoundId[compoundId]?.close(); + _jellyfinByCompoundId[compoundId] = client; + + // Bind this user as the active client for its machine. A previously + // active client for a *different* compound id stays alive in + // [_jellyfinByCompoundId] so a future profile switch can re-bind it. + _clients[machineId] = client; + _activeJellyfinMachine[machineId] = compoundId; + + final health = await client.checkHealth(); + final healthy = health == HealthStatus.online; + _jellyfinHealthByCompoundId[compoundId] = health; + _applyHealth(machineId, health); + + appLogger.i('Added Jellyfin server: ${connection.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); + return false; + } + } + + void _wireJellyfinConnectionUpdates(JellyfinClient client) { + client.onConnectionUpdated = (updated) async { + if (_jellyfinByCompoundId[updated.id] != client) { + appLogger.d('Ignoring stale Jellyfin connection update for ${updated.serverName}'); + return; + } + final persist = onJellyfinConnectionUpdated; + if (persist != null) { + try { + await Future.sync(() => persist(updated)); + } catch (e, st) { + appLogger.w('Failed to persist Jellyfin connection update', error: e, stackTrace: st); + } + } + _statusController.add(Map.from(_serverStatus)); + }; + } + + /// Look up a tracked Jellyfin client by its compound id + /// (`{serverMachineId}/{userId}`). Returns `null` if no Jellyfin + /// connection with that id has been added. Useful for callers that need + /// the *specific* user's client, not whichever is currently active for + /// the machine. + JellyfinClient? getJellyfinClientByCompoundId(String compoundId) => _jellyfinByCompoundId[compoundId]; + + /// Tear down a specific Jellyfin user's client. If it was the active one + /// for its machine, the machine slot is cleared. + void removeJellyfinConnection(JellyfinConnection connection) { + final compoundId = connection.id; + final machineId = connection.serverMachineId; + final client = _jellyfinByCompoundId.remove(compoundId); + _jellyfinHealthByCompoundId.remove(compoundId); + client?.close(); + if (_activeJellyfinMachine[machineId] == compoundId) { + _activeJellyfinMachine.remove(machineId); + _clients.remove(machineId); + _serverStatus.remove(machineId); + _authErrorServers.remove(machineId); + _statusController.add(Map.from(_serverStatus)); + } + } + + /// Update server status (used for health monitoring). + /// + /// Clears the auth-error flag — callers that observed an auth failure + /// should use [_applyHealth] instead. void updateServerStatus(String serverId, bool isOnline) { - if (_serverStatus[serverId] != isOnline) { + final prevOnline = _serverStatus[serverId]; + final hadAuthError = _authErrorServers.remove(serverId); + if (prevOnline != isOnline || hadAuthError) { _serverStatus[serverId] = isOnline; _statusController.add(Map.from(_serverStatus)); appLogger.d('Server $serverId status changed to: $isOnline'); } } - /// Test connection health for all servers. - /// Uses [PlexClient.isHealthy] which checks for HTTP 200, so servers with - /// invalid tokens (401) are correctly reported as offline. + /// Apply a health-probe outcome to both online state and auth-error + /// tracking. Used by the manager's own health checks; external callers + /// without an auth-distinct signal should use [updateServerStatus]. + void _applyHealth(String serverId, HealthStatus status) { + final isOnline = status == HealthStatus.online; + final isAuthError = status == HealthStatus.authError; + final prevOnline = _serverStatus[serverId]; + final hadAuthError = _authErrorServers.contains(serverId); + + _serverStatus[serverId] = isOnline; + if (isAuthError) { + _authErrorServers.add(serverId); + } else { + _authErrorServers.remove(serverId); + } + + final changed = prevOnline != isOnline || hadAuthError != isAuthError; + if (changed) { + _statusController.add(Map.from(_serverStatus)); + if (isAuthError) { + appLogger.w('Server $serverId auth rejected — token expired or revoked'); + } else { + appLogger.d('Server $serverId status changed to: $isOnline'); + } + } + } + + /// 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. Future checkServerHealth() async { // Coalesce concurrent calls — return the in-flight future if one exists if (_activeHealthCheck != null) return _activeHealthCheck!; @@ -360,11 +599,20 @@ class MultiServerManager { final healthChecks = _clients.entries.map((entry) async { final serverId = entry.key; final client = entry.value; + final expectedJellyfinCompoundId = client is JellyfinClient ? client.connection.id : null; - final healthy = await client.isHealthy(); - updateServerStatus(serverId, healthy); - if (!healthy) { - appLogger.w('Server $serverId health check failed'); + final status = await client.checkHealth(); + if (client is JellyfinClient) { + final compoundId = expectedJellyfinCompoundId ?? client.connection.id; + _jellyfinHealthByCompoundId[compoundId] = status; + if (_activeJellyfinMachine[serverId] != compoundId) { + appLogger.d('Ignoring stale Jellyfin health result for ${client.connection.serverName}'); + return; + } + } + _applyHealth(serverId, status); + if (status != HealthStatus.online) { + appLogger.w('Server $serverId health check failed: ${status.name}'); } }); @@ -372,7 +620,7 @@ class MultiServerManager { } /// Start monitoring network connectivity for all servers - void startNetworkMonitoring() { + void _startNetworkMonitoring() { if (_connectivitySubscription != null) { appLogger.d('Network monitoring already active'); return; @@ -400,7 +648,7 @@ class MultiServerManager { error: { 'status': status.name, 'interfaces': results.map((r) => r.name).toList(), - 'serverCount': _servers.length, + 'serverCount': _plexServers.length, }, ); @@ -419,7 +667,7 @@ class MultiServerManager { } /// Stop monitoring network connectivity - void stopNetworkMonitoring() { + void _stopNetworkMonitoring() { _connectivitySubscription?.cancel(); _connectivitySubscription = null; _connectivityDebounce?.cancel(); @@ -429,7 +677,7 @@ class MultiServerManager { /// Re-optimize all connected servers and attempt reconnection for offline ones void _reoptimizeAllServers({required String reason}) { - for (final entry in _servers.entries) { + for (final entry in _plexServers.entries) { final serverId = entry.key; final server = entry.value; @@ -452,12 +700,36 @@ class MultiServerManager { }); } } + + // Jellyfin has no endpoint-racing — only offline servers need a reprobe. + // Online ones are left alone; checkServerHealth runs on the same tick. + for (final entry in _activeJellyfinMachine.entries) { + final serverId = entry.key; + if (_activeOptimizations.containsKey(serverId)) continue; + if (isServerOnline(serverId)) continue; + + final client = _jellyfinByCompoundId[entry.value]; + if (client == null) continue; + + _activeOptimizations[serverId] = _reconnectJellyfinServer(serverId, client).whenComplete(() { + _activeOptimizations.remove(serverId); + }); + } } - /// Re-optimize connection for a specific server + /// Re-optimize connection for a specific server. + /// + /// Today this only runs against Plex servers — the connection-racing logic + /// is built around [PlexServer.findBestWorkingConnection]. Non-Plex + /// clients short-circuit until a backend-agnostic equivalent lands. Future _reoptimizeServer({required String serverId, required PlexServer server, required String reason}) async { final storage = await StorageService.getInstance(); - final client = _clients[serverId]; + final raw = _clients[serverId]; + final client = raw is PlexClient ? raw : null; + if (raw != null && client == null) { + // Non-Plex client registered for this serverId — no Plex-style optimizer to run. + return; + } final cachedEndpoint = storage.getServerEndpoint(serverId); try { @@ -465,7 +737,7 @@ class MultiServerManager { await for (final connection in server.findBestWorkingConnection( preferredUri: cachedEndpoint, - clientIdentifier: _clientIdentifier, + clientIdentifier: _resolveClientIdentifier(serverId), )) { final newUrl = connection.uri; @@ -490,7 +762,7 @@ class MultiServerManager { /// Attempt full reconnection for a single offline server Future _reconnectServer(String serverId, PlexServer server) async { - final clientId = _clientIdentifier; + final clientId = _resolveClientIdentifier(serverId); if (clientId == null) { appLogger.w('Cannot reconnect ${server.name}: no client identifier cached'); return; @@ -510,6 +782,35 @@ class MultiServerManager { } } + /// Attempt reconnection for a single offline Jellyfin 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 + /// entry. + Future _reconnectJellyfinServer(String machineId, JellyfinClient client) async { + final expectedCompoundId = client.connection.id; + try { + appLogger.d('Attempting reconnection for Jellyfin server ${client.connection.serverName}'); + final status = await client.checkHealth(); + _jellyfinHealthByCompoundId[expectedCompoundId] = status; + if (_activeJellyfinMachine[machineId] != expectedCompoundId) { + appLogger.d('Ignoring stale Jellyfin reconnection result for ${client.connection.serverName}'); + return; + } + _applyHealth(machineId, status); + if (status == HealthStatus.online) { + appLogger.i('Successfully reconnected to ${client.connection.serverName}'); + } else { + appLogger.d('Reconnection probe for ${client.connection.serverName} returned ${status.name}'); + } + } catch (e) { + appLogger.d('Reconnection failed for ${client.connection.serverName}: $e'); + // Leave status as offline — will retry on next trigger + } + } + /// Attempt reconnection for all offline servers. /// /// When [forceRediscovery] is true, the cached endpoint is cleared before @@ -545,23 +846,44 @@ class MultiServerManager { } final futures = offline.map((serverId) { - final server = _servers[serverId]; - if (server == null) return Future.value(); - // Skip if already running if (_activeOptimizations.containsKey(serverId)) return Future.value(); - final future = _reconnectServer(serverId, server) - .timeout( - const Duration(seconds: 15), - onTimeout: () { - appLogger.d('Reconnection timed out for $serverId'); - }, - ) - .whenComplete(() => _activeOptimizations.remove(serverId)); + final server = _plexServers[serverId]; + if (server != null) { + final future = _reconnectServer(serverId, server) + .timeout( + const Duration(seconds: 15), + onTimeout: () { + appLogger.d('Reconnection timed out for $serverId'); + }, + ) + .whenComplete(() => _activeOptimizations.remove(serverId)); - _activeOptimizations[serverId] = future; - return future; + _activeOptimizations[serverId] = future; + return future; + } + + // Jellyfin offline path — no `_plexServers` entry, but the active + // [JellyfinClient] is keyed by machineId in `_clients` and tracked in + // `_activeJellyfinMachine`. Run the same auth probe used at add time. + final activeCompoundId = _activeJellyfinMachine[serverId]; + final jellyfinClient = activeCompoundId != null ? _jellyfinByCompoundId[activeCompoundId] : null; + if (jellyfinClient != null) { + final future = _reconnectJellyfinServer(serverId, jellyfinClient) + .timeout( + const Duration(seconds: 15), + onTimeout: () { + appLogger.d('Jellyfin reconnection timed out for $serverId'); + }, + ) + .whenComplete(() => _activeOptimizations.remove(serverId)); + + _activeOptimizations[serverId] = future; + return future; + } + + return Future.value(); }); await Future.wait(futures); @@ -576,7 +898,7 @@ class MultiServerManager { _reconnectDebounce[serverId] = Timer(const Duration(seconds: 5), () { _reconnectDebounce.remove(serverId); - final server = _servers[serverId]; + final server = _plexServers[serverId]; if (server == null) return; appLogger.i('All endpoints exhausted for $serverId, triggering reconnection'); @@ -594,19 +916,30 @@ class MultiServerManager { /// Disconnect all servers void disconnectAll() { appLogger.i('Disconnecting all servers'); - stopNetworkMonitoring(); + _stopNetworkMonitoring(); for (final timer in _reconnectDebounce.values) { timer.cancel(); } _reconnectDebounce.clear(); _activeHealthCheck = null; _activeReconnect = null; + final activeClients = _clients.values.toSet(); for (final client in _clients.values) { client.close(); } + for (final client in _jellyfinByCompoundId.values) { + if (!activeClients.contains(client)) { + client.close(); + } + } _clients.clear(); - _servers.clear(); + _jellyfinByCompoundId.clear(); + _activeJellyfinMachine.clear(); + _jellyfinHealthByCompoundId.clear(); + _plexServers.clear(); _serverStatus.clear(); + _authErrorServers.clear(); + _clientIdByServer.clear(); _activeOptimizations.clear(); _statusController.add({}); } diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 3a453fe9..89fbc71c 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -1,19 +1,26 @@ +import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import '../database/app_database.dart'; -import '../models/plex_metadata.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'; import '../utils/app_logger.dart'; +import '../utils/global_key_utils.dart'; import 'offline_mode_source.dart'; -import '../utils/plex_cache_parser.dart'; import '../utils/watch_state_notifier.dart'; import 'multi_server_manager.dart'; -import 'plex_api_cache.dart'; import 'plex_client.dart'; import 'settings_service.dart'; -/// Service for managing offline watch progress and syncing to Plex servers. +/// 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. /// /// Handles: /// - Queuing progress updates when offline @@ -31,19 +38,28 @@ class OfflineWatchSyncService extends ChangeNotifier { bool _isShutDown = false; DateTime? _lastSyncTime; bool _hasPerformedStartupSync = false; + String? _activeProfileId; + int? _availableProfileCount; + final Set _legacyWatchActionsAdoptedForProfiles = {}; /// Callback to refresh download provider metadata after sync VoidCallback? onWatchStatesRefreshed; /// Get watched threshold for a server. Cascades: - /// 1. Online client's fetched server prefs - /// 2. Cached value in SettingsService - /// 3. Default 90% + /// 1. Plex's fetched server prefs (`/:/prefs`) + /// 2. Jellyfin's fixed [MediaServerClient.watchedThreshold] (0.9) + /// 3. Cached value in SettingsService + /// 4. Default 90% double getWatchedThreshold(String serverId) { final client = _serverManager.getClient(serverId); - if (client != null && client.serverPrefs.isNotEmpty) { + if (client is PlexClient && client.serverPrefs.isNotEmpty) { return client.watchedThresholdPercent / 100.0; } + 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. + return client.watchedThreshold; + } final cached = SettingsService.instanceOrNull?.read(SettingsService.watchedThresholdPref(serverId)) ?? 90; return cached / 100.0; } @@ -58,7 +74,9 @@ class OfflineWatchSyncService extends ChangeNotifier { return const Duration(minutes: 2); } - /// Maximum sync attempts before giving up on an item + /// Maximum sync attempts before suppressing additional server-write retries. + /// Queued actions are retained so a transient outage or server bug never + /// silently drops local watch progress. static const int maxSyncAttempts = 5; OfflineWatchSyncService({required AppDatabase database, required MultiServerManager serverManager}) @@ -68,6 +86,34 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Whether a sync is currently in progress bool get isSyncing => _isSyncing; + void setActiveProfileId(String? profileId, {int? availableProfileCount}) { + _activeProfileId = profileId; + _availableProfileCount = availableProfileCount; + if (profileId != null && profileId.isNotEmpty) { + unawaited(_adoptLegacyWatchActionsForProfile(profileId, availableProfileCount: availableProfileCount)); + } + } + + Future _adoptLegacyWatchActionsForProfile(String profileId, {int? availableProfileCount}) async { + if (profileId.isEmpty || !_legacyWatchActionsAdoptedForProfiles.add(profileId)) return; + if (availableProfileCount != 1) { + _legacyWatchActionsAdoptedForProfiles.remove(profileId); + return; + } + try { + await _database.adoptLegacyOfflineWatchActionsForProfile(profileId); + } catch (e, st) { + _legacyWatchActionsAdoptedForProfiles.remove(profileId); + appLogger.w('Failed to adopt legacy offline watch actions for $profileId', error: e, stackTrace: st); + } + } + + Future _adoptLegacyWatchActionsForActiveProfile() async { + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) return; + await _adoptLegacyWatchActionsForProfile(profileId, availableProfileCount: _availableProfileCount); + } + /// Start monitoring for connectivity changes to auto-sync void startConnectivityMonitoring(OfflineModeSource source) { // Remove previous listener if any @@ -164,51 +210,67 @@ class OfflineWatchSyncService extends ChangeNotifier { /// /// This is called during offline playback to track the watch position. /// If progress exceeds the server's threshold, shouldMarkWatched is set to true. - Future queueProgressUpdate({ + /// + /// The `ratingKey:` parameter on the underlying `_database` calls is + /// preserved as the on-disk column name; the in-memory parameter renamed + /// here is just the API-level identifier. + Future queueProgressUpdate({ required String serverId, - required String ratingKey, + required String itemId, required int viewOffset, required int duration, }) async { final shouldMarkWatched = isWatchedByProgress(viewOffset, duration, serverId: serverId); + final clientScopeId = await _clientScopeIdForItem(serverId, itemId); await _database.upsertProgressAction( + profileId: _activeProfileId, serverId: serverId, - ratingKey: ratingKey, + clientScopeId: clientScopeId, + ratingKey: itemId, viewOffset: viewOffset, duration: duration, shouldMarkWatched: shouldMarkWatched, ); appLogger.d( - 'Queued offline progress: $serverId:$ratingKey at ${(viewOffset / 1000).toStringAsFixed(0)}s / ${(duration / 1000).toStringAsFixed(0)}s (${((viewOffset / duration) * 100).toStringAsFixed(1)}%)', + 'Queued offline progress: $serverId:$itemId at ${(viewOffset / 1000).toStringAsFixed(0)}s / ${(duration / 1000).toStringAsFixed(0)}s (${((viewOffset / duration) * 100).toStringAsFixed(1)}%)', ); notifyListeners(); + return clientScopeId; } /// Queue a manual "mark as watched" action. /// /// Removes any conflicting actions for the same item. - Future queueMarkWatched({required String serverId, required String ratingKey}) => - _queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: OfflineActionType.watched.name); + Future queueMarkWatched({required String serverId, required String itemId}) => + _queueWatchStatusAction(serverId: serverId, itemId: itemId, actionType: OfflineActionType.watched.id); /// Queue a manual "mark as unwatched" action. /// /// Removes any conflicting actions for the same item. - Future queueMarkUnwatched({required String serverId, required String ratingKey}) => - _queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: OfflineActionType.unwatched.name); + Future queueMarkUnwatched({required String serverId, required String itemId}) => + _queueWatchStatusAction(serverId: serverId, itemId: itemId, actionType: OfflineActionType.unwatched.id); /// Internal helper to queue watch/unwatch actions. - Future _queueWatchStatusAction({ + Future _queueWatchStatusAction({ required String serverId, - required String ratingKey, + required String itemId, required String actionType, }) async { - await _database.insertWatchAction(serverId: serverId, ratingKey: ratingKey, actionType: actionType); + final clientScopeId = await _clientScopeIdForItem(serverId, itemId); + await _database.insertWatchAction( + profileId: _activeProfileId, + serverId: serverId, + clientScopeId: clientScopeId, + ratingKey: itemId, + actionType: actionType, + ); - appLogger.d('Queued offline mark $actionType: $serverId:$ratingKey'); + appLogger.d('Queued offline mark $actionType: $serverId:$itemId'); notifyListeners(); + return clientScopeId; } /// Check if an item should be considered watched based on progress percentage. @@ -224,8 +286,17 @@ class OfflineWatchSyncService extends ChangeNotifier { /// - `true` if item was marked as watched locally or progress >= server threshold /// - `false` if item was marked as unwatched locally /// - `null` if no local action exists (use cached server data) - Future getLocalWatchStatus(String globalKey) async { - final action = await _database.getLatestWatchAction(globalKey); + Future getLocalWatchStatus(String globalKey, {String? clientScopeId}) async { + await _adoptLegacyWatchActionsForActiveProfile(); + final expectedScope = clientScopeId ?? _activeClientScopeIdForGlobalKey(globalKey); + final profileId = _activeProfileId; + final action = await _database.getLatestWatchAction( + globalKey, + profileId: profileId, + filterProfile: profileId != null, + clientScopeId: expectedScope, + filterClientScope: expectedScope != null, + ); if (action == null) return null; switch (action.actionType) { @@ -245,10 +316,21 @@ class OfflineWatchSyncService extends ChangeNotifier { /// /// Returns a map of globalKey -> watch status (true/false/null). /// More efficient than calling getLocalWatchStatus multiple times. - Future> getLocalWatchStatusesBatched(Set globalKeys) async { + Future> getLocalWatchStatusesBatched( + Set globalKeys, { + Map? clientScopeIdsByGlobalKey, + }) async { if (globalKeys.isEmpty) return {}; + await _adoptLegacyWatchActionsForActiveProfile(); - final actions = await _database.getLatestWatchActionsForKeys(globalKeys); + final scopes = clientScopeIdsByGlobalKey ?? _activeClientScopeIdsForGlobalKeys(globalKeys); + final profileId = _activeProfileId; + final actions = await _database.getLatestWatchActionsForKeys( + globalKeys, + profileId: profileId, + filterProfile: profileId != null, + clientScopeIdsByGlobalKey: scopes, + ); final result = {}; for (final key in globalKeys) { @@ -276,12 +358,21 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Get the local view offset (resume position) for a media item. /// /// Returns the locally tracked position, or null if none exists. - Future getLocalViewOffset(String globalKey) async { - final action = await _database.getLatestWatchAction(globalKey); + Future getLocalViewOffset(String globalKey, {String? clientScopeId}) async { + await _adoptLegacyWatchActionsForActiveProfile(); + final expectedScope = clientScopeId ?? _activeClientScopeIdForGlobalKey(globalKey); + final profileId = _activeProfileId; + final action = await _database.getLatestWatchAction( + globalKey, + profileId: profileId, + filterProfile: profileId != null, + clientScopeId: expectedScope, + filterClientScope: expectedScope != null, + ); if (action == null) return null; // Only return offset for progress actions - if (action.actionType == OfflineActionType.progress.name) { + if (action.actionType == OfflineActionType.progress.id) { return action.viewOffset; } @@ -289,8 +380,12 @@ class OfflineWatchSyncService extends ChangeNotifier { } /// Get count of pending sync items. - Future getPendingSyncCount() { - return _database.getPendingSyncCount(); + Future getPendingSyncCount() async { + await _adoptLegacyWatchActionsForActiveProfile(); + final profileId = _activeProfileId; + return profileId == null || profileId.isEmpty + ? _database.getPendingSyncCount() + : _database.getPendingSyncCount(profileId: profileId); } /// Sync all pending items to their respective servers. @@ -307,7 +402,11 @@ class OfflineWatchSyncService extends ChangeNotifier { notifyListeners(); try { - final pendingActions = await _database.getPendingWatchActions(); + await _adoptLegacyWatchActionsForActiveProfile(); + final profileId = _activeProfileId; + final pendingActions = profileId == null || profileId.isEmpty + ? await _database.getPendingWatchActions() + : await _database.getPendingWatchActions(profileId: profileId); if (pendingActions.isEmpty) { appLogger.d('No pending watch actions to sync'); @@ -316,58 +415,28 @@ class OfflineWatchSyncService extends ChangeNotifier { appLogger.i('Syncing ${pendingActions.length} pending watch actions'); - // First pass: handle retry limit exceeded and group by server - final actionsByServer = >{}; - for (final action in pendingActions) { - // Delete items that have exceeded retry limit if (action.syncAttempts >= maxSyncAttempts) { appLogger.w( - 'Deleting action ${action.id} - exceeded retry limit ' + 'Skipping action ${action.id} - exceeded retry limit ' '(${action.syncAttempts} attempts). Last error: ${action.lastError}', ); - await _database.deleteWatchAction(action.id); continue; } - // Check if server still exists - if (_serverManager.getServer(action.serverId) == null) { - appLogger.w('Deleting action ${action.id} - server ${action.serverId} no longer exists'); - await _database.deleteWatchAction(action.id); - continue; - } - - actionsByServer.putIfAbsent(action.serverId, () => []).add(action); - } - - // Second pass: process each server's actions with single connectivity check - for (final entry in actionsByServer.entries) { - final serverId = entry.key; - final actions = entry.value; - - await _withOnlineClient(serverId, (client) async { - for (final action in actions) { - try { - await _syncAction(client, action); - // Success - delete the action from queue - await _database.deleteWatchAction(action.id); - appLogger.d('Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}'); - } catch (e) { - appLogger.w('Failed to sync action ${action.id}: $e'); - await _database.updateSyncAttempt(action.id, e.toString()); - } + final synced = await _withOnlineClientForAction(action, (client) async { + try { + await _syncAction(client, action); + await _database.deleteWatchAction(action.id); + appLogger.d('Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}'); + } catch (e) { + appLogger.w('Failed to sync action ${action.id}: $e'); + await _database.updateSyncAttempt(action.id, e.toString()); } }); - - // If _withOnlineClient returned null (server offline), mark actions for retry - if (_serverManager.getClient(serverId) == null || !_serverManager.isServerOnline(serverId)) { - for (final action in actions) { - // Only update if we haven't already processed it - final stillPending = await _database.getLatestWatchAction('${action.serverId}:${action.ratingKey}'); - if (stillPending != null && stillPending.id == action.id) { - await _database.updateSyncAttempt(action.id, 'Server not available'); - } - } + if (!synced) { + appLogger.d('Keeping action ${action.id} queued until server is available'); + continue; } } } finally { @@ -376,19 +445,102 @@ class OfflineWatchSyncService extends ChangeNotifier { } } - /// Execute a callback with an online client for the given server. - /// - /// Returns null if no client available or server is offline. - /// The callback receives the PlexClient and should return the result. - Future _withOnlineClient(String serverId, Future Function(PlexClient client) callback) async { + Future _clientScopeIdForItem(String serverId, String itemId) async { + // A downloaded row's clientScopeId is a cache/source hint, not an owner. + // Offline watch actions are user-owned, so a new local action follows the + // currently active scoped Jellyfin client. Once queued, _clientForAction + // replays that exact scope even if the active user changes later. final client = _serverManager.getClient(serverId); + if (client != null) { + final scopeId = client.cacheServerId; + if (scopeId != serverId) return scopeId; + } + final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); + final downloadedScopeId = download?.clientScopeId; + if (downloadedScopeId != null && downloadedScopeId.isNotEmpty) return downloadedScopeId; + return null; + } + + Future<({MediaServerClient client, String? clientScopeId})?> _clientForAction(OfflineWatchProgressItem action) async { + final scopeId = action.clientScopeId; + if (scopeId != null && scopeId.isNotEmpty) { + final scoped = _serverManager.getJellyfinClientByCompoundId(scopeId); + if (scoped != null) return (client: scoped, clientScopeId: scopeId); + } + final client = _serverManager.getClient(action.serverId); + if (client == null) return null; + if (client.backend == MediaBackend.jellyfin && client.cacheServerId != action.serverId) { + appLogger.w( + 'Refusing to sync unscoped Jellyfin action ${action.id} for ${action.serverId}:${action.ratingKey}; ' + 'no queued client scope is available', + ); + return null; + } + return (client: client, clientScopeId: action.clientScopeId); + } + + Future _withOnlineClientForAction( + OfflineWatchProgressItem action, + Future Function(MediaServerClient client) callback, + ) async { + final resolved = await _clientForAction(action); + if (resolved == null) { + appLogger.d('No client for server ${action.serverId} scope ${action.clientScopeId}, skipping'); + return false; + } + + if (!_serverManager.isClientOnline(action.serverId, clientScopeId: resolved.clientScopeId)) { + appLogger.d('Server ${action.serverId} scope ${resolved.clientScopeId} is offline, skipping'); + return false; + } + + await callback(resolved.client); + return true; + } + + Map _activeClientScopeIdsForGlobalKeys(Set globalKeys) { + final scopes = {}; + for (final globalKey in globalKeys) { + final scopeId = _activeClientScopeIdForGlobalKey(globalKey); + if (scopeId != null) scopes[globalKey] = scopeId; + } + return scopes; + } + + String? _activeClientScopeIdForGlobalKey(String globalKey) { + final parsed = parseGlobalKey(globalKey); + if (parsed == null) return null; + return _activeClientScopeIdForServer(parsed.serverId); + } + + String? _activeClientScopeIdForServer(String serverId) { + final client = _serverManager.getClient(serverId); + if (client == null) return null; + final scopeId = client.cacheServerId; + return scopeId == serverId ? null : scopeId; + } + + Future _clientForDownloadScope(String serverId, String? clientScopeId) async { + if (clientScopeId != null && clientScopeId.isNotEmpty) { + final scoped = _serverManager.getJellyfinClientByCompoundId(clientScopeId); + if (scoped != null) return scoped; + } + return _serverManager.getClient(serverId); + } + + Future _withOnlineClientForDownloadScope( + String serverId, + String? clientScopeId, + Future Function(MediaServerClient client) callback, + ) async { + final client = await _clientForDownloadScope(serverId, clientScopeId); if (client == null) { - appLogger.d('No client for server $serverId, skipping'); + appLogger.d('No client for server $serverId scope $clientScopeId, skipping'); return null; } - if (!_serverManager.isServerOnline(serverId)) { - appLogger.d('Server $serverId is offline, skipping'); + if (!_serverManager.isClientOnline(serverId, clientScopeId: clientScopeId)) { + appLogger.d('Server $serverId scope $clientScopeId is offline, skipping'); return null; } @@ -396,47 +548,66 @@ class OfflineWatchSyncService extends ChangeNotifier { } /// Sync a single action to the server. - Future _syncAction(PlexClient client, OfflineWatchProgressItem action) async { + /// + /// Uses the neutral [MediaServerClient] surface so Jellyfin's + /// `/UserPlayedItems/{id}` and `/Sessions/Playing*` endpoints receive + /// the same queued state Plex's `/:/scrobble` and `/:/timeline` do. + Future _syncAction(MediaServerClient client, OfflineWatchProgressItem action) async { // Fetch metadata so the WatchStateNotifier emission inside - // markAsWatched/markAsUnwatched carries enough context for downstream + // markWatched/markUnwatched carries enough context for downstream // listeners (UI invalidation, Trakt sync). Best-effort: a missed metadata - // fetch only suppresses the event, not the Plex API call. + // fetch falls back to a minimal MediaItem — the network call still goes + // through, just without a rich event payload. final emitsEvent = - action.actionType == OfflineActionType.watched.name || - action.actionType == OfflineActionType.unwatched.name || - (action.actionType == OfflineActionType.progress.name && action.shouldMarkWatched); - PlexMetadata? metadata; + action.actionType == OfflineActionType.watched.id || + action.actionType == OfflineActionType.unwatched.id || + (action.actionType == OfflineActionType.progress.id && action.shouldMarkWatched); + MediaItem? item; if (emitsEvent) { try { - metadata = await client.getMetadataWithImages(action.ratingKey); + item = await client.fetchItem(action.ratingKey); } catch (_) { - // Proceed without metadata — Plex still gets the call. + // Fall through to the synthetic item below. } } + item ??= MediaItem( + id: action.ratingKey, + backend: client.backend, + kind: MediaKind.unknown, + serverId: action.serverId, + ); switch (action.actionType) { case 'watched': - await client.markAsWatched(action.ratingKey, metadata: metadata); + await client.markWatched(item); break; case 'unwatched': - await client.markAsUnwatched(action.ratingKey, metadata: metadata); + await client.markUnwatched(item); break; case 'progress': - // First, update the timeline with current position + // Push the resume position. Jellyfin's `/Sessions/Playing/Stopped` + // ignores events that arrive without an open session row, so we + // bracket with a Started call. Plex's `/:/timeline` collapses both + // into a single row and treats the second as the canonical state. if (action.viewOffset != null && action.duration != null) { - await client.updateProgress( - action.ratingKey, - time: action.viewOffset!, - state: 'stopped', // Use 'stopped' since we're syncing after the fact - duration: action.duration, - ); + final position = Duration(milliseconds: action.viewOffset!); + final duration = Duration(milliseconds: action.duration!); + try { + await client.reportPlaybackStarted(itemId: action.ratingKey, position: position, duration: duration); + } catch (e) { + // Plex sometimes 5xxs the start when nothing follows; treat as + // best-effort and continue to the stop call which is the one + // that actually persists the resume position. + appLogger.d('Offline progress: started call failed (continuing)', error: e); + } + await client.reportPlaybackStopped(itemId: action.ratingKey, position: position, duration: duration); } - // If progress exceeded threshold, also mark as watched + // If progress exceeded threshold, also mark as watched. if (action.shouldMarkWatched) { - await client.markAsWatched(action.ratingKey, metadata: metadata); + await client.markWatched(item); } break; } @@ -446,59 +617,60 @@ class OfflineWatchSyncService extends ChangeNotifier { /// /// Returns the number of episodes synced, or -1 on failure. Future _syncSeasonEpisodes( - PlexClient client, + MediaServerClient client, String serverId, String seasonRatingKey, Set downloadedEpisodeKeys, ) async { try { - final seasonEpisodes = await client.getChildren(seasonRatingKey); + final seasonEpisodes = await client.fetchChildren(seasonRatingKey); int synced = 0; for (final episode in seasonEpisodes) { - if (!downloadedEpisodeKeys.contains(episode.ratingKey)) continue; + if (!downloadedEpisodeKeys.contains(episode.id)) continue; - final cacheKey = '/library/metadata/${episode.ratingKey}'; - final existing = await PlexApiCache.instance.get(serverId, cacheKey); - final existingMeta = PlexCacheParser.extractFirstMetadata(existing); - - if (existingMeta != null) { - // Detect a watched-status flip from another device so listeners - // (UI, Trakt sync) get the change. `viewCount` going from 0 to >0 - // (or back) is the canonical "marked watched" signal. - final wasWatched = ((existingMeta['viewCount'] as num?)?.toInt() ?? 0) > 0; - final isWatched = (episode.viewCount ?? 0) > 0; - - // Only update watch-state fields — preserves Chapter/Marker/Media/etc. - existingMeta['viewCount'] = episode.viewCount; - existingMeta['viewOffset'] = episode.viewOffset; - existingMeta['lastViewedAt'] = episode.lastViewedAt; - existingMeta['viewedLeafCount'] = episode.viewedLeafCount; - await PlexApiCache.instance.put(serverId, cacheKey, { - 'MediaContainer': { - 'Metadata': [existingMeta], - }, - }); + final cacheServerId = client.cacheServerId; + final prior = await client.cache.getMetadata(cacheServerId, episode.id); + final isWatched = (episode.viewCount ?? 0) > 0; + if (prior != null) { + // Existing cached row — patch in the watch-state and rollup + // fields without disturbing Media/Chapter blobs. + final wasWatched = (prior.viewCount ?? 0) > 0; + await client.cache.applyWatchState( + serverId: cacheServerId, + itemId: episode.id, + isWatched: isWatched, + viewOffsetMs: episode.viewOffsetMs, + lastViewedAt: episode.lastViewedAt, + viewedLeafCount: episode.viewedLeafCount, + ); if (wasWatched != isWatched) { - WatchStateNotifier().notifyWatched(metadata: episode, isNowWatched: isWatched); - } - - // Repair corrupted entries (missing Media/Chapter from previous overwrites) - if (existingMeta['Media'] == null) { - try { - await client.getMetadataWithImages(episode.ratingKey); - } catch (e) { - appLogger.d('Cache repair fetch skipped for ${episode.ratingKey}', error: e); - } + WatchStateNotifier().notifyWatched( + item: episode, + isNowWatched: isWatched, + cacheServerId: client.cacheServerId, + ); } } else { - // No existing entry — write what we have - await PlexApiCache.instance.put(serverId, cacheKey, { - 'MediaContainer': { - 'Metadata': [episode.toJson()], - }, - }); + // No cached row yet — populate the canonical row via fetchItem + // (cache-fallback mixin writes the response to cache for free) + // and then stamp the watch snapshot. Transition is unknowable + // without a prior row, so we skip the notify to match the + // previous synthesize-fresh-row behaviour. + try { + await client.fetchItem(episode.id); + await client.cache.applyWatchState( + serverId: cacheServerId, + itemId: episode.id, + isWatched: isWatched, + viewOffsetMs: episode.viewOffsetMs, + lastViewedAt: episode.lastViewedAt, + viewedLeafCount: episode.viewedLeafCount, + ); + } catch (e) { + appLogger.d('Cache populate failed for ${episode.id}', error: e); + } } synced++; } @@ -519,8 +691,24 @@ class OfflineWatchSyncService extends ChangeNotifier { /// instead of one API call per episode. Future syncWatchStatesFromServer() async { try { - // Get all downloaded items from database - final downloadedItems = await _database.getAllDownloadedMetadata(); + final profileId = _activeProfileId; + if (profileId == null || profileId.isEmpty) { + appLogger.d('Skipping watch-state pull — no active profile'); + return; + } + + await _database.adoptLegacyDownloadsForProfile(profileId); + final ownedKeys = await _database.getDownloadOwnerKeysForProfile(profileId); + if (ownedKeys.isEmpty) { + appLogger.d('No active-profile downloads to sync watch states for'); + return; + } + + // Pull watch state only for downloads visible to the active profile. The + // physical rows are shared across profiles, but server watch state is not. + final downloadedItems = (await _database.getAllDownloadedMetadata()) + .where((item) => ownedKeys.contains(item.globalKey)) + .toList(); if (downloadedItems.isEmpty) { appLogger.d('No downloaded items to sync watch states for'); @@ -529,22 +717,25 @@ class OfflineWatchSyncService extends ChangeNotifier { appLogger.i('Syncing watch states from server for ${downloadedItems.length} items'); - // Separate episodes (with season parent) from other items (movies, etc.) - // Structure: serverId -> seasonRatingKey -> Set - final episodesByServerAndSeason = >>{}; - // Structure: serverId -> List - final nonEpisodeItems = >{}; + // Separate episodes (with season parent) from other items (movies, + // etc.), using the active scoped client for user-owned Jellyfin watch + // state. Downloads are shared, but server watch state is per user. + // Structure: (serverId, clientScopeId) -> seasonRatingKey -> Set + final episodesByScopeAndSeason = <({String serverId, String? clientScopeId}), Map>>{}; + // Structure: (serverId, clientScopeId) -> List + final nonEpisodeItems = <({String serverId, String? clientScopeId}), List>{}; for (final item in downloadedItems) { + final scope = (serverId: item.serverId, clientScopeId: _activeClientScopeIdForServer(item.serverId)); if (item.type == 'episode' && item.parentRatingKey != null) { // Group episodes by server and season for batch fetching - episodesByServerAndSeason - .putIfAbsent(item.serverId, () => {}) + episodesByScopeAndSeason + .putIfAbsent(scope, () => {}) .putIfAbsent(item.parentRatingKey!, () => {}) .add(item.ratingKey); } else { // Movies, or episodes without parent (fallback to individual fetch) - nonEpisodeItems.putIfAbsent(item.serverId, () => []).add(item.ratingKey); + nonEpisodeItems.putIfAbsent(scope, () => []).add(item.ratingKey); } } @@ -552,13 +743,13 @@ class OfflineWatchSyncService extends ChangeNotifier { int seasonCount = 0; // Fetch episodes by season (batch) - one API call per season - for (final serverEntry in episodesByServerAndSeason.entries) { - final serverId = serverEntry.key; - final seasonMap = serverEntry.value; + for (final scopeEntry in episodesByScopeAndSeason.entries) { + final scope = scopeEntry.key; + final seasonMap = scopeEntry.value; - await _withOnlineClient(serverId, (client) async { + await _withOnlineClientForDownloadScope(scope.serverId, scope.clientScopeId, (client) async { for (final seasonEntry in seasonMap.entries) { - final result = await _syncSeasonEpisodes(client, serverId, seasonEntry.key, seasonEntry.value); + final result = await _syncSeasonEpisodes(client, scope.serverId, seasonEntry.key, seasonEntry.value); if (result >= 0) { syncedCount += result; seasonCount++; @@ -569,27 +760,29 @@ class OfflineWatchSyncService extends ChangeNotifier { // Fetch non-episode items individually (movies, etc.) for (final entry in nonEpisodeItems.entries) { - final serverId = entry.key; + final scope = entry.key; final ratingKeys = entry.value; - await _withOnlineClient(serverId, (client) async { + await _withOnlineClientForDownloadScope(scope.serverId, scope.clientScopeId, (client) async { for (final ratingKey in ratingKeys) { try { - // Snapshot prior viewCount before the cache-refreshing fetch so we + // Snapshot prior viewCount through the neutral cache so we // can detect a watched-status change from another device. - final cacheKey = '/library/metadata/$ratingKey'; - final priorCached = await PlexApiCache.instance.get(serverId, cacheKey); - final priorMeta = PlexCacheParser.extractFirstMetadata(priorCached); - final wasWatched = ((priorMeta?['viewCount'] as num?)?.toInt() ?? 0) > 0; + final prior = await client.cache.getMetadata(client.cacheServerId, ratingKey); + final wasWatched = (prior?.viewCount ?? 0) > 0; - // getMetadataWithImages already caches the full API response - // (with chapters/markers) via _fetchWithCacheFallback internally - final metadata = await client.getMetadataWithImages(ratingKey); + // fetchItem already caches the full API response (with + // chapters/markers) via the client's internal cache layer. + final metadata = await client.fetchItem(ratingKey); if (metadata != null) { syncedCount++; final isWatched = (metadata.viewCount ?? 0) > 0; if (wasWatched != isWatched) { - WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: isWatched); + WatchStateNotifier().notifyWatched( + item: metadata, + isNowWatched: isWatched, + cacheServerId: client.cacheServerId, + ); } } } catch (e) { diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 0f4883c4..d2da7e73 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -3,73 +3,115 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../models/play_queue_response.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_playlist.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_playlist.dart'; +import '../models/plex/play_queue_response.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; -import '../utils/app_logger.dart'; -import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; import '../i18n/strings.g.dart'; +import 'media_list_playback_launcher.dart'; import 'plex_client.dart'; -/// Result type for play queue operations -sealed class PlayQueueResult { - const PlayQueueResult(); -} +// Re-export the result types so existing imports of this file keep working. +export 'media_list_playback_launcher.dart' show PlayQueueResult, PlayQueueSuccess, PlayQueueEmpty, PlayQueueError; -class PlayQueueSuccess extends PlayQueueResult { - const PlayQueueSuccess(); -} - -class PlayQueueEmpty extends PlayQueueResult { - const PlayQueueEmpty(); -} - -class PlayQueueError extends PlayQueueResult { - final Object error; - const PlayQueueError(this.error); -} - -/// Service to handle play queue creation and navigation. +/// Plex-specific play queue launcher. /// /// Centralizes the common pattern of: -/// 1. Creating a play queue via various methods -/// 2. Setting up PlaybackStateProvider +/// 1. Creating a play queue via Plex's server-side `/playQueues` resource +/// 2. Setting up [PlaybackStateProvider] /// 3. Navigating to the video player /// 4. Handling errors with appropriate feedback -class PlayQueueLauncher { +/// +/// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for +/// the backend-neutral entry point. Plex-only flows +/// ([launchFromPlaylistItem], [launchShuffledShow], [launchFromFolder]) live +/// directly on this class because they have no Jellyfin equivalent. +class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { final BuildContext context; final PlexClient client; final String? serverId; final String? serverName; - PlayQueueLauncher({required this.context, required this.client, this.serverId, this.serverName}); + PlexPlayQueueLauncher({required this.context, required this.client, this.serverId, this.serverName}); - /// Launch playback from a collection or playlist. - Future launchFromCollectionOrPlaylist({ - required dynamic item, // PlexMetadata (collection) or PlexPlaylist - required bool shuffle, - bool showLoadingIndicator = true, - }) async { - final isCollection = item is PlexMetadata; - final isPlaylist = item is PlexPlaylist; - - if (!isCollection && !isPlaylist) { - return PlayQueueError(Exception('Item must be either a collection or playlist')); + /// Resolve the right [PlexClient] for [item]'s server and build a launcher. + /// Falls back to the first available Plex client when [item] doesn't carry + /// a `serverId`. + factory PlexPlayQueueLauncher.forContext(BuildContext context, Object item) { + final String? itemServerId; + final String? itemServerName; + if (item is MediaItem) { + itemServerId = item.serverId; + itemServerName = item.serverName; + } else if (item is MediaPlaylist) { + itemServerId = item.serverId; + itemServerName = item.serverName; + } else { + itemServerId = null; + itemServerName = null; } - return _executeWithLoading( + final provider = Provider.of(context, listen: false); + PlexClient? plexClient; + if (itemServerId != null) { + // Plex-only: server-side `/playQueues` resource has no Jellyfin equivalent. + plexClient = provider.getPlexClientForServer(itemServerId); + } + if (plexClient == null) { + // Fall back to the first online Plex client. + for (final id in provider.onlineServerIds) { + final c = provider.getPlexClientForServer(id); + if (c != null) { + plexClient = c; + break; + } + } + } + if (plexClient == null) { + throw Exception(t.errors.noClientAvailable); + } + return PlexPlayQueueLauncher( + context: context, + client: plexClient, + serverId: itemServerId, + serverName: itemServerName, + ); + } + + /// Launch playback from a collection or playlist. + /// + /// Accepts a [MediaItem] (collection) or [MediaPlaylist]. Typed as [Object] + /// because Dart has no nominal union type. + @override + Future launchFromCollectionOrPlaylist({ + required Object item, + required bool shuffle, + MediaItem? startItem, + bool showLoadingIndicator = true, + }) async { + final facts = MediaListPlaybackLauncher.classifyItem(item); + if (facts == null) { + return PlayQueueError(Exception('Item must be either a collection or playlist')); + } + final ratingKey = facts.id; + final itemServerId = facts.serverId ?? serverId; + final itemServerName = facts.serverName ?? serverName; + + return executeWithLoading( + context: context, showLoading: showLoadingIndicator, - action: t.common.shuffle, + actionLabel: t.common.shuffle, execute: (dismissLoading) async { - final String ratingKey = item.ratingKey; - final String? itemServerId = item.serverId ?? serverId; - final String? itemServerName = item.serverName ?? serverName; - PlayQueueResponse? playQueue; + // Plex's `key` param positions the queue's selected item — passed + // through when the caller wants playback to start at a specific + // entry. Ignored on shuffle (the server picks a random head). + final selectedKey = (!shuffle && startItem != null) ? '/library/metadata/${startItem.id}' : null; - if (isCollection) { + if (facts.isCollection) { // Get machine identifier (fetch if not cached in config) final machineId = client.config.machineIdentifier ?? await client.getMachineIdentifier(); @@ -77,14 +119,20 @@ class PlayQueueLauncher { throw Exception('Could not get server machine identifier'); } - final collectionUri = 'server://$machineId/com.plexapp.plugins.library/library/collections/${item.ratingKey}'; - playQueue = await client.createPlayQueue(uri: collectionUri, type: 'video', shuffle: shuffle ? 1 : 0); + final collectionUri = 'server://$machineId/com.plexapp.plugins.library/library/collections/$ratingKey'; + playQueue = await client.createPlayQueue( + uri: collectionUri, + type: 'video', + shuffle: shuffle ? 1 : 0, + key: selectedKey, + ); } else { // For playlists, use playlistID parameter playQueue = await client.createPlayQueue( - playlistID: int.parse(item.ratingKey), + playlistID: int.parse(ratingKey), type: 'video', shuffle: shuffle ? 1 : 0, + key: selectedKey, ); } @@ -104,6 +152,7 @@ class PlayQueueLauncher { ratingKey: ratingKey, serverId: itemServerId, serverName: itemServerName, + selectedItem: selectedKey != null ? _resolveSelectedMediaItem(playQueue) : null, ); }, ); @@ -111,18 +160,22 @@ class PlayQueueLauncher { /// Launch playback from a playlist starting at a specific item. Future launchFromPlaylistItem({ - required PlexPlaylist playlist, - required PlexMetadata selectedItem, + required MediaPlaylist playlist, + required MediaItem selectedItem, bool showLoadingIndicator = true, }) async { - return _executeWithLoading( + return executeWithLoading( + context: context, showLoading: showLoadingIndicator, - action: t.common.play, + actionLabel: t.common.play, execute: (dismissLoading) async { + // Plex's createPlayQueue takes the metadata `key` (`/library/metadata/{id}`), + // not the bare ratingKey. Construct it from the MediaItem id. + final selectedKey = '/library/metadata/${selectedItem.id}'; final playQueue = await client.createPlayQueue( - playlistID: int.parse(playlist.ratingKey), + playlistID: int.parse(playlist.id), type: 'video', - key: selectedItem.key, + key: selectedKey, ); // Close loading dialog before navigating to the player @@ -130,37 +183,39 @@ class PlayQueueLauncher { return _launchFromQueue( playQueue: playQueue, - ratingKey: playlist.ratingKey, + ratingKey: playlist.id, serverId: serverId, serverName: serverName, - selectedItem: playQueue?.selectedItem, + selectedItem: _resolveSelectedMediaItem(playQueue), ); }, ); } /// Launch shuffled playback for a show or season. - Future launchShuffledShow({required PlexMetadata metadata, bool showLoadingIndicator = true}) async { - final mediaType = metadata.mediaType; + @override + Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async { + final kind = metadata.kind; - if (mediaType != PlexMediaType.show && mediaType != PlexMediaType.season) { + if (kind != MediaKind.show && kind != MediaKind.season) { return PlayQueueError(Exception('Shuffle play only works for shows and seasons')); } - return _executeWithLoading( + return executeWithLoading( + context: context, showLoading: showLoadingIndicator, - action: t.common.shuffle, + actionLabel: t.common.shuffle, execute: (dismissLoading) async { // Determine the rating key for the play queue String showRatingKey; - if (mediaType == PlexMediaType.show) { - showRatingKey = metadata.ratingKey; + if (kind == MediaKind.show) { + showRatingKey = metadata.id; } else { // For seasons, we need the show's rating key - if (metadata.parentRatingKey == null) { + if (metadata.parentId == null) { throw Exception('Season is missing parentRatingKey'); } - showRatingKey = metadata.parentRatingKey!; + showRatingKey = metadata.parentId!; } final playQueue = await client.createShowPlayQueue(showRatingKey: showRatingKey, shuffle: 1); @@ -185,9 +240,10 @@ class PlayQueueLauncher { required bool shuffle, bool showLoadingIndicator = true, }) async { - return _executeWithLoading( + return executeWithLoading( + context: context, showLoading: showLoadingIndicator, - action: shuffle ? t.common.shuffle : t.common.play, + actionLabel: shuffle ? t.common.shuffle : t.common.play, execute: (dismissLoading) async { final folderUri = await client.buildFolderUri(folderKey); @@ -213,7 +269,7 @@ class PlayQueueLauncher { required String ratingKey, String? serverId, String? serverName, - PlexMetadata? selectedItem, + MediaItem? selectedItem, bool copyServerInfo = false, }) async { if (playQueue == null || playQueue.items == null || playQueue.items!.isEmpty) { @@ -224,7 +280,7 @@ class PlayQueueLauncher { // Set up playback state final playbackState = context.read(); - playbackState.setClient(client); + playbackState.setPlayQueueWindowFetcher(client.getPlayQueue); await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey); if (!context.mounted) return const PlayQueueError('Context not mounted'); @@ -242,67 +298,10 @@ class PlayQueueLauncher { return const PlayQueueSuccess(); } +} - /// Execute an action with optional loading indicator and error handling. - Future _executeWithLoading({ - required bool showLoading, - required String action, - required Future Function(Future Function() dismissLoading) execute, - }) async { - BuildContext? loadingDialogContext; - var loadingVisible = false; - - // Show loading indicator - if (showLoading && context.mounted) { - loadingVisible = true; - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - loadingDialogContext = dialogContext; - return const Center(child: CircularProgressIndicator()); - }, - ), - ); - } - - Future dismissLoading() async { - if (!showLoading || !loadingVisible) return; - final dialogContext = loadingDialogContext; - if (dialogContext == null) return; - - // Only dismiss if the dialog is still the current route to avoid - // accidentally popping the player after navigation. - final route = ModalRoute.of(dialogContext); - if (route?.isCurrent ?? false) { - Navigator.of(dialogContext).pop(); - } - - loadingVisible = false; - } - - try { - final result = await execute(dismissLoading); - - // Handle empty queue result - if (result is PlayQueueEmpty && context.mounted) { - showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems); - } - - await dismissLoading(); - return result; - } catch (e) { - appLogger.e('Failed to $action', error: e); - - if (context.mounted) { - showErrorSnackBar(context, t.messages.failedPlayback(action: action, error: e.toString())); - } - - await dismissLoading(); - return PlayQueueError(e); - } finally { - await dismissLoading(); - } - } +/// Pull the selected item from a play queue. The selected item is identified +/// by `playQueueSelectedItemID`; returns null if the queue has no selection. +MediaItem? _resolveSelectedMediaItem(PlayQueueResponse? playQueue) { + return playQueue?.selectedItem; } diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 7e965e53..b9bf2f59 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -1,24 +1,40 @@ -import 'plex_client.dart'; -import '../models/plex_media_info.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_video_playback_data.dart'; -import '../models/download_models.dart'; -import '../models/transcode_quality_preset.dart'; -import '../mpv/mpv.dart'; -import '../utils/app_logger.dart'; -import '../utils/global_key_utils.dart'; -import '../utils/plex_url_helper.dart'; -import '../i18n/strings.g.dart'; -import '../database/app_database.dart'; -import 'download_storage_service.dart'; import 'dart:io'; -/// Service responsible for fetching video playback data from the Plex server +import 'package:path/path.dart' as p; + +import '../database/app_database.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_server_client.dart'; +import '../media/media_source_info.dart'; +import '../models/download_models.dart'; +import '../models/transcode_quality_preset.dart'; +import '../mpv/models.dart'; +import '../utils/app_logger.dart'; +import '../utils/global_key_utils.dart'; +import 'cached_playback_metadata_service.dart'; +import 'download_storage_service.dart'; +import 'playback_initialization_types.dart'; + +// Re-export so existing callers (video_player_screen) can keep importing +// PlaybackException / PlaybackInitializationResult / TranscodeFallbackReason +// via this service file. +export 'playback_initialization_types.dart'; + +/// Coordinates playback initialization across backends and modes. +/// +/// **Online (client + network):** delegates to +/// [MediaServerClient.getPlaybackInitialization] for the per-backend +/// transcode-or-direct decision. +/// +/// **Downloaded/offline:** when [preferOffline] finds a local copy, opens it +/// immediately using per-backend cached [MediaSourceInfo] plus sidecar +/// subtitles. This intentionally avoids any network-first metadata call. class PlaybackInitializationService { - final PlexClient client; + final MediaServerClient? client; final AppDatabase? database; - PlaybackInitializationService({required this.client, this.database}); + PlaybackInitializationService({this.client, this.database}); /// Format a video path as a URL (adds file:// prefix for file paths) String _formatVideoUrl(String path) { @@ -85,276 +101,174 @@ class PlaybackInitializationService { } } - /// Fetch playback data for the given metadata + /// Fetch playback data for the given metadata. /// - /// Returns a PlaybackInitializationResult with video URL and available versions - /// If [preferOffline] is true and offline content is available, uses local file - /// If [playbackData] is provided, skips the network call to fetch it again. - /// When [qualityPreset] is non-original and online, the video URL is built - /// against Plex's transcode start endpoint and all subtitle tracks are - /// sidecar-attached since the transcoded stream carries none. + /// Online path: delegates to [MediaServerClient.getPlaybackInitialization]. + /// + /// Downloaded/offline path: when [preferOffline] finds a downloaded copy, + /// builds from cached [MediaSourceInfo] and local sidecars immediately. Future getPlaybackData({ - required PlexMetadata metadata, + required MediaItem metadata, required int selectedMediaIndex, bool preferOffline = false, - PlexVideoPlaybackData? playbackData, TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original, int? selectedAudioStreamId, String? sessionIdentifier, String? transcodeSessionId, }) async { + final serverId = metadata.serverId ?? client?.serverId; + + String? offlineVideoPath; + if (serverId != null && (preferOffline || client == null) && database != null) { + offlineVideoPath = await getOfflineVideoPath(serverId, metadata.id, mediaIndex: selectedMediaIndex); + } + + // Downloaded playback must not wait on a live server. Cached media info + // preserves track labels where available; the local file is enough to play. + if (offlineVideoPath != null) { + appLogger.d('Using offline playback for ${metadata.id}'); + return _buildOfflineResult( + metadata: metadata, + offlineVideoPath: offlineVideoPath, + selectedMediaIndex: selectedMediaIndex, + ); + } + + if (client == null) throw PlaybackException('No video URL available'); + + PlaybackInitializationResult result; try { - // Check for offline content first if preferOffline is enabled - String? offlineVideoPath; - if (preferOffline && database != null) { - offlineVideoPath = await getOfflineVideoPath( - client.serverId, - metadata.ratingKey, - mediaIndex: selectedMediaIndex, - ); - } - - // If offline video is available, use it - if (offlineVideoPath != null) { - appLogger.d('Using offline playback for ${metadata.ratingKey}'); - - // For offline playback, we still need to fetch media info for subtitles - // but use the local file path for video - try { - final data = - playbackData ?? await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex); - - // Build list of external subtitle tracks - final externalSubtitles = _buildExternalSubtitles(data.mediaInfo); - - // Return result with local file path - return PlaybackInitializationResult( - availableVersions: data.availableVersions, - videoUrl: _formatVideoUrl(offlineVideoPath), - mediaInfo: data.mediaInfo, - externalSubtitles: externalSubtitles, - isOffline: true, - ); - } catch (e) { - // If we can't fetch media info (e.g., no network), use offline-only mode - appLogger.w('Failed to fetch media info for offline video, using offline-only mode', error: e); - return PlaybackInitializationResult( - availableVersions: [], - videoUrl: _formatVideoUrl(offlineVideoPath), - mediaInfo: null, - externalSubtitles: const [], - isOffline: true, - ); - } - } - - // Use pre-parsed data or fall back to network streaming - final data = - playbackData ?? await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex); - - if (!data.hasValidVideoUrl) { - throw PlaybackException(t.messages.fileInfoNotAvailable); - } - - final wantTranscode = !qualityPreset.isOriginal; - if (wantTranscode && sessionIdentifier != null && transcodeSessionId != null) { - final resolvedAudioId = _resolveAudioStreamId(selectedAudioStreamId, data.mediaInfo); - // Note: no `offsetMs` — seeking is handled by the player via the HLS - // manifest, matching Plex Web's behavior. Baking `offset=` into the URL - // makes the server pre-position the transcoder, but the resulting - // segments and mpv's native HLS positioning fight each other, leaving - // the player clock at 0 and desyncing sidecar subtitles. - final result = await client.buildTranscodeStartPath( - ratingKey: metadata.ratingKey, - mediaIndex: selectedMediaIndex, - preset: qualityPreset, + result = await client!.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: metadata, + selectedMediaIndex: selectedMediaIndex, + qualityPreset: qualityPreset, + selectedAudioStreamId: selectedAudioStreamId, sessionIdentifier: sessionIdentifier, transcodeSessionId: transcodeSessionId, - audioStreamId: resolvedAudioId, - ); - - if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) { - final transcodeUrl = '${client.config.baseUrl}${result.startPath}'.withPlexToken(client.config.token); - final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo); - return PlaybackInitializationResult( - availableVersions: data.availableVersions, - videoUrl: transcodeUrl, - mediaInfo: data.mediaInfo, - externalSubtitles: sidecarSubs, - isOffline: false, - isTranscoding: true, - activeAudioStreamId: resolvedAudioId, - ); - } - - // Decision failed or said direct-play only — fall through to direct-play path - // and surface the fallback reason so the UI can notify the user. - final fallbackReason = result.outcome == TranscodeDecisionOutcome.directPlayOnly - ? TranscodeFallbackReason.directPlayOnly - : TranscodeFallbackReason.decisionFailed; - appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}'); - return PlaybackInitializationResult( - availableVersions: data.availableVersions, - videoUrl: data.videoUrl, - mediaInfo: data.mediaInfo, - externalSubtitles: _buildExternalSubtitles(data.mediaInfo), - isOffline: false, - isTranscoding: false, - fallbackReason: fallbackReason, - ); - } - - // Build list of external subtitle tracks - final externalSubtitles = _buildExternalSubtitles(data.mediaInfo); - - // Return result with available versions and video URL - return PlaybackInitializationResult( - availableVersions: data.availableVersions, - videoUrl: data.videoUrl, - mediaInfo: data.mediaInfo, - externalSubtitles: externalSubtitles, - isOffline: false, + ), ); } catch (e) { - if (e is PlaybackException) { - rethrow; + rethrow; + } + + return result; + } + + /// Assemble a pure-offline result: local file + cached media info + sidecar + /// subtitles. Used both when [client] is null and when an online fetch throws. + Future _buildOfflineResult({ + required MediaItem metadata, + required String offlineVideoPath, + required int selectedMediaIndex, + }) async { + MediaSourceInfo? mediaInfo; + try { + final cacheServerId = await _resolveCacheServerId(metadata); + if (cacheServerId != null) { + mediaInfo = await CachedPlaybackMetadataService.fetchMediaSourceInfo( + backend: metadata.backend, + cacheServerId: cacheServerId, + itemId: metadata.id, + mediaIndex: selectedMediaIndex, + ); } - throw PlaybackException(t.messages.errorLoading(error: e.toString())); + } catch (e) { + appLogger.d('Could not load cached media info for offline playback', error: e); + } + + final sidecarSubtitles = await _discoverSidecarSubtitles( + offlineVideoPath, + metadata: metadata, + mediaInfo: mediaInfo, + ); + + return PlaybackInitializationResult( + availableVersions: const [], + videoUrl: _formatVideoUrl(offlineVideoPath), + mediaInfo: mediaInfo, + externalSubtitles: sidecarSubtitles, + isOffline: true, + ); + } + + Future _resolveCacheServerId(MediaItem metadata) async { + final liveClient = client; + if (liveClient != null) return liveClient.cacheServerId; + + final serverId = metadata.serverId; + if (serverId == null) return null; + final db = database; + if (db == null) return serverId; + + try { + final row = await (db.select( + db.downloadedMedia, + )..where((tbl) => tbl.globalKey.equals(buildGlobalKey(serverId, metadata.id)))).getSingleOrNull(); + return row?.clientScopeId ?? serverId; + } catch (_) { + return serverId; } } - /// Pick the audio stream ID to send to the transcoder. Preference order: - /// explicit [explicit] → audio track with `selected == true` → first → null. - int? _resolveAudioStreamId(int? explicit, PlexMediaInfo? info) { - if (explicit != null) return explicit; - if (info == null) return null; - final tracks = info.audioTracks; - if (tracks.isEmpty) return null; - for (final track in tracks) { - if (track.selected) return track.id; - } - return tracks.first.id; - } + /// Find sidecar subtitle files written by the downloader. Plain file videos + /// use `{video}_subs/{trackId}.{ext}` with a legacy `{videoDir}/subtitles/*` + /// fallback. SAF videos are `content://` URIs, so sidecars live in the + /// app-managed subtitle directory keyed by server/item id. + Future> _discoverSidecarSubtitles( + String videoPath, { + required MediaItem metadata, + MediaSourceInfo? mediaInfo, + }) async { + final subtitles = []; + final dirs = videoPath.startsWith('content://') + ? await _safSidecarSubtitleDirs(metadata) + : await _fileSidecarSubtitleDirs(videoPath); - /// Build sidecar SubtitleTracks for ALL source subtitle streams (internal + - /// external) so the player can hot-swap between them when the main stream - /// is transcoded and has no embedded subs. - List _buildTranscodeSidecarSubtitles(PlexMediaInfo? mediaInfo) { - if (mediaInfo == null) return const []; - final token = client.config.token; - if (token == null) { - appLogger.w('No auth token available for transcode sidecar subtitles'); - return const []; - } + for (final subsDir in dirs) { + if (!await subsDir.exists()) continue; + final entities = await subsDir.list().toList(); + for (final entity in entities) { + if (entity is! File) continue; + final fileName = p.basenameWithoutExtension(entity.path); + final trackId = int.tryParse(fileName); - final tracks = []; - for (final sub in mediaInfo.subtitleTracks) { - try { - final url = sub.getTranscodeSidecarUrl(client.config.baseUrl, token); - tracks.add( + final cachedTrack = trackId != null + ? mediaInfo?.subtitleTracks.where((t) => t.id == trackId).firstOrNull + : null; + + subtitles.add( SubtitleTrack.uri( - url, - title: sub.displayTitle ?? sub.language ?? 'Track ${sub.id}', - language: sub.languageCode, + 'file://${entity.path}', + title: cachedTrack?.displayTitle ?? cachedTrack?.language ?? 'Subtitle $fileName', + language: cachedTrack?.languageCode, ), ); - } catch (e) { - appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e); - } - } - return tracks; - } - - /// Build list of external subtitle tracks from media info - List _buildExternalSubtitles(PlexMediaInfo? mediaInfo) { - final externalSubtitles = []; - - if (mediaInfo == null) { - return externalSubtitles; - } - - final externalTracks = mediaInfo.subtitleTracks.where((PlexSubtitleTrack track) => track.isExternal).toList(); - - if (externalTracks.isNotEmpty) { - appLogger.d('Found ${externalTracks.length} external subtitle track(s)'); - } - - for (final plexTrack in externalTracks) { - try { - // Skip if no auth token is available - final token = client.config.token; - if (token == null) { - appLogger.w('No auth token available for external subtitles'); - continue; - } - - final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token); - - // Skip if URL couldn't be constructed - if (url == null) continue; - - externalSubtitles.add( - SubtitleTrack.uri( - url, - title: plexTrack.displayTitle ?? plexTrack.language ?? 'Track ${plexTrack.id}', - language: plexTrack.languageCode, - ), - ); - } catch (e) { - // Silent fallback - log error but continue with other subtitles - appLogger.w('Failed to add external subtitle track ${plexTrack.id}', error: e); } } - return externalSubtitles; + return subtitles; + } + + Future> _fileSidecarSubtitleDirs(String videoPath) async { + final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs'); + final primary = Directory(subsPath); + if (await primary.exists()) return [primary]; + return [Directory(p.join(File(videoPath).parent.path, 'subtitles'))]; + } + + Future> _safSidecarSubtitleDirs(MediaItem metadata) async { + final serverId = metadata.serverId; + if (serverId == null) return const []; + + final storage = DownloadStorageService.instance; + final dirs = []; + if (metadata.isEpisode && metadata.title != null) { + dirs.add(await storage.getEpisodeSubtitlesDirectory(metadata)); + } else if (metadata.isMovie && metadata.title != null) { + dirs.add(await storage.getMovieSubtitlesDirectory(metadata)); + } + dirs.add(await storage.getSubtitlesDirectory(serverId, metadata.id)); + return dirs; } } - -/// Reason the transcode branch fell back to direct play. -enum TranscodeFallbackReason { - /// Plex decision said only direct-play is available. - directPlayOnly, - - /// The decision endpoint errored (HTTP error, code >= 2000, parse failure). - decisionFailed, -} - -/// Result of playback initialization -class PlaybackInitializationResult { - final List availableVersions; - final String? videoUrl; - final PlexMediaInfo? mediaInfo; - final List externalSubtitles; - final bool isOffline; - - /// `true` when [videoUrl] is a Plex transcode start URL. - final bool isTranscoding; - - /// Non-null when a non-original preset was requested but fallback kicked in. - final TranscodeFallbackReason? fallbackReason; - - /// The Plex audio stream ID actually passed to the transcoder (`null` when - /// not transcoding or when no audio stream was selectable). - final int? activeAudioStreamId; - - PlaybackInitializationResult({ - required this.availableVersions, - this.videoUrl, - this.mediaInfo, - this.externalSubtitles = const [], - this.isOffline = false, - this.isTranscoding = false, - this.fallbackReason, - this.activeAudioStreamId, - }); -} - -/// Exception thrown when playback initialization fails -class PlaybackException implements Exception { - final String message; - - PlaybackException(this.message); - - @override - String toString() => message; -} diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart new file mode 100644 index 00000000..801bb3ef --- /dev/null +++ b/lib/services/playback_initialization_types.dart @@ -0,0 +1,98 @@ +import '../media/media_item.dart'; +import '../media/media_source_info.dart'; +import '../media/media_version.dart'; +import '../models/transcode_quality_preset.dart'; +import '../mpv/mpv.dart'; + +/// Inputs for [MediaServerClient.getPlaybackInitialization]. Most fields +/// are backend-specific knobs (transcode preset, audio stream, session ids). +class PlaybackInitializationOptions { + /// The item to play. + final MediaItem metadata; + + /// Picks among multiple `MediaSources[]` versions when an item has them. + final int selectedMediaIndex; + + /// Transcode preset. `original` means direct-play; anything else asks the + /// server to transcode when supported. + final TranscodeQualityPreset qualityPreset; + + /// Audio stream id forwarded to the transcoder. `null` means "let the + /// server pick". + final int? selectedAudioStreamId; + + /// Plex transcode `X-Plex-Session-Identifier`. Required for Plex transcode. + final String? sessionIdentifier; + + /// Plex transcode `playSessionId`. Same as [sessionIdentifier] — required + /// for Plex transcode. + final String? transcodeSessionId; + + const PlaybackInitializationOptions({ + required this.metadata, + required this.selectedMediaIndex, + this.qualityPreset = TranscodeQualityPreset.original, + this.selectedAudioStreamId, + this.sessionIdentifier, + this.transcodeSessionId, + }); +} + +/// Reason the transcode branch fell back to direct play. +enum TranscodeFallbackReason { + /// Plex decision said only direct-play is available. + directPlayOnly, + + /// The decision endpoint errored (HTTP error, code >= 2000, parse failure). + decisionFailed, +} + +/// Result of playback initialization +class PlaybackInitializationResult { + final List availableVersions; + final String? videoUrl; + final MediaSourceInfo? mediaInfo; + final List externalSubtitles; + final bool isOffline; + + /// `true` when [videoUrl] is a Plex transcode start URL. + final bool isTranscoding; + + /// Non-null when a non-original preset was requested but fallback kicked in. + final TranscodeFallbackReason? fallbackReason; + + /// The Plex audio stream ID actually passed to the transcoder (`null` when + /// not transcoding or when no audio stream was selectable). + final int? activeAudioStreamId; + + /// Server playback session ID that must be echoed in progress/stop reports. + /// Jellyfin returns this from `PlaybackInfo` / `TranscodingUrl`. + final String? playSessionId; + + /// Backend playback method value to report with playback progress. Jellyfin + /// expects one of `DirectPlay`, `DirectStream`, or `Transcode`. + final String? playMethod; + + PlaybackInitializationResult({ + required this.availableVersions, + this.videoUrl, + this.mediaInfo, + this.externalSubtitles = const [], + this.isOffline = false, + this.isTranscoding = false, + this.fallbackReason, + this.activeAudioStreamId, + this.playSessionId, + this.playMethod, + }); +} + +/// Exception thrown when playback initialization fails +class PlaybackException implements Exception { + final String message; + + PlaybackException(this.message); + + @override + String toString() => message; +} diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index dfc787ee..dde0ccd4 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -2,25 +2,30 @@ import 'dart:async'; import '../mpv/mpv.dart'; -import 'plex_client.dart'; +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; +import '../media/media_source_info.dart'; import 'offline_watch_sync_service.dart'; -import '../models/plex_metadata.dart'; +import 'playback_report_session.dart'; +import 'settings_service.dart'; +import 'track_selection_service.dart'; import '../utils/app_logger.dart'; import '../utils/watch_state_notifier.dart'; -/// Tracks playback progress and reports it to the Plex server. +/// Tracks playback progress and reports it to the active media server. /// -/// Handles: -/// - Periodic timeline updates during playback (online) or queuing (offline) -/// - Resume position tracking -/// - State change reporting (playing, paused, stopped) -/// - Offline progress queuing for later sync +/// Both Plex and Jellyfin 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. Scrobble fires once the position +/// crosses the client's [watchedThreshold] (per-server pref on Plex, fixed +/// 90% on Jellyfin). class PlaybackProgressTracker { - /// Plex client for online progress updates (null when offline) - final PlexClient? client; + /// Server client for online progress updates (null when offline). + final MediaServerClient? client; /// Metadata of the media being played - final PlexMetadata metadata; + final MediaItem metadata; /// Video player instance final Player player; @@ -31,9 +36,21 @@ class PlaybackProgressTracker { /// Service for queuing offline progress updates final OfflineWatchSyncService? offlineWatchService; + final String? playMethod; + + /// Backend session ID to echo in progress reports. Jellyfin uses this to + /// associate `/Sessions/Playing*` calls with a transcoded playback session. + final String? playSessionId; + + /// Source-level stream metadata for mapping local player track ids back to + /// Jellyfin stream indexes in playback-progress reports. + final MediaSourceInfo? mediaInfo; + /// Timer for periodic progress updates Timer? _progressTimer; + StreamSubscription? _trackSelectionSubscription; + /// Update interval (default: 10 seconds) final Duration updateInterval; @@ -49,15 +66,31 @@ class PlaybackProgressTracker { /// Whether we've already scrobbled (marked as watched) for this playback session. bool _scrobbled = false; + /// Whether the final stopped progress event was already emitted locally. + bool _stopProgressNotified = false; + + final PlaybackReportSession? _reportSession; + PlaybackProgressTracker({ required this.client, required this.metadata, required this.player, this.isOffline = false, this.offlineWatchService, + this.playMethod, + this.playSessionId, + this.mediaInfo, this.updateInterval = const Duration(seconds: 10), }) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'), - assert(isOffline || client != null, 'client is required when isOffline is false'); + assert(isOffline || client != null, 'client is required when isOffline is false'), + _reportSession = isOffline || client == null + ? null + : PlaybackReportSession( + client: client, + itemId: metadata.id, + playSessionId: playSessionId, + playMethod: playMethod, + ); void startTracking() { if (_progressTimer != null) { @@ -65,6 +98,14 @@ class PlaybackProgressTracker { return; } + if (!isOffline) { + _trackSelectionSubscription = player.streams.track.listen((_) { + if (!player.state.isActive && (_reportSession?.isIdle ?? true)) return; + final state = player.state.isActive ? 'playing' : 'paused'; + unawaited(_sendProgress(state)); + }); + } + // Send initial progress immediately (don't wait for first timer tick) if (player.state.isActive) { _sendProgress('playing'); @@ -81,7 +122,7 @@ class PlaybackProgressTracker { } _sendProgress('playing'); } else { - // Send periodic "paused" updates to keep the Plex session alive + // Send periodic "paused" updates to keep the server session alive // (~60s with default 10s interval) _pausedTickCounter++; if (_pausedTickCounter >= 6) { @@ -101,6 +142,8 @@ class PlaybackProgressTracker { void stopTracking() { _progressTimer?.cancel(); _progressTimer = null; + _trackSelectionSubscription?.cancel(); + _trackSelectionSubscription = null; appLogger.d('Stopped progress tracking'); } @@ -147,13 +190,14 @@ class PlaybackProgressTracker { } // Emit watch state event on stop for UI updates across screens. - // Skip if already scrobbled — markAsWatched already emitted a watched event. - if (state == 'stopped' && position.inMilliseconds > 0 && !_scrobbled) { + // Skip if already scrobbled — markWatched already emitted a watched event. + if (state == 'stopped' && position.inMilliseconds > 0 && !_scrobbled && !_stopProgressNotified) { + _stopProgressNotified = true; WatchStateNotifier().notifyProgress( - metadata: metadata, + item: metadata, viewOffset: position.inMilliseconds, duration: duration.inMilliseconds, - watchedThreshold: client != null ? client!.watchedThresholdPercent / 100.0 : 0.9, + watchedThreshold: client?.watchedThreshold ?? 0.9, ); } } catch (e) { @@ -178,36 +222,126 @@ class PlaybackProgressTracker { } } - /// Send progress update to Plex server (online mode) + /// Send progress update to the active server through the unified + /// [MediaServerClient.reportPlayback*] surface. Future _sendOnlineProgress(String state, Duration position, Duration duration) async { - await client!.updateProgress( - metadata.ratingKey, - time: position.inMilliseconds, - state: state, - duration: duration.inMilliseconds, + final c = client; + final session = _reportSession; + if (c == null || session == null) return; + + final accepted = await session.report( + PlaybackReportSnapshot( + state: state, + position: position, + duration: duration, + resolveStreamSelection: state == 'stopped' + ? _currentStreamSelectionForStopped + : _currentStreamSelectionForProgress, + ), ); + if (accepted) { + await _maybeScrobble(c, position, duration); + } + } + + PlaybackStreamSelection _currentStreamSelectionForStopped() { + final info = mediaInfo; + return info == null ? PlaybackStreamSelection.none : PlaybackStreamSelection(mediaSourceId: info.mediaSourceId); + } + + Future _maybeScrobble(MediaServerClient c, Duration position, Duration duration) async { // Explicitly scrobble once progress crosses the watched threshold. - // The Plex server may not auto-mark from timeline updates alone - // (e.g. when playing a local file without an active play session). + // Some servers (Plex with no active play session, Jellyfin always) + // don't auto-mark from progress updates alone. if (!_scrobbled && duration.inMilliseconds > 0) { final percent = position.inMilliseconds / duration.inMilliseconds; - final threshold = client!.watchedThresholdPercent / 100.0; + final threshold = c.watchedThreshold; if (percent >= threshold) { _scrobbled = true; try { - await client!.markAsWatched(metadata.ratingKey, metadata: metadata); + // The neutral markWatched(MediaItem) call emits the watched event + // through WatchStateNotifier itself, so no extra notify here. + await c.markWatched(metadata); appLogger.d( - 'Scrobbled ${metadata.ratingKey} (${(percent * 100).toStringAsFixed(0)}% >= ${client!.watchedThresholdPercent}%)', + 'Scrobbled ${metadata.id} (${(percent * 100).toStringAsFixed(0)}% >= ${(threshold * 100).toStringAsFixed(0)}%)', ); } catch (e) { - appLogger.w('Failed to scrobble ${metadata.ratingKey}', error: e); + appLogger.w('Failed to scrobble ${metadata.id}', error: e); _scrobbled = false; // Retry on next tick } } } } + Future _currentStreamSelectionForProgress() async { + final info = mediaInfo; + if (info == null) { + return PlaybackStreamSelection.none; + } + + if (!await _shouldReportTrackSelections()) { + return PlaybackStreamSelection(mediaSourceId: info.mediaSourceId); + } + + return PlaybackStreamSelection( + mediaSourceId: info.mediaSourceId, + audioStreamIndex: _currentAudioStreamIndex(info), + subtitleStreamIndex: _currentSubtitleStreamIndex(info), + ); + } + + Future _shouldReportTrackSelections() async { + try { + final settings = await SettingsService.getInstance(); + return settings.read(SettingsService.rememberTrackSelections); + } catch (e) { + appLogger.d('Could not read track-selection persistence setting; reporting selected streams', error: e); + return true; + } + } + + int? _currentAudioStreamIndex(MediaSourceInfo info) { + final track = player.state.track.audio; + if (track == null) return null; + + final ordinal = player.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList().indexOf(track); + if (ordinal >= 0 && ordinal < info.audioTracks.length) return info.audioTracks[ordinal].id; + + final matched = findPlexTrackForMpvAudio(track, info.audioTracks, allMpvTracks: player.state.tracks.audio); + if (matched != null) return matched.id; + + final parsedId = int.tryParse(track.id); + if (parsedId != null && info.audioTracks.any((t) => t.id == parsedId)) return parsedId; + + return null; + } + + int? _currentSubtitleStreamIndex(MediaSourceInfo info) { + final track = player.state.track.subtitle; + if (track == null || track.id == 'no') return -1; + + if (track.isExternal && track.uri != null) { + for (final mediaTrack in info.subtitleTracks) { + final key = mediaTrack.key; + if (mediaTrack.isExternal && key != null && track.uri!.contains(key)) { + return mediaTrack.id; + } + } + } + + final ordinal = player.state.tracks.subtitle.where((t) => t.id != 'auto' && t.id != 'no').toList().indexOf(track); + if (ordinal >= 0 && ordinal < info.subtitleTracks.length) return info.subtitleTracks[ordinal].id; + + final matched = findPlexTrackForMpvSubtitle(track, info.subtitleTracks, allMpvTracks: player.state.tracks.subtitle); + if (matched != null) return matched.id; + + final parsedId = int.tryParse(track.id); + if (parsedId != null && info.subtitleTracks.any((t) => t.id == parsedId)) return parsedId; + + return null; + } + /// Queue progress update locally (offline mode) Future _sendOfflineProgress(Duration position, Duration duration) async { final serverId = metadata.serverId; @@ -218,7 +352,7 @@ class PlaybackProgressTracker { await offlineWatchService!.queueProgressUpdate( serverId: serverId, - ratingKey: metadata.ratingKey, + itemId: metadata.id, viewOffset: position.inMilliseconds, duration: duration.inMilliseconds, ); diff --git a/lib/services/playback_report_session.dart b/lib/services/playback_report_session.dart new file mode 100644 index 00000000..0ada57bd --- /dev/null +++ b/lib/services/playback_report_session.dart @@ -0,0 +1,253 @@ +import 'dart:async'; + +import '../media/media_server_client.dart'; + +enum _PlaybackReportState { idle, starting, started, stopping, stopFailed, stopped } + +class _PendingProgressReport { + _PendingProgressReport(this.snapshot); + + final PlaybackReportSnapshot snapshot; + final Completer completer = Completer(); + + void complete(bool value) { + if (!completer.isCompleted) completer.complete(value); + } + + void completeError(Object error, StackTrace stackTrace) { + if (!completer.isCompleted) completer.completeError(error, stackTrace); + } +} + +class PlaybackStreamSelection { + final String? mediaSourceId; + final int? audioStreamIndex; + final int? subtitleStreamIndex; + + const PlaybackStreamSelection({this.mediaSourceId, this.audioStreamIndex, this.subtitleStreamIndex}); + + static const none = PlaybackStreamSelection(); +} + +typedef PlaybackStreamSelectionResolver = FutureOr Function(); + +class PlaybackReportSnapshot { + final String state; + final Duration position; + final Duration duration; + final PlaybackStreamSelectionResolver resolveStreamSelection; + + const PlaybackReportSnapshot({ + required this.state, + required this.position, + required this.duration, + this.resolveStreamSelection = _noStreamSelection, + }); + + bool get isStopped => state == 'stopped'; + + static PlaybackStreamSelection _noStreamSelection() => PlaybackStreamSelection.none; +} + +/// Serializes backend playback-report calls for one media item. +/// +/// This class owns the start/progress/stop lifecycle invariants. Callers may +/// fire reports concurrently, but state changes are recorded synchronously +/// before any async work such as settings lookup, track mapping, or HTTP calls. +class PlaybackReportSession { + PlaybackReportSession({required this.client, required this.itemId, this.playSessionId, this.playMethod}); + + final MediaServerClient client; + final String itemId; + final String? playSessionId; + final String? playMethod; + + _PlaybackReportState _state = _PlaybackReportState.idle; + PlaybackReportSnapshot? _startSnapshot; + _PendingProgressReport? _pendingProgress; + Future? _pumpFuture; + Future? _stopFuture; + + bool get isIdle => _state == _PlaybackReportState.idle; + + bool get _isStoppingOrTerminal => + _state == _PlaybackReportState.stopping || + _state == _PlaybackReportState.stopFailed || + _state == _PlaybackReportState.stopped; + + Future report(PlaybackReportSnapshot snapshot) { + return snapshot.isStopped ? _reportStopped(snapshot) : _reportProgress(snapshot); + } + + Future _reportProgress(PlaybackReportSnapshot snapshot) { + if (_isStoppingOrTerminal) { + return Future.value(false); + } + + switch (_state) { + case _PlaybackReportState.idle: + _state = _PlaybackReportState.starting; + _startSnapshot = snapshot; + return _ensurePump().then((_) => true); + case _PlaybackReportState.starting: + // A duplicate playing heartbeat during startup does not need an + // immediate progress ping; a state change (playing -> paused) does. + if (_startSnapshot?.state != snapshot.state) { + return _setPendingProgress(snapshot); + } + final pump = _pumpFuture; + return (pump ?? Future.value()).then((_) => true); + case _PlaybackReportState.started: + final pending = _setPendingProgress(snapshot); + _ensurePump(); + return pending; + case _PlaybackReportState.stopping: + case _PlaybackReportState.stopFailed: + case _PlaybackReportState.stopped: + return Future.value(false); + } + } + + Future _reportStopped(PlaybackReportSnapshot snapshot) { + if (_state == _PlaybackReportState.stopped) { + return Future.value(false); + } + if (_state == _PlaybackReportState.stopping) { + final pending = _stopFuture; + return (pending ?? Future.value()).then((_) => false); + } + + _state = _PlaybackReportState.stopping; + _discardPendingProgress(); + final stopFuture = _runStop(snapshot); + _stopFuture = stopFuture; + return stopFuture.then((_) => true); + } + + Future _ensurePump() { + final existing = _pumpFuture; + if (existing != null) return existing; + + final future = _runPump(); + _pumpFuture = future; + future.then( + (_) { + if (identical(_pumpFuture, future)) _pumpFuture = null; + }, + onError: (Object error, StackTrace stackTrace) { + if (identical(_pumpFuture, future)) _pumpFuture = null; + }, + ); + return future; + } + + Future _runPump() async { + try { + final start = _startSnapshot; + if (start != null) { + await _sendStarted(start); + _startSnapshot = null; + if (_state == _PlaybackReportState.starting) { + _state = _PlaybackReportState.started; + } + } + + while (_state == _PlaybackReportState.started) { + final progress = _pendingProgress; + if (progress == null) break; + _pendingProgress = null; + try { + progress.complete(await _sendProgress(progress.snapshot)); + } catch (e, st) { + progress.completeError(e, st); + rethrow; + } + } + } catch (_) { + if (_state == _PlaybackReportState.starting) { + _state = _PlaybackReportState.idle; + _startSnapshot = null; + } + _discardPendingProgress(); + rethrow; + } + } + + Future _runStop(PlaybackReportSnapshot snapshot) async { + var stopSucceeded = false; + try { + final pump = _pumpFuture; + if (pump != null) { + try { + await pump; + } catch (_) { + // Stop is terminal and best-effort. A failed start/progress report + // must not prevent the final stopped position from being reported. + } + } + await _sendStopped(snapshot); + stopSucceeded = true; + } finally { + _stopFuture = null; + _discardPendingProgress(); + _pumpFuture = null; + _state = stopSucceeded ? _PlaybackReportState.stopped : _PlaybackReportState.stopFailed; + } + } + + Future _setPendingProgress(PlaybackReportSnapshot snapshot) { + _discardPendingProgress(); + final pending = _PendingProgressReport(snapshot); + _pendingProgress = pending; + return pending.completer.future; + } + + void _discardPendingProgress() { + final pending = _pendingProgress; + if (pending == null) return; + _pendingProgress = null; + pending.complete(false); + } + + Future _sendStarted(PlaybackReportSnapshot snapshot) async { + final selection = await snapshot.resolveStreamSelection(); + await client.reportPlaybackStarted( + itemId: itemId, + position: snapshot.position, + duration: snapshot.duration, + playSessionId: playSessionId, + playMethod: playMethod, + mediaSourceId: selection.mediaSourceId, + audioStreamIndex: selection.audioStreamIndex, + subtitleStreamIndex: selection.subtitleStreamIndex, + ); + } + + Future _sendProgress(PlaybackReportSnapshot snapshot) async { + final selection = await snapshot.resolveStreamSelection(); + if (_state != _PlaybackReportState.started) return false; + await client.reportPlaybackProgress( + itemId: itemId, + position: snapshot.position, + duration: snapshot.duration, + isPaused: snapshot.state == 'paused', + playSessionId: playSessionId, + playMethod: playMethod, + mediaSourceId: selection.mediaSourceId, + audioStreamIndex: selection.audioStreamIndex, + subtitleStreamIndex: selection.subtitleStreamIndex, + ); + return true; + } + + Future _sendStopped(PlaybackReportSnapshot snapshot) async { + final selection = await snapshot.resolveStreamSelection(); + await client.reportPlaybackStopped( + itemId: itemId, + position: snapshot.position, + duration: snapshot.duration, + playSessionId: playSessionId, + mediaSourceId: selection.mediaSourceId, + ); + } +} diff --git a/lib/services/playlist_items_loader.dart b/lib/services/playlist_items_loader.dart new file mode 100644 index 00000000..0b68dfe7 --- /dev/null +++ b/lib/services/playlist_items_loader.dart @@ -0,0 +1,16 @@ +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; + +/// Page through every item in a playlist via the backend-neutral client API. +Future> fetchAllPlaylistItems(MediaServerClient client, String playlistId, {int pageSize = 100}) async { + final all = []; + var offset = 0; + while (true) { + final page = await client.fetchPlaylistItems(playlistId, offset: offset, limit: pageSize); + if (page.isEmpty) break; + all.addAll(page); + if (page.length < pageSize) break; + offset += page.length; + } + return all; +} diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index 4747aa24..ff6eb40e 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -1,16 +1,23 @@ import 'dart:convert'; -import '../utils/isolate_helper.dart'; import 'package:drift/drift.dart'; import '../database/app_database.dart'; -import '../models/plex_metadata.dart'; -import '../utils/plex_cache_parser.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; import '../utils/global_key_utils.dart'; +import '../utils/isolate_helper.dart'; +import '../utils/plex_cache_parser.dart'; +import 'api_cache.dart'; +import 'plex_mappers.dart'; -/// Key-value cache for Plex API responses using Drift/SQLite. -/// Stores raw JSON responses keyed by serverId:endpoint format. -class PlexApiCache { +/// Plex-shape helpers on top of the shared [ApiCache] substrate. +/// +/// The generic CRUD (`get`/`put`/`pin`/...) lives on [ApiCache]. This class +/// adds operations that bake in Plex's `/library/metadata/{ratingKey}` +/// endpoint shape and parse cached JSON into [MediaItem] via +/// [PlexMappers.mediaItemFromCacheJson]. +class PlexApiCache extends ApiCache { static PlexApiCache? _instance; static PlexApiCache get instance { if (_instance == null) { @@ -19,143 +26,124 @@ class PlexApiCache { return _instance!; } - final AppDatabase _db; + PlexApiCache._(super.db); - PlexApiCache._(this._db); - - /// Initialize the singleton with an AppDatabase instance + /// Initialize the singleton with an [AppDatabase] instance. Also registers + /// this instance with the [ApiCache] backend dispatch so callers using + /// `ApiCache.forBackend(MediaBackend.plex)` resolve here. static void initialize(AppDatabase db) { _instance = PlexApiCache._(db); + ApiCache.registerInstance(MediaBackend.plex, _instance!); } - /// Get the database instance (for services that need direct database access) - AppDatabase get database => _db; - - /// Build cache key from serverId and endpoint - String _buildKey(String serverId, String endpoint) { - return '$serverId:$endpoint'; - } - - /// Get cached response for an endpoint - Future?> get(String serverId, String endpoint) async { - final key = _buildKey(serverId, endpoint); - final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(key))).getSingleOrNull(); - - if (result != null) { - return await tryIsolateRun(() => jsonDecode(result.data) as Map); - } - return null; - } - - /// Cache a response for an endpoint - Future put(String serverId, String endpoint, Map data) async { - final key = _buildKey(serverId, endpoint); - final encoded = await tryIsolateRun(() => jsonEncode(data)); - await _db - .into(_db.apiCache) - .insertOnConflictUpdate( - ApiCacheCompanion(cacheKey: Value(key), data: Value(encoded), cachedAt: Value(DateTime.now())), - ); - } - - /// Delete all cached data for a server - Future deleteForServer(String serverId) async { - await (_db.delete(_db.apiCache)..where((t) => t.cacheKey.like('$serverId:%'))).go(); - } - - /// Delete cached data for a specific item (when removing a download) + /// Delete cached data for a specific item (when removing a download). + @override Future deleteForItem(String serverId, String ratingKey) async { - // Delete the metadata endpoint - final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey'); - final childrenKey = _buildKey(serverId, '/library/metadata/$ratingKey/children'); + final metadataKey = '$serverId:/library/metadata/$ratingKey'; + final childrenKey = '$serverId:/library/metadata/$ratingKey/children'; - await (_db.delete( - _db.apiCache, + await (database.delete( + database.apiCache, )..where((t) => t.cacheKey.equals(metadataKey) | t.cacheKey.equals(childrenKey))).go(); } - /// Mark an item as pinned for offline access + /// Mark an item as pinned for offline access. + @override Future pinForOffline(String serverId, String ratingKey) async { - final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey'); - await (_db.update( - _db.apiCache, - )..where((t) => t.cacheKey.equals(metadataKey))).write(const ApiCacheCompanion(pinned: Value(true))); + return pin(serverId, '/library/metadata/$ratingKey'); } - /// Unpin an item + /// Unpin an item. Future unpinForOffline(String serverId, String ratingKey) async { - final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey'); - await (_db.update( - _db.apiCache, - )..where((t) => t.cacheKey.equals(metadataKey))).write(const ApiCacheCompanion(pinned: Value(false))); + return unpin(serverId, '/library/metadata/$ratingKey'); } - /// Check if an item is pinned for offline - Future isPinned(String serverId, String ratingKey) async { - final metadataKey = _buildKey(serverId, '/library/metadata/$ratingKey'); - final result = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.equals(metadataKey))).getSingleOrNull(); - return result?.pinned ?? false; - } - - /// Get all pinned rating keys for a server - Future> getPinnedKeys(String serverId) async { - final results = await (_db.select( - _db.apiCache, - )..where((t) => t.cacheKey.like('$serverId:%') & t.pinned.equals(true))).get(); - - final keys = {}; - for (final row in results) { - // Extract ratingKey from cache key like "serverId:/library/metadata/12345" - // Rating keys can be alphanumeric, not just numeric - final match = RegExp(r'/library/metadata/([^/]+)$').firstMatch(row.cacheKey); - if (match != null) { - keys.add(match.group(1)!); - } - } - return keys; - } - - /// Fetch and parse a [PlexMetadata] item from cache. + /// Whether the metadata for [ratingKey] is pinned for offline. /// - /// Returns `null` when the endpoint is not cached or contains no metadata. - Future getMetadata(String serverId, String ratingKey) async { + /// Named `isPinnedRatingKey` to avoid colliding with the inherited + /// [ApiCache.isPinned]'s identical Dart signature. + Future isPinnedRatingKey(String serverId, String ratingKey) { + return isPinned(serverId, '/library/metadata/$ratingKey'); + } + + // Rating keys can be alphanumeric, not just numeric. + static final RegExp _metadataKeyPattern = RegExp(r'/library/metadata/([^/]+)$'); + + /// Get all pinned rating keys for a server. + Future> getPinnedKeys(String serverId) => extractPinnedIds(serverId, _metadataKeyPattern); + + /// Fetch and parse a [MediaItem] from cache. + /// + /// The on-disk format is the raw Plex `/library/metadata/{id}` JSON shape; + /// [PlexMappers.mediaItemFromCacheJson] converts it to the neutral + /// [MediaItem] at the boundary. Returns `null` when the endpoint is not + /// cached or contains no metadata. + @override + Future getMetadata(String serverId, String ratingKey) async { final cached = await get(serverId, '/library/metadata/$ratingKey'); final json = PlexCacheParser.extractFirstMetadata(cached); if (json == null) return null; - return PlexMetadata.fromJsonWithImages(json).copyWith(serverId: serverId); + return PlexMappers.mediaItemFromCacheJson(json, serverId: serverId); } - /// Load all pinned metadata in a single query. + /// Persist a watched/unwatched flip into the cached metadata JSON. Mirrors + /// what the server would return after the flip so a later cache reload + /// (e.g. on app restart) reflects the current watched state without a + /// network roundtrip. /// - /// Returns a map keyed by `serverId:ratingKey` for O(1) lookups. - /// Used by DownloadProvider to batch-load metadata on startup instead of - /// issuing per-item DB queries. - Future> getAllPinnedMetadata() async { - final rows = await (_db.select(_db.apiCache)..where((t) => t.pinned.equals(true))).get(); - - // Extract (cacheKey, data) pairs and parse serverId/ratingKey on main thread (cheap string ops), - // then send raw JSON strings to isolate for the expensive decode+parse. - final entries = <(String serverId, String ratingKey, String data)>[]; - for (final row in rows) { - final colonIdx = row.cacheKey.indexOf(':'); - if (colonIdx < 0) continue; - final serverId = row.cacheKey.substring(0, colonIdx); - final match = RegExp(r'/library/metadata/([^/]+)$').firstMatch(row.cacheKey); - if (match == null) continue; - entries.add((serverId, match.group(1)!, row.data)); + /// When [viewOffsetMs] / [lastViewedAt] / [viewedLeafCount] are supplied, + /// they overwrite the snapshot values mirrored from a fresher server + /// response (e.g. the offline-watch-sync episode-list refresh). They take + /// precedence over the defaults the watched flip would otherwise apply — + /// callers passing them have a more accurate read of server state. + @override + Future applyWatchState({ + required String serverId, + required String itemId, + required bool isWatched, + int? viewOffsetMs, + int? lastViewedAt, + int? viewedLeafCount, + }) async { + final endpoint = '/library/metadata/$itemId'; + final cached = await get(serverId, endpoint); + final json = PlexCacheParser.extractFirstMetadata(cached); + if (cached == null || json == null) return; + if (isWatched) { + final current = (json['viewCount'] as num?)?.toInt() ?? 0; + json['viewCount'] = current < 1 ? 1 : current; + json['viewOffset'] = viewOffsetMs ?? 0; + json['lastViewedAt'] = lastViewedAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; + } else { + json['viewCount'] = 0; + json['viewOffset'] = viewOffsetMs ?? 0; + if (lastViewedAt != null) json['lastViewedAt'] = lastViewedAt; } + if (viewedLeafCount != null) json['viewedLeafCount'] = viewedLeafCount; + await put(serverId, endpoint, cached); + } + /// Load all pinned Plex metadata in a single query. + /// + /// Returns a map keyed by `buildGlobalKey(serverId, ratingKey)` for O(1) + /// lookups. Used by DownloadProvider to batch-load metadata on startup + /// instead of issuing per-item DB queries. + @override + Future> getAllPinnedMetadata() async { + final entries = await listPinnedRowsByPattern(_metadataKeyPattern); if (entries.isEmpty) return {}; return await tryIsolateRun(() { - final result = {}; - for (final (serverId, ratingKey, rawData) in entries) { + final result = {}; + for (final entry in entries) { try { - final data = jsonDecode(rawData) as Map; + final data = jsonDecode(entry.data) as Map; final json = PlexCacheParser.extractFirstMetadata(data); if (json == null) continue; - final metadata = PlexMetadata.fromJsonWithImages(json).copyWith(serverId: serverId); - result[buildGlobalKey(serverId, ratingKey)] = metadata; + result[buildGlobalKey(entry.serverId, entry.id)] = PlexMappers.mediaItemFromCacheJson( + json, + serverId: entry.serverId, + ); } catch (_) { // Skip malformed entries } @@ -163,9 +151,4 @@ class PlexApiCache { return result; }); } - - /// Clear all cached data (useful for debugging/testing) - Future clearAll() async { - await _db.delete(_db.apiCache).go(); - } } diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index b6589ec6..a16c0894 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -2,12 +2,13 @@ import 'dart:async'; import 'dart:io' show InternetAddress; import 'storage_service.dart'; import 'plex_client.dart'; -import '../models/plex_user_profile.dart'; -import '../models/plex_home.dart'; +import '../models/plex/plex_user_profile.dart'; +import '../models/plex/plex_home.dart'; import '../models/user_switch_response.dart'; import '../utils/app_logger.dart'; -import '../utils/connection_constants.dart'; -import '../utils/plex_http_client.dart'; +import '../utils/media_server_timeouts.dart'; +import '../utils/media_server_http_client.dart'; +import '../utils/poll_with_backoff.dart'; /// Redacts the middle of an IP address or hostname for safe logging. /// E.g. `192.168.1.50` → `192.***.***.50`, `my.server.example.com` → `my.***.***. com`. @@ -44,7 +45,7 @@ class PlexAuthService { static const String _plexApiBase = 'https://plex.tv/api/v2'; static const String _clientsApi = 'https://clients.plex.tv/api/v2'; - final PlexHttpClient _http; + final MediaServerHttpClient _http; final String _clientIdentifier; PlexAuthService._(this._http, this._clientIdentifier); @@ -55,9 +56,9 @@ class PlexAuthService { static Future create() async { final storage = await StorageService.getInstance(); - final http = PlexHttpClient( - connectTimeout: ConnectionTimeouts.plexTvConnect, - receiveTimeout: ConnectionTimeouts.plexTvReceive, + final http = MediaServerHttpClient( + connectTimeout: MediaServerTimeouts.plexTvConnect, + receiveTimeout: MediaServerTimeouts.plexTvReceive, ); final clientIdentifier = await storage.getOrCreateClientIdentifier(); return PlexAuthService._(http, clientIdentifier); @@ -79,15 +80,15 @@ class PlexAuthService { return headers; } - Future _getUser(String authToken) { + Future _getUser(String authToken) { return _http.get( '$_plexApiBase/user', headers: _getCommonHeaders(authToken: authToken), - timeout: ConnectionTimeouts.plexTvReceive, + timeout: MediaServerTimeouts.plexTvReceive, ); } - void _checkStatus(PlexResponse response) => throwIfHttpError(response); + void _checkStatus(MediaServerResponse response) => throwIfHttpError(response); /// Verify if a plex.tv token is valid Future verifyToken(String authToken) async { @@ -104,7 +105,7 @@ class PlexAuthService { final response = await _http.post( '$_plexApiBase/pins?strong=true', headers: _getCommonHeaders(), - timeout: ConnectionTimeouts.plexTvReceive, + timeout: MediaServerTimeouts.plexTvReceive, ); _checkStatus(response); return response.data as Map; @@ -127,7 +128,7 @@ class PlexAuthService { final response = await _http.get( '$_plexApiBase/pins/$pinId', headers: _getCommonHeaders(), - timeout: ConnectionTimeouts.plexTvReceive, + timeout: MediaServerTimeouts.plexTvReceive, ); final data = response.data as Map; @@ -145,27 +146,12 @@ class PlexAuthService { int pinId, { Duration timeout = const Duration(minutes: 2), bool Function()? shouldCancel, - }) async { - final endTime = DateTime.now().add(timeout); - var backoff = const Duration(seconds: 1); - const maxBackoff = Duration(seconds: 5); - - while (DateTime.now().isBefore(endTime)) { - if (shouldCancel != null && shouldCancel()) { - return null; - } - - final token = await checkPin(pinId); - if (token != null) { - return token; - } - - await Future.delayed(backoff); - final next = backoff * 2; - backoff = next > maxBackoff ? maxBackoff : next; - } - - return null; // Timeout + }) { + return pollWithBackoff( + probe: () => checkPin(pinId), + endTime: DateTime.now().add(timeout), + shouldCancel: shouldCancel, + ); } /// Fetch available Plex servers for the authenticated user @@ -401,8 +387,8 @@ class PlexServer { return; } - const preferredTimeout = ConnectionTimeouts.preferredEndpointProbe; - const raceTimeout = ConnectionTimeouts.connectionRace; + const preferredTimeout = MediaServerTimeouts.preferredEndpointProbe; + const raceTimeout = MediaServerTimeouts.connectionRace; final candidates = _buildPrioritizedCandidates(); if (candidates.isEmpty) { @@ -760,7 +746,7 @@ class PlexServer { final result = await PlexClient.testConnectionWithLatency( httpsUrl, accessToken, - timeout: ConnectionTimeouts.connectionRace, + timeout: MediaServerTimeouts.connectionRace, clientIdentifier: clientIdentifier, ); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index d2e3364c..6cc6bc89 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,73 +1,94 @@ import 'dart:async'; import '../utils/isolate_helper.dart'; import '../utils/json_utils.dart'; -import 'dart:math'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; -import '../utils/plex_http_client.dart'; -import '../utils/plex_http_exception.dart'; +import '../media/download_resolution.dart'; +import '../media/library_filter_result.dart'; +import '../media/library_first_character.dart'; +import '../media/library_query.dart'; +import '../media/live_tv_support.dart'; +import '../media/media_backend.dart'; +import '../media/media_hub.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_library.dart'; +import '../media/media_playlist.dart'; +import '../media/media_server_client.dart'; +import '../media/media_version.dart'; +import '../media/server_capabilities.dart'; +import '../utils/external_ids.dart'; +import 'bif_thumbnail_service.dart'; +import 'download_artwork_helpers.dart'; +import 'file_info_parser.dart'; +import 'library_query_translator.dart'; +import 'scrub_preview_source.dart'; +import '../utils/media_server_http_client.dart'; +import '../exceptions/media_server_exceptions.dart'; import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; import '../models/livetv_hub_result.dart'; import '../models/livetv_program.dart'; -import '../models/plex_activity.dart'; -import '../models/plex_config.dart'; -import '../models/play_queue_response.dart'; -import '../models/plex_file_info.dart'; -import '../models/plex_filter.dart'; -import '../models/plex_first_character.dart'; -import '../models/plex_hub.dart'; -import '../models/plex_library.dart'; -import '../models/plex_media_info.dart'; -import '../models/plex_subtitle_search_result.dart'; -import '../models/plex_media_version.dart'; -import '../models/plex_match_result.dart'; -import '../models/plex_metadata.dart'; +import '../models/plex/plex_activity.dart'; +import '../models/plex/plex_config.dart'; +import '../models/plex/play_queue_response.dart'; +import '../media/media_file_info.dart'; +import '../media/media_filter.dart'; +import '../media/media_source_info.dart'; +import '../models/plex/plex_subtitle_search_result.dart'; +import '../models/plex/plex_match_result.dart'; +import '../utils/codec_utils.dart'; import '../utils/content_utils.dart'; -import '../models/plex_playlist.dart'; -import '../models/plex_sort.dart'; -import '../models/plex_video_playback_data.dart'; +import '../media/media_sort.dart'; +import '../models/plex/plex_video_playback_data.dart'; import '../models/transcode_quality_preset.dart'; import '../utils/endpoint_failover_interceptor.dart'; import '../utils/app_logger.dart'; -import '../utils/connection_constants.dart'; +import '../utils/media_server_timeouts.dart'; import '../utils/log_redaction_manager.dart'; import '../utils/plex_cache_parser.dart'; import '../utils/plex_url_helper.dart'; +import '../utils/session_identifier.dart' as session_id; import '../utils/watch_state_notifier.dart'; +import '../i18n/strings.g.dart'; +import '../mpv/mpv.dart'; +import 'api_cache.dart'; import 'plex_api_cache.dart'; +import 'plex_mappers.dart'; +import 'playback_initialization_types.dart'; /// Result of a paginated library content fetch -class LibraryContentResult { - final List items; +class _LibraryContentResult { + final List items; final int totalSize; - const LibraryContentResult({required this.items, required this.totalSize}); + const _LibraryContentResult({required this.items, required this.totalSize}); } /// Process hub response in an isolate. /// Top-level function so it can be passed to [Isolate.run]. -List _processHubResponse( +List _processHubResponse( Map decoded, String serverId, String? serverName, { - bool Function(PlexMetadata)? filter, + bool Function(PlexMetadataDto)? filter, }) { final container = decoded['MediaContainer'] as Map?; if (container == null || container['Hub'] == null) return []; - final itemFilter = filter ?? (PlexMetadata item) => item.isVideoContent; - final hubs = []; + final itemFilter = filter ?? (PlexMetadataDto item) => ContentTypes.videoTypes.contains(item.type?.toLowerCase()); + final hubs = []; for (final hubJson in container['Hub'] as List) { try { - final hub = PlexHub.fromJson(hubJson as Map, serverId: serverId, serverName: serverName); + final hub = PlexHubDto.fromJson(hubJson as Map, serverId: serverId, serverName: serverName); if (hub.items.isEmpty) continue; final filteredItems = hub.items.where(itemFilter).toList(); if (filteredItems.isNotEmpty) { hubs.add( - PlexHub( + PlexHubDto( hubKey: hub.hubKey, title: hub.title, type: hub.type, @@ -87,12 +108,8 @@ List _processHubResponse( return hubs; } -/// Constants for Plex stream types -class PlexStreamType { - static const int video = 1; - static const int audio = 2; - static const int subtitle = 3; -} +// PlexStreamType moved to plex_constants.dart to break a would-be circular +// import once plex_mappers.dart started referencing the same names. /// Result of testing a connection, including success status and latency class ConnectionTestResult { @@ -108,22 +125,30 @@ class ConnectionTestResult { ConnectionTestResult({required this.success, required this.latencyMs, this.error, this.transcoderVideo}); } -class PlexClient { +class PlexClient with MediaServerCacheMixin implements MediaServerClient { PlexConfig config; - late final PlexHttpClient _http; + late final MediaServerHttpClient _http; final EndpointFailoverManager? _endpointManager; final Future Function(String newBaseUrl)? _onEndpointChanged; final VoidCallback? _onAllEndpointsExhausted; - /// Server identifier - all PlexMetadata items created by this client are tagged with this + /// Server identifier - all PlexMetadataDto items created by this client are tagged with this + @override final String serverId; - /// Server name - all PlexMetadata items created by this client are tagged with this + /// Server name - all PlexMetadataDto items created by this client are tagged with this + @override final String? serverName; /// API response cache for offline support final PlexApiCache _cache = PlexApiCache.instance; + /// Expose the cache through the [MediaServerClient] interface so the shared + /// `fetchWithCacheFallback` / `fetchWithCacheFirst` helpers route through + /// the Plex-specific cache substrate. + @override + ApiCache get cache => _cache; + /// Whether to operate in offline mode (use cache only) bool _offlineMode = false; @@ -136,10 +161,10 @@ class PlexClient { Future? _serverTranscoderPending; /// Libraries parsed from /media/providers (includes individually shared items) - late final List _providerLibraries; + List _providerLibraries = const []; /// EPG providers parsed from /media/providers - late final List<({String identifier, String gridEndpoint})> _providerEpg; + List<({String identifier, String gridEndpoint})> _providerEpg = const []; /// Server-level preferences fetched from /:/prefs Map _serverPrefs = {}; @@ -156,11 +181,13 @@ class PlexClient { } /// Set offline mode - when true, only cached responses are returned + @override void setOfflineMode(bool offline) { _offlineMode = offline; } /// Get current offline mode state + @override bool get isOfflineMode => _offlineMode; /// Create a fully initialized PlexClient. @@ -202,6 +229,7 @@ class PlexClient { List? prioritizedEndpoints, Future Function(String newBaseUrl)? onEndpointChanged, VoidCallback? onAllEndpointsExhausted, + http.Client? httpClient, }) : _endpointManager = (prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty) ? EndpointFailoverManager(prioritizedEndpoints) : null, @@ -210,14 +238,35 @@ class PlexClient { LogRedactionManager.registerServerUrl(config.baseUrl); LogRedactionManager.registerToken(config.token); - _http = PlexHttpClient( + _http = MediaServerHttpClient( baseUrl: config.baseUrl, defaultHeaders: config.headers, - connectTimeout: ConnectionTimeouts.connect, - receiveTimeout: ConnectionTimeouts.receive, + connectTimeout: MediaServerTimeouts.connect, + receiveTimeout: MediaServerTimeouts.receive, + client: httpClient, ); } + /// Test-only factory that injects an [http.Client] so URL-builder tests can + /// capture the request URI without spinning up a real Plex server. Mirrors + /// [JellyfinClient.forTesting]. Skips the [_initMediaProviders] step from + /// [create] — tests that need libraries should mock the `/media/providers` + /// response themselves. + @visibleForTesting + static PlexClient forTesting({ + required PlexConfig config, + required String serverId, + String? serverName, + required http.Client httpClient, + List<({String identifier, String gridEndpoint})> epgProviders = const [], + }) { + final client = PlexClient._(config, serverId: serverId, serverName: serverName, httpClient: httpClient); + client._providerLibraries = const []; + client._providerEpg = epgProviders; + return client; + } + + @override void close() { _http.close(); } @@ -226,7 +275,7 @@ class PlexClient { /// Execute a GET request with endpoint failover retry. On timeout/connection /// errors the next endpoint is tried (once). Non-GET methods are not retried. - Future _getWithFailover( + Future _getWithFailover( String path, { Map? queryParameters, Map? headers, @@ -244,7 +293,7 @@ class PlexClient { ); throwIfHttpError(response); return response; - } on PlexHttpException catch (e) { + } on MediaServerHttpException catch (e) { if (!_shouldAttemptFailover(e) || _failoverSwitching || _endpointManager == null || @@ -286,21 +335,21 @@ class PlexClient { } } - bool _shouldAttemptFailover(PlexHttpException e) { + bool _shouldAttemptFailover(MediaServerHttpException e) { if (e.isTransient) return true; final sc = e.statusCode; return sc != null && sc >= 500 && sc <= 599; } /// POST the tune endpoint with one retry on transient HTTP failure. - Future _postTuneWithRetry(String path, String sessionIdentifier) async { + Future _postTuneWithRetry(String path, String sessionIdentifier) async { final query = {'X-Plex-Session-Identifier': sessionIdentifier}; try { - return await _http.post(path, queryParameters: query, timeout: ConnectionTimeouts.tune); - } on PlexHttpException catch (e) { + return await _http.post(path, queryParameters: query, timeout: MediaServerTimeouts.tune); + } on MediaServerHttpException catch (e) { if (!e.isTransient) rethrow; appLogger.w('Tune channel: transient failure, retrying once', error: e); - return await _http.post(path, queryParameters: query, timeout: ConnectionTimeouts.tune); + return await _http.post(path, queryParameters: query, timeout: MediaServerTimeouts.tune); } } @@ -324,7 +373,7 @@ class PlexClient { } // Parse libraries from the library provider - final libraries = []; + final libraries = []; final epg = <({String identifier, String gridEndpoint})>[]; for (final provider in providers) { @@ -364,7 +413,7 @@ class PlexClient { json['key'] = id; libraries.add( - PlexLibrary.fromJson( + PlexLibraryDto.fromJson( json, ).copyWith(serverId: serverId, serverName: serverName, isShared: isSharedLibrary), ); @@ -423,10 +472,10 @@ class PlexClient { String? clientIdentifier, }) async { final stopwatch = Stopwatch()..start(); - PlexHttpClient? client; + MediaServerHttpClient? client; try { - client = PlexHttpClient(baseUrl: baseUrl, connectTimeout: timeout, receiveTimeout: timeout); + client = MediaServerHttpClient(baseUrl: baseUrl, connectTimeout: timeout, receiveTimeout: timeout); final headers = {'X-Plex-Token': token}; if (clientIdentifier != null) { @@ -451,15 +500,15 @@ class PlexClient { error: success ? null : 'HTTP ${response.statusCode}', transcoderVideo: transcoderVideo, ); - } on PlexHttpException catch (e) { + } on MediaServerHttpException catch (e) { stopwatch.stop(); final label = switch (e.type) { - PlexHttpErrorType.connectionTimeout => 'Connection timeout', - PlexHttpErrorType.receiveTimeout => 'Receive timeout', - PlexHttpErrorType.connectionError => 'Connection error', + MediaServerHttpErrorType.connectionTimeout => 'Connection timeout', + MediaServerHttpErrorType.receiveTimeout => 'Receive timeout', + MediaServerHttpErrorType.connectionError => 'Connection error', _ => e.type.name, }; - final message = e.message?.trim() ?? ''; + final message = e.message.trim(); var error = message.isEmpty ? label : '$label: $message'; if (e.statusCode != null) { error += ' (HTTP ${e.statusCode})'; @@ -510,22 +559,23 @@ class PlexClient { // ============================================================================ /// Extract MediaContainer from API response - Map? _getMediaContainer(PlexResponse response) { + Map? _getMediaContainer(MediaServerResponse response) { if (response.data is Map && response.data.containsKey('MediaContainer')) { return response.data['MediaContainer']; } return null; } - /// Tag a PlexMetadata with this client's serverId and serverName - PlexMetadata _tagMetadata(PlexMetadata metadata) => metadata.copyWith(serverId: serverId, serverName: serverName); + /// Tag a PlexMetadataDto with this client's serverId and serverName + PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) => + metadata.copyWith(serverId: serverId, serverName: serverName); - /// Create and tag a PlexMetadata from JSON - PlexMetadata _createTaggedMetadata(Map json) => _tagMetadata(PlexMetadata.fromJson(json)); + /// Create and tag a PlexMetadataDto from JSON + PlexMetadataDto _createTaggedMetadata(Map json) => _tagMetadata(PlexMetadataDto.fromJson(json)); - /// Extract list of PlexMetadata from response + /// Extract list of PlexMetadataDto from response /// Automatically tags all items with this client's serverId and serverName - List _extractMetadataList(PlexResponse response) { + List _extractMetadataList(MediaServerResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List).map((json) => _createTaggedMetadata(json)).toList(); @@ -534,7 +584,7 @@ class PlexClient { } /// Extract first metadata JSON from response (returns raw Map or null) - Map? _getFirstMetadataJson(PlexResponse response) { + Map? _getFirstMetadataJson(MediaServerResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null && (container['Metadata'] as List).isNotEmpty) { return container['Metadata'][0] as Map; @@ -543,7 +593,7 @@ class PlexClient { } /// Generic helper to extract and map Directory list from response - List _extractDirectoryList(PlexResponse response, T Function(Map) fromJson) { + List _extractDirectoryList(MediaServerResponse response, T Function(Map) fromJson) { final container = _getMediaContainer(response); if (container != null && container['Directory'] != null) { return (container['Directory'] as List).map((json) => fromJson(json as Map)).toList(); @@ -551,27 +601,28 @@ class PlexClient { return []; } - /// Extract PlexLibrary list from response with auto-tagging - List _extractLibraryList(PlexResponse response) { + /// Extract PlexLibraryDto list from response with auto-tagging + List _extractLibraryList(MediaServerResponse response) { final container = _getMediaContainer(response); if (container != null && container['Directory'] != null) { return (container['Directory'] as List) .map( - (json) => - PlexLibrary.fromJson(json as Map).copyWith(serverId: serverId, serverName: serverName), + (json) => PlexLibraryDto.fromJson( + json as Map, + ).copyWith(serverId: serverId, serverName: serverName), ) .toList(); } return []; } - /// Extract PlexPlaylist list from response with auto-tagging - List _extractPlaylistList(PlexResponse response) { + /// Extract PlexPlaylistDto list from response with auto-tagging + List _extractPlaylistList(MediaServerResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List) .map( - (json) => PlexPlaylist.fromJson( + (json) => PlexPlaylistDto.fromJson( json as Map, ).copyWith(serverId: serverId, serverName: serverName), ) @@ -591,16 +642,32 @@ class PlexClient { } /// Check if the server connection is healthy (reachable AND authenticated). - /// Returns true only if the server responds with HTTP 200. - Future isHealthy() async { + /// + /// Hits the root `/` MediaContainer (auth-required) rather than `/identity` + /// (an unauthenticated discovery endpoint). With `/identity`, a server with + /// a revoked or expired token would still report healthy, only to 401 on + /// the very next real call. Mirrors Jellyfin's `/Users/Me` choice. + /// + /// Distinguishes 401/403 (token revoked / wrong user) as + /// [HealthStatus.authError] from generic transport failures so the + /// manager can route them to a re-auth banner instead of generic + /// "server offline" UI. + @override + Future checkHealth() async { try { - final response = await _getWithFailover('/identity'); - return response.statusCode == 200; - } catch (e) { - return false; + final response = await _getWithFailover('/'); + return response.statusCode == 200 ? HealthStatus.online : HealthStatus.offline; + } on MediaServerHttpException catch (e) { + if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError; + return HealthStatus.offline; + } catch (_) { + return HealthStatus.offline; } } + @override + Future isHealthy() async => (await checkHealth()) == HealthStatus.online; + /// Get running background tasks (thumbnail generation, credit detection, etc.) Future> getActivities() async { try { @@ -625,7 +692,7 @@ class PlexClient { /// Returns libraries automatically tagged with this client's serverId and serverName. /// Prefers /media/providers data (includes individually shared items), /// falls back to /library/sections for old servers. - Future> getLibraries() async { + Future> _getLibraries() async { if (_providerLibraries.isNotEmpty) return _providerLibraries; // Fallback for old servers that don't support /media/providers final response = await _getWithFailover('/library/sections'); @@ -633,7 +700,7 @@ class PlexClient { } /// Get library content by section ID - Future getLibraryContent( + Future<_LibraryContentResult> _getLibraryContent( String sectionId, { int? start, int? size, @@ -654,20 +721,25 @@ class PlexClient { return params; } - LibraryContentResult _extractLibraryContentResult(PlexResponse response) { + _LibraryContentResult _extractLibraryContentResult(MediaServerResponse response) { final items = _extractMetadataList(response); final container = _getMediaContainer(response); final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length; - return LibraryContentResult(items: items, totalSize: totalSize); + return _LibraryContentResult(items: items, totalSize: totalSize); } - Future _fetchPaginatedList(String path, {int? start, int? size, AbortController? abort}) async { + Future<_LibraryContentResult> _fetchPaginatedList( + String path, { + int? start, + int? size, + AbortController? abort, + }) async { final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort); return _extractLibraryContentResult(response); } - /// Parse list of PlexMetadata from a cached response - List _parseMetadataListFromCachedResponse(Map cached) { + /// Parse list of PlexMetadataDto from a cached response + List _parseMetadataListFromCachedResponse(Map cached) { final metadataList = PlexCacheParser.extractMetadataList(cached); if (metadataList != null) { return metadataList.map((json) => _createTaggedMetadata(json)).toList(); @@ -676,6 +748,7 @@ class PlexClient { } /// Get the server's machine identifier + @override Future getMachineIdentifier() async { try { final response = await _getWithFailover('/'); @@ -717,9 +790,9 @@ class PlexClient { // Cache key is always the base endpoint (no query params) final cacheKey = '/library/metadata/$ratingKey'; - // Special handling needed for OnDeck - can't use simple _fetchWithCacheFallback + // Special handling needed for OnDeck - can't use simple fetchWithCacheFallback // because OnDeck is only available from network response, not cache - return await _fetchWithCacheFallback>( + return await fetchWithCacheFallback>( cacheKey: cacheKey, networkCall: () => _http.get( '/library/metadata/$ratingKey', @@ -727,18 +800,16 @@ class PlexClient { ), parseCache: (cachedData) { final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData); - final firstMetadata = PlexCacheParser.extractFirstMetadata(cachedData); - final playbackData = parseVideoPlaybackDataFromJson(firstMetadata); - return {'metadata': metadata, 'onDeckEpisode': null, 'playbackData': playbackData}; + return {'metadata': metadata, 'onDeckEpisode': null}; }, parseResponse: (response) { - PlexMetadata? metadata; - PlexMetadata? onDeckEpisode; + PlexMetadataDto? metadata; + PlexMetadataDto? onDeckEpisode; final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null) { - metadata = _tagMetadata(PlexMetadata.fromJsonWithImages(metadataJson)); + metadata = _tagMetadata(PlexMetadataDto.fromJsonWithImages(metadataJson)); // Check if OnDeck is nested inside Metadata if (metadataJson.containsKey('OnDeck') && metadataJson['OnDeck'] != null) { @@ -754,125 +825,70 @@ class PlexClient { } } - // Parse playback data from the same response — zero extra network cost - final playbackData = parseVideoPlaybackDataFromJson(metadataJson); - - return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode, 'playbackData': playbackData}; + return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode}; }, ) ?? - {'metadata': null, 'onDeckEpisode': null, 'playbackData': null}; + {'metadata': null, 'onDeckEpisode': null}; } /// Get metadata by rating key with images (includes clearLogo) /// Uses cache when offline or as fallback on network error /// Always fetches with chapters/markers but caches at base endpoint - Future getMetadataWithImages(String ratingKey) async { + Future _getMetadataWithImages(String ratingKey) async { // Cache key is always the base endpoint (no query params) final cacheKey = '/library/metadata/$ratingKey'; - return _fetchWithCacheFallback( + return fetchWithCacheFallback( cacheKey: cacheKey, networkCall: () => _http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData), parseResponse: (response) { final metadataJson = _getFirstMetadataJson(response); - return metadataJson != null ? _tagMetadata(PlexMetadata.fromJsonWithImages(metadataJson)) : null; + return metadataJson != null ? _tagMetadata(PlexMetadataDto.fromJsonWithImages(metadataJson)) : null; }, ); } - /// Parse PlexMetadata with images from a cached response - PlexMetadata? _parseMetadataWithImagesFromCachedResponse(Map cached) { + /// Parse PlexMetadataDto with images from a cached response + PlexMetadataDto? _parseMetadataWithImagesFromCachedResponse(Map cached) { final firstMetadata = PlexCacheParser.extractFirstMetadata(cached); if (firstMetadata != null) { - return _tagMetadata(PlexMetadata.fromJsonWithImages(firstMetadata)); + return _tagMetadata(PlexMetadataDto.fromJsonWithImages(firstMetadata)); } return null; } - /// Generic cache-network-fallback helper for fetching data - /// - /// This method implements the standard pattern used throughout the client: - /// 1. If offline mode is enabled, return cached data only - /// 2. Otherwise, try network request first - /// 3. If network succeeds and cacheResponse is true, cache the response - /// 4. If network fails, fall back to cached data - /// 5. If no cached data available, rethrow the network error - /// Fetch data with cache fallback for offline mode and network errors. - /// - /// Use this to get fresh data when cross-device sync is needed. - Future _fetchWithCacheFallback({ - required String cacheKey, - required Future Function() networkCall, - required T? Function(dynamic cachedData) parseCache, - required T? Function(PlexResponse response) parseResponse, - bool cacheResponse = true, - }) async { - if (_offlineMode) { - final cached = await _cache.get(serverId, cacheKey); - if (cached != null) return parseCache(cached); - return null; - } - try { - final response = await networkCall(); - if (cacheResponse) await _cacheResponseData(cacheKey, response.data); - return parseResponse(response); - } catch (e) { - // On forceRefresh, still try cache as last resort on network error - appLogger.w('Network request failed for $cacheKey, trying cache', error: e); - final cached = await _cache.get(serverId, cacheKey); - if (cached != null) return parseCache(cached); - rethrow; - } - } - - /// Fetch data with cache checked first, network only on cache miss. - /// - /// Use this when fresh data is not critical and prior fetches likely - /// already populated the cache (e.g. playback after visiting detail screen). - Future _fetchWithCacheFirst({ - required String cacheKey, - required Future Function() networkCall, - required T? Function(dynamic cachedData) parseCache, - required T? Function(PlexResponse response) parseResponse, - bool cacheResponse = true, - }) async { - final cached = await _cache.get(serverId, cacheKey); - if (cached != null) return parseCache(cached); - if (_offlineMode) return null; - final response = await networkCall(); - if (cacheResponse) await _cacheResponseData(cacheKey, response.data); - return parseResponse(response); - } - - Future _cacheResponseData(String cacheKey, dynamic data) async { - if (data is Map) { - await _cache.put(serverId, cacheKey, data); - } else if (data != null) { - appLogger.w('Unexpected response type for $cacheKey: ${data.runtimeType}'); - } - } - /// Get first metadata JSON from response data Map? _getFirstMetadataJsonFromData(Map? data) => PlexCacheParser.extractFirstMetadata(data); - /// Wraps an API call that returns a boolean success status - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage) async { + /// Wraps an API call that returns a boolean success status. + /// + /// Contract (matches the rest of the [MediaServerClient] surface): + /// - HTTP 2xx → returns `true`. + /// - HTTP 4xx/5xx → throws [MediaServerHttpException] (via + /// [throwIfHttpError]) so callers can show a real error rather than a + /// silent "success: false". + /// - Network/IO failure → exception bubbles unchanged. + /// - Non-2xx success that the server reports without an error code is + /// vanishingly rare for these endpoints; we still return `false` so + /// callers don't celebrate a non-200 silently. + Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage) async { try { final response = await apiCall(); + throwIfHttpError(response); return response.statusCode == 200; - } catch (e) { - appLogger.e(errorMessage, error: e); - return false; + } catch (e, st) { + appLogger.e(errorMessage, error: e, stackTrace: st); + rethrow; } } /// Wraps an API call that returns a list, returning empty list on error Future> _wrapListApiCall( - Future Function() apiCall, - List Function(PlexResponse response) parseResponse, + Future Function() apiCall, + List Function(MediaServerResponse response) parseResponse, String errorMessage, ) async { try { @@ -893,13 +909,13 @@ class PlexClient { static const int _fetchAllPageSize = 200; /// Iterate every page of a paginated endpoint and concatenate the results. - /// Stops as soon as [LibraryContentResult.totalSize] is reached or a page + /// Stops as soon as [_LibraryContentResult.totalSize] is reached or a page /// returns no items. Errors propagate. - Future> _fetchAllPages( - Future Function(int start, int size, AbortController? abort) fetchPage, { + Future> _fetchAllPages( + Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, { AbortController? abort, }) async { - final all = []; + final all = []; var start = 0; while (true) { final page = await fetchPage(start, _fetchAllPageSize, abort); @@ -911,92 +927,13 @@ class PlexClient { return all; } - /// Parse audio/subtitle tracks and the video stream's frame rate from a - /// raw Part.Stream list in a single pass. - ({List audio, List subtitles, double? frameRate}) _parseStreams( - List? streams, - ) { - final audioTracks = []; - final subtitleTracks = []; - double? frameRate; - - if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate); - - for (final stream in streams) { - final streamType = stream['streamType'] as int?; - - if (streamType == PlexStreamType.video) { - frameRate ??= (stream['frameRate'] as num?)?.toDouble(); - } else if (streamType == PlexStreamType.audio) { - audioTracks.add( - PlexAudioTrack( - id: stream['id'] as int, - index: stream['index'] as int?, - codec: stream['codec'] as String?, - language: stream['language'] as String?, - languageCode: stream['languageCode'] as String?, - title: stream['title'] as String?, - displayTitle: stream['displayTitle'] as String?, - channels: stream['channels'] as int?, - selected: flexibleBool(stream['selected']), - ), - ); - } else if (streamType == PlexStreamType.subtitle) { - subtitleTracks.add( - PlexSubtitleTrack( - id: stream['id'] as int, - index: stream['index'] as int?, - codec: stream['codec'] as String?, - language: stream['language'] as String?, - languageCode: stream['languageCode'] as String?, - title: stream['title'] as String?, - displayTitle: stream['displayTitle'] as String?, - selected: flexibleBool(stream['selected']), - forced: flexibleBool(stream['forced']), - key: stream['key'] as String?, - ), - ); - } - } - - return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate); - } + static const _streamReader = PlexFileInfoStreamReader(); /// Parse chapters from metadata JSON - List _parseChapters(Map? metadataJson) { - if (metadataJson == null || metadataJson['Chapter'] == null) { - return []; - } - - final chapterList = metadataJson['Chapter'] as List; - return chapterList.map((chapter) { - return PlexChapter( - id: chapter['id'] as int, - index: chapter['index'] as int?, - startTimeOffset: chapter['startTimeOffset'] as int?, - endTimeOffset: chapter['endTimeOffset'] as int?, - title: chapter['tag'] as String? ?? chapter['title'] as String?, - thumb: chapter['thumb'] as String?, - ); - }).toList(); - } + List _parseChapters(Map? metadataJson) => plexChaptersFromCacheJson(metadataJson); /// Parse markers from metadata JSON - List _parseMarkers(Map? metadataJson) { - if (metadataJson == null || metadataJson['Marker'] == null) { - return []; - } - - final markerList = metadataJson['Marker'] as List; - return markerList.map((marker) { - return PlexMarker( - id: marker['id'] as int, - type: marker['type'] as String, - startTimeOffset: marker['startTimeOffset'] as int, - endTimeOffset: marker['endTimeOffset'] as int, - ); - }).toList(); - } + List _parseMarkers(Map? metadataJson) => plexMarkersFromCacheJson(metadataJson); /// Set per-media language preferences (audio and subtitle) /// For TV shows, use grandparentRatingKey to set preference for the entire series @@ -1107,7 +1044,7 @@ class PlexClient { /// Search across all libraries including individually shared items. /// Uses /library/search (same endpoint as Plex Web) which finds shared content. /// Only returns movies and shows, filtering out other types. - Future> search(String query, {int limit = 30}) async { + Future> _search(String query, {int limit = 30}) async { final response = await _getWithFailover( '/library/search', queryParameters: { @@ -1120,7 +1057,7 @@ class PlexClient { }, ); - final results = []; + final results = []; final container = _getMediaContainer(response); if (container == null) return results; @@ -1147,7 +1084,7 @@ class PlexClient { } /// Get recently added media (filtered to video content only) - Future> getRecentlyAdded({int limit = 50}) async { + Future> _getRecentlyAdded({int limit = 50}) async { final response = await _getWithFailover( '/library/recentlyAdded', queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, @@ -1155,13 +1092,13 @@ class PlexClient { final allItems = _extractMetadataList(response); // Filter out music content (artists, albums, tracks) - return allItems.where((item) => !item.isMusicContent).toList(); + return allItems.where((item) => !ContentTypes.musicTypes.contains(item.type?.toLowerCase())).toList(); } /// Get continue watching items via the hubs system. /// Uses /hubs?identifier=home.continue,home.ondeck which respects the /// server's OnDeckWindow preference (unlike /library/onDeck). - Future> getContinueWatching({int count = 20}) async { + Future> _getContinueWatching({int count = 20}) async { final response = await _getWithFailover( '/hubs', queryParameters: {'identifier': 'home.continue,home.ondeck', 'count': count, 'includeGuids': 1}, @@ -1173,7 +1110,7 @@ class PlexClient { // Like plex-web, episodes from the same show (same grandparentRatingKey) // are deduplicated, preferring the in-progress item (has viewOffset). final items = hubs.expand((hub) => hub.items).toList(); - final result = []; + final result = []; for (final item in items) { final isEpisode = item.type?.toLowerCase() == 'episode'; final gpKey = item.grandparentRatingKey; @@ -1193,10 +1130,32 @@ class PlexClient { /// Get children of a metadata item (e.g., seasons for a show, episodes for a season) /// Uses cache when offline or as fallback on network error - Future> getChildren(String ratingKey) async { + Future> _getChildren(String ratingKey) async { final endpoint = '/library/metadata/$ratingKey/children'; - return await _fetchWithCacheFallback>( + return await fetchWithCacheFallback>( + cacheKey: endpoint, + networkCall: () => _http.get(endpoint), + parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), + parseResponse: (response) => _extractMetadataList(response), + ) ?? + []; + } + + /// Get every episode beneath a show or season in one call — for a show + /// this returns episodes across every season (no per-season walk), for a + /// season the episodes directly. Mirrors [_getChildren]'s cache-fallback + /// behaviour. + /// + /// Uses `/grandchildren` rather than `/allLeaves` because the live server + /// returns 0 items for `/allLeaves` on a season — `/grandchildren` is the + /// only endpoint Plex serves that one-shots both levels (and is also the + /// recommended path for mini-series shows that set `skipChildren=true`, + /// per the API docs). + Future> _getGrandchildren(String ratingKey) async { + final endpoint = '/library/metadata/$ratingKey/grandchildren'; + + return await fetchWithCacheFallback>( cacheKey: endpoint, networkCall: () => _http.get(endpoint), parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), @@ -1207,10 +1166,10 @@ class PlexClient { /// Get extras for a metadata item (trailers, behind-the-scenes, etc.) /// Uses cache when offline or as fallback on network error - Future> getExtras(String ratingKey) async { + Future> _getExtras(String ratingKey) async { final endpoint = '/library/metadata/$ratingKey/extras'; - return await _fetchWithCacheFallback>( + return await fetchWithCacheFallback>( cacheKey: endpoint, networkCall: () => _http.get(endpoint), parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), @@ -1249,7 +1208,7 @@ class PlexClient { bool forceRefresh = false, }) async { try { - final fetch = forceRefresh ? _fetchWithCacheFallback : _fetchWithCacheFirst; + final fetch = forceRefresh ? fetchWithCacheFallback : fetchWithCacheFirst; final data = await fetch>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => @@ -1274,30 +1233,26 @@ class PlexClient { Map? metadataJson, { String? introPattern, String? creditsPattern, - }) { - return PlaybackExtras.withChapterFallback( - chapters: _parseChapters(metadataJson), - markers: _parseMarkers(metadataJson), - introPatternStr: introPattern, - creditsPatternStr: creditsPattern, - ); - } + }) => plexPlaybackExtrasFromCacheJson(metadataJson, introPattern: introPattern, creditsPattern: creditsPattern); /// Parse video playback data from raw metadata JSON (no network call). - /// Used by [getVideoPlaybackData] and [getMetadataWithImagesAndOnDeck] to - /// avoid redundant fetches when the response is already available. + /// Used by [getVideoPlaybackData] to avoid redundant fetches when the + /// response is already available. PlexVideoPlaybackData parseVideoPlaybackDataFromJson(Map? metadataJson, {int mediaIndex = 0}) { String? videoUrl; - PlexMediaInfo? mediaInfo; - List availableVersions = []; + MediaSourceInfo? mediaInfo; + List availableVersions = []; final markers = _parseMarkers(metadataJson); if (metadataJson != null) { if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { final mediaList = metadataJson['Media'] as List; - // Parse available media versions first - availableVersions = mediaList.map((media) => PlexMediaVersion.fromJson(media as Map)).toList(); + // Parse available media versions first (convert via the internal + // mapper so PlaybackInitializationResult sees neutral MediaVersion). + availableVersions = mediaList + .map((media) => PlexMappers.mediaVersionFromJson(media as Map)) + .toList(); // Ensure the requested index is valid if (mediaIndex < 0 || mediaIndex >= mediaList.length) { @@ -1321,16 +1276,16 @@ class PlexClient { // Get video URL videoUrl = '${config.baseUrl}$partKey'.withPlexToken(config.token); - // Parse streams using helper - final streams = _parseStreams(part['Stream'] as List?); + // Parse streams using shared parser + final streams = walkStreams(part['Stream'] as List?, _streamReader); // Parse chapters using helper final chapters = _parseChapters(metadataJson); // Create media info - mediaInfo = PlexMediaInfo( + mediaInfo = MediaSourceInfo( videoUrl: videoUrl, - audioTracks: streams.audio, - subtitleTracks: streams.subtitles, + audioTracks: streams.audioTracks, + subtitleTracks: streams.subtitleTracks, chapters: chapters, partId: part['id'] as int?, frameRate: streams.frameRate, @@ -1354,7 +1309,7 @@ class PlexClient { Future getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async { Map? data; try { - data = await _fetchWithCacheFallback>( + data = await fetchWithCacheFallback>( cacheKey: '/library/metadata/$ratingKey', // checkFiles=1 populates Part.accessible/exists so we can skip // deleted-but-still-indexed versions before play. @@ -1372,11 +1327,16 @@ class PlexClient { return parseVideoPlaybackDataFromJson(metadataJson, mediaIndex: mediaIndex); } - /// Get file information for a media item - /// Uses cache for offline mode support and network fallback. - Future getFileInfo(String ratingKey) async { + /// Get file information for a media item. + /// + /// Uses cache for offline mode support and network fallback. Wires the + /// neutral [MediaServerClient.getFileInfo] override below. + @override + Future getFileInfo(MediaItem item) => _fetchFileInfo(item.id); + + Future _fetchFileInfo(String ratingKey) async { try { - final data = await _fetchWithCacheFirst>( + final data = await fetchWithCacheFirst>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => _http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), @@ -1389,23 +1349,14 @@ class PlexClient { final media = metadataJson['Media'][0]; final part = media['Part'] != null && (media['Part'] as List).isNotEmpty ? media['Part'][0] : null; - // Extract video stream details and all audio/subtitle tracks - final streams = part?['Stream'] as List? ?? []; - Map? videoStream; - Map? audioStream; + // One pass over the streams array, capturing both the raw video / + // audio map pointers (for fields the parsed track classes don't + // carry — colorSpace, bitDepth, …) and the parsed track lists. + final parsedTracks = walkStreams(part?['Stream'] as List?, _streamReader); + final videoStream = parsedTracks.videoStream; + final audioStream = parsedTracks.audioStream; - for (final stream in streams) { - final streamType = stream['streamType'] as int?; - if (streamType == PlexStreamType.video && videoStream == null) { - videoStream = stream; - } else if (streamType == PlexStreamType.audio && audioStream == null) { - audioStream = stream; - } - } - - final parsedTracks = _parseStreams(streams); - - return PlexFileInfo( + return MediaFileInfo( // Media level properties container: media['container'] as String?, videoCodec: media['videoCodec'] as String?, @@ -1436,8 +1387,8 @@ class PlexClient { // Audio stream details audioChannelLayout: audioStream?['audioChannelLayout'] as String?, // All audio and subtitle tracks - audioTracks: parsedTracks.audio, - subtitleTracks: parsedTracks.subtitles, + audioTracks: parsedTracks.audioTracks, + subtitleTracks: parsedTracks.subtitleTracks, ); } @@ -1474,27 +1425,27 @@ class PlexClient { /// Mark media as watched /// - /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. - Future markAsWatched(String ratingKey, {PlexMetadata? metadata}) async { + /// If [item] is provided, emits a [WatchStateEvent] for UI updates. + Future markAsWatched(String ratingKey, {MediaItem? item}) async { await _getWithFailover( '/:/scrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}, ); - if (metadata != null) { - WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true); + if (item != null) { + WatchStateNotifier().notifyWatched(item: item, isNowWatched: true); } } /// Mark media as unwatched /// - /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. - Future markAsUnwatched(String ratingKey, {PlexMetadata? metadata}) async { + /// If [item] is provided, emits a [WatchStateEvent] for UI updates. + Future markAsUnwatched(String ratingKey, {MediaItem? item}) async { await _getWithFailover( '/:/unscrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}, ); - if (metadata != null) { - WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: false); + if (item != null) { + WatchStateNotifier().notifyWatched(item: item, isNowWatched: false); } } @@ -1505,7 +1456,7 @@ class PlexClient { required String state, // 'playing', 'paused', 'stopped', 'buffering' int? duration, }) async { - await _http.post( + final response = await _http.post( '/:/timeline', queryParameters: { 'ratingKey': ratingKey, @@ -1515,6 +1466,9 @@ class PlexClient { 'duration': ?duration, }, ); + // Surface non-2xx instead of swallowing — progress is the cornerstone + // of resume/Continue Watching, so silent failures hurt the user later. + throwIfHttpError(response); } /// Send a live TV timeline heartbeat to keep the transcode session alive. @@ -1586,23 +1540,12 @@ class PlexClient { await _http.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey}); } - /// Rate a media item (0.0-10.0 scale, where each integer = half a star) - /// Pass -1 to clear an existing rating - Future rateItem(String ratingKey, double rating) { - return _wrapBoolApiCall( - () => _http.put( - '/:/rate', - queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library', 'rating': rating}, - ), - 'Failed to rate item', - ); - } - /// Delete a media item from the library /// This permanently removes the item and its associated files from the server /// Returns true if deletion was successful, false otherwise - Future deleteMediaItem(String ratingKey) { - return _wrapBoolApiCall(() => _http.delete('/library/metadata/$ratingKey'), 'Failed to delete media item'); + @override + Future deleteMediaItem(MediaItem item) { + return _wrapBoolApiCall(() => _http.delete('/library/metadata/${item.id}'), 'Failed to delete media item'); } /// Parse a Plex Settings response into a map of id --> value. @@ -1636,14 +1579,14 @@ class PlexClient { } /// Get available filters for a library section - Future> getLibraryFilters(String sectionId) async { + Future> getLibraryFilters(String sectionId) async { if (sectionId == 'shared') return []; final response = await _getWithFailover('/library/sections/$sectionId/filters'); - return _extractDirectoryList(response, PlexFilter.fromJson); + return _extractDirectoryList(response, MediaFilter.fromJson); } /// Get first characters (alphabet index) for a library section - Future> getFirstCharacters( + Future> getFirstCharacters( String sectionId, { int? type, Map? filters, @@ -1656,27 +1599,41 @@ class PlexClient { '/library/sections/$sectionId/firstCharacter', queryParameters: queryParams, ); - return _extractDirectoryList(response, PlexFirstCharacter.fromJson); + return _extractDirectoryList(response, (json) { + // The Plex /firstCharacter endpoint returns rows with `key`/`title`/ + // `size` (size is a string in the wire payload). + return LibraryFirstCharacter( + key: (json['key'] as String?) ?? '', + title: (json['title'] as String?) ?? '', + size: int.tryParse((json['size'] ?? '').toString()) ?? 0, + ); + }); } /// Get filter values (e.g., list of genres, years, etc.) - Future> getFilterValues(String filterKey) async { + Future> getFilterValues(String filterKey) async { final response = await _getWithFailover(filterKey); - return _extractDirectoryList(response, PlexFilterValue.fromJson); + return _extractDirectoryList(response, MediaFilterValue.fromJson); } /// Get available sort options for a library section /// /// If [libraryType] is provided (e.g., 'movie', 'show'), it's used for fallback /// sorts without needing to re-fetch the library sections list. - Future> getLibrarySorts(String sectionId, {String? libraryType}) async { + @override + Future> fetchSortOptions(String sectionId, {String? libraryType}) async { if (sectionId == 'shared') { return [ - PlexSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title', defaultDirection: 'asc'), - PlexSort( + MediaSort( + key: 'titleSort', + descKey: 'titleSort:desc', + title: t.libraries.sortLabels.title, + defaultDirection: 'asc', + ), + MediaSort( key: 'taggingCreatedAt', descKey: 'taggingCreatedAt:desc', - title: 'Date Shared', + title: t.libraries.sortLabels.dateShared, defaultDirection: 'desc', ), ]; @@ -1686,7 +1643,7 @@ class PlexClient { final response = await _getWithFailover('/library/sections/$sectionId/sorts'); // Parse the Directory array (not Sort array) per the API spec - final sorts = _extractDirectoryList(response, PlexSort.fromJson); + final sorts = _extractDirectoryList(response, MediaSort.fromJson); if (sorts.isNotEmpty) { return sorts; @@ -1704,32 +1661,37 @@ class PlexClient { /// Build fallback sort options based on library type. /// /// If [libraryType] is null, returns generic sorts without the show-specific options. - List _getFallbackSorts(String? libraryType) { - final fallbackSorts = [ - PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), - PlexSort(key: 'addedAt', descKey: 'addedAt:desc', title: 'Date Added', defaultDirection: 'desc'), + List _getFallbackSorts(String? libraryType) { + final fallbackSorts = [ + MediaSort(key: 'titleSort', title: t.libraries.sortLabels.title, defaultDirection: 'asc'), + MediaSort( + key: 'addedAt', + descKey: 'addedAt:desc', + title: t.libraries.sortLabels.dateAdded, + defaultDirection: 'desc', + ), ]; // Add "Latest Episode Air Date" only for TV show libraries if (libraryType?.toLowerCase() == 'show') { fallbackSorts.add( - PlexSort( + MediaSort( key: 'episode.originallyAvailableAt', descKey: 'episode.originallyAvailableAt:desc', - title: 'Latest Episode Air Date', + title: t.libraries.sortLabels.latestEpisodeAirDate, defaultDirection: 'desc', ), ); } fallbackSorts.addAll([ - PlexSort( + MediaSort( key: 'originallyAvailableAt', descKey: 'originallyAvailableAt:desc', - title: 'Release Date', + title: t.libraries.sortLabels.releaseDate, defaultDirection: 'desc', ), - PlexSort(key: 'rating', descKey: 'rating:desc', title: 'Rating', defaultDirection: 'desc'), + MediaSort(key: 'rating', descKey: 'rating:desc', title: t.libraries.sortLabels.rating, defaultDirection: 'desc'), ]); return fallbackSorts; @@ -1737,7 +1699,7 @@ class PlexClient { /// Get library hubs (recommendations for a specific library section) /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. - Future> getLibraryHubs(String sectionId, {int limit = 10}) async { + Future> _getLibraryHubs(String sectionId, {int limit = 10}) async { try { final response = await _getWithFailover( '/hubs/sections/$sectionId', @@ -1755,7 +1717,7 @@ class PlexClient { /// Get global hubs (home page recommendations) /// Returns actual home page hubs like "Recently Added Movies", "Recently Added TV", etc. /// This matches the official Plex client's home page layout. - Future> getGlobalHubs({int limit = 10}) async { + Future> _getGlobalHubs({int limit = 10}) async { try { final response = await _getWithFailover('/hubs', queryParameters: {'count': limit, 'includeGuids': 1}); final sid = serverId; @@ -1768,7 +1730,7 @@ class PlexClient { } /// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor) - Future> getRelatedHubs(String ratingKey, {int count = 10}) async { + Future> _getRelatedHubs(String ratingKey, {int count = 10}) async { try { final response = await _getWithFailover('/hubs/metadata/$ratingKey/related', queryParameters: {'count': count}); final sid = serverId; @@ -1778,7 +1740,10 @@ class PlexClient { response.data as Map, sid, sname, - filter: (item) => item.isVideoContent || item.isCollection, + filter: (item) { + final type = item.type?.toLowerCase(); + return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection; + }, ), ); } catch (e) { @@ -1789,29 +1754,29 @@ class PlexClient { /// Get full content from a hub using its hub key /// Returns the complete list of metadata items in the hub - Future> getHubContent(String hubKey) async { - return _wrapListApiCall(() => _http.get(hubKey), (response) { + Future> _getHubContent(String hubKey) async { + return _wrapListApiCall(() => _http.get(hubKey), (response) { final allItems = _extractMetadataList(response); // Filter to only video content (movies, shows, seasons, episodes) return allItems.where((item) { - return item.isVideoContent; + return ContentTypes.videoTypes.contains(item.type?.toLowerCase()); }).toList(); }, 'Failed to get hub content'); } /// Get playlist content by playlist ID, paginated. - Future getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) => + Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) => _fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort); /// Fetch every page of a playlist's items. For callers that need the full list /// (downloads, sync rules, context-menu shuffle). - Future> fetchAllPlaylistItems(String playlistId) => - _fetchAllPages((start, size, abort) => getPlaylist(playlistId, start: start, size: size, abort: abort)); + Future> _fetchAllPlaylistItemsDto(String playlistId) => + _fetchAllPages((start, size, abort) => _getPlaylist(playlistId, start: start, size: size, abort: abort)); /// Get all playlists /// Filters by playlistType=video by default /// Set smart to true/false to filter smart playlists, or null for all - Future> getPlaylists({String playlistType = 'video', bool? smart}) { + Future> _getPlaylists({String playlistType = 'video', bool? smart}) { final queryParams = { 'playlistType': playlistType, 'X-Plex-Container-Size': _defaultListContainerSize, @@ -1820,7 +1785,7 @@ class PlexClient { queryParams['smart'] = smart ? '1' : '0'; } - return _wrapListApiCall( + return _wrapListApiCall( () => _http.get('/playlists', queryParameters: queryParams), _extractPlaylistList, 'Failed to get playlists', @@ -1829,7 +1794,7 @@ class PlexClient { /// Get playlist metadata by playlist ID /// Returns the playlist details (not the items) - Future getPlaylistMetadata(String playlistId) async { + Future _getPlaylistMetadata(String playlistId) async { try { final response = await _getWithFailover('/playlists/$playlistId'); final container = _getMediaContainer(response); @@ -1844,57 +1809,82 @@ class PlexClient { return null; } - return PlexPlaylist.fromJson(metadata.first as Map); + return PlexPlaylistDto.fromJson(metadata.first as Map); } catch (e) { appLogger.e('Failed to get playlist metadata: $e'); return null; } } + /// Neutral [MediaServerClient.createPlaylist] override — wraps + /// [createPlaylistFromUri] after building a Plex metadata URI from + /// the supplied items. + @override + Future createPlaylist({required String title, required List items}) async { + if (items.isEmpty) { + return createPlaylistFromUri(title: title); + } + final uri = await buildMetadataUri(items.map((i) => i.id).join(',')); + return createPlaylistFromUri(title: title, uri: uri); + } + /// Create a new playlist /// [title] - Name of the playlist /// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234") /// [playQueueId] - Optional play queue ID to create playlist from - Future createPlaylist({required String title, String? uri, int? playQueueId}) async { - try { - final queryParams = {'type': 'video', 'title': title, 'smart': '0'}; + /// + /// Errors propagate to the caller (matches the [MediaServerClient] + /// contract — throw on HTTP/transport failures, return `null` only when + /// the server replied 2xx but with no usable playlist payload). + Future createPlaylistFromUri({required String title, String? uri, int? playQueueId}) async { + final queryParams = {'type': 'video', 'title': title, 'smart': '0'}; - if (uri != null) { - queryParams['uri'] = uri; - } - if (playQueueId != null) { - queryParams['playQueueID'] = playQueueId.toString(); - } + if (uri != null) { + queryParams['uri'] = uri; + } + if (playQueueId != null) { + queryParams['playQueueID'] = playQueueId.toString(); + } - final response = await _http.post('/playlists', queryParameters: queryParams); - final container = _getMediaContainer(response); + final response = await _http.post('/playlists', queryParameters: queryParams); + throwIfHttpError(response); + final container = _getMediaContainer(response); - if (container == null || container['Metadata'] == null) { - return null; - } - - final List metadata = container['Metadata'] as List; - - if (metadata.isEmpty) { - return null; - } - - return PlexPlaylist.fromJson(metadata.first as Map); - } catch (e) { - appLogger.e('Failed to create playlist: $e'); + if (container == null || container['Metadata'] == null) { return null; } + + final List metadata = container['Metadata'] as List; + + if (metadata.isEmpty) { + return null; + } + + final dto = PlexPlaylistDto.fromJson( + metadata.first as Map, + ).copyWith(serverId: serverId, serverName: serverName); + return PlexMappers.mediaPlaylist(dto); } /// Delete a playlist - Future deletePlaylist(String playlistId) { - return _wrapBoolApiCall(() => _http.delete('/playlists/$playlistId'), 'Failed to delete playlist'); + @override + Future deletePlaylist(MediaPlaylist playlist) { + return _wrapBoolApiCall(() => _http.delete('/playlists/${playlist.id}'), 'Failed to delete playlist'); + } + + /// Neutral [MediaServerClient.addToPlaylist] override — builds a Plex + /// metadata URI from [items] and delegates to [addItemsToPlaylistByUri]. + @override + Future addToPlaylist({required String playlistId, required List items}) async { + if (items.isEmpty) return true; + final uri = await buildMetadataUri(items.map((i) => i.id).join(',')); + return addItemsToPlaylistByUri(playlistId: playlistId, uri: uri); } /// Add items to a playlist /// [playlistId] - The playlist to add items to /// [uri] - Comma-separated list of item URIs to add - Future addToPlaylist({required String playlistId, required String uri}) async { + Future addItemsToPlaylistByUri({required String playlistId, required String uri}) async { appLogger.d( 'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}', ); @@ -1908,38 +1898,39 @@ class PlexClient { return result; } - /// Remove an item from a playlist - /// [playlistId] - The playlist to remove from - /// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field) - Future removeFromPlaylist({required String playlistId, required String playlistItemId}) { + @override + Future removeFromPlaylist({required String playlistId, required MediaItem item}) { + if (item is! PlexMediaItem || item.playlistItemId == null) return Future.value(false); return _wrapBoolApiCall( - () => _http.delete('/playlists/$playlistId/items/$playlistItemId'), + () => _http.delete('/playlists/$playlistId/items/${item.playlistItemId}'), 'Failed to remove from playlist', ); } - /// Move a playlist item to a new position - /// Only works with non-smart playlists - /// [playlistId] - The playlist rating key - /// [playlistItemId] - The playlist item ID to move - /// [afterPlaylistItemId] - Move the item after this playlist item ID (0 = move to top) + /// Plex's `?after=0` sentinel means "move to the top". For any other index + /// the API needs the playlist-item id of the row that should sit immediately + /// before [item] after the move — that's what [afterItem] provides. + @override Future movePlaylistItem({ required String playlistId, - required int playlistItemId, - required int afterPlaylistItemId, + required MediaItem item, + required int newIndex, + required MediaItem? afterItem, }) async { - appLogger.d('Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId'); - final result = await _wrapBoolApiCall( - () => _http.put( - '/playlists/$playlistId/items/$playlistItemId/move', - queryParameters: {'after': afterPlaylistItemId}, - ), + if (item is! PlexMediaItem || item.playlistItemId == null) return false; + final int after; + if (newIndex == 0) { + after = 0; + } else if (afterItem is PlexMediaItem && afterItem.playlistItemId != null) { + after = afterItem.playlistItemId!; + } else { + return false; + } + appLogger.d('Moving playlist item ${item.playlistItemId} after $after in playlist $playlistId'); + return _wrapBoolApiCall( + () => _http.put('/playlists/$playlistId/items/${item.playlistItemId}/move', queryParameters: {'after': after}), 'Failed to move playlist item', ); - if (result) { - appLogger.d('Successfully moved playlist item'); - } - return result; } // ============================================================================ @@ -2105,9 +2096,9 @@ class PlexClient { // ============================================================================ /// Get all collections for a library section - /// Returns collections as PlexMetadata objects with type="collection" - Future> getLibraryCollections(String sectionId) async { - return _wrapListApiCall( + /// Returns collections as PlexMetadataDto objects with type="collection" + Future> _getLibraryCollections(String sectionId) async { + return _wrapListApiCall( () => _http.get( '/library/sections/$sectionId/collections', queryParameters: {'includeGuids': 1, 'X-Plex-Container-Size': _defaultListContainerSize}, @@ -2116,7 +2107,7 @@ class PlexClient { final allItems = _extractMetadataList(response); // Collections should have type="collection" return allItems.where((item) { - return item.isCollection; + return item.type?.toLowerCase() == ContentTypes.collection; }).toList(); }, 'Failed to get library collections', @@ -2124,7 +2115,7 @@ class PlexClient { } /// Get items in a collection, paginated. - Future getCollectionItems( + Future<_LibraryContentResult> _getCollectionItems( String collectionId, { int? start, int? size, @@ -2132,20 +2123,25 @@ class PlexClient { }) => _fetchPaginatedList('/library/collections/$collectionId/children', start: start, size: size, abort: abort); /// Fetch every item in a collection (downloads, sync rules, context-menu shuffle). - Future> fetchAllCollectionItems(String collectionId) => - _fetchAllPages((start, size, abort) => getCollectionItems(collectionId, start: start, size: size, abort: abort)); + Future> _fetchAllCollectionItemsDto(String collectionId) => + _fetchAllPages((start, size, abort) => _getCollectionItems(collectionId, start: start, size: size, abort: abort)); /// Get media featuring a specific person (actor/director), paginated. - Future getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) => + Future<_LibraryContentResult> _getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) => _fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort); /// Fetch every media item featuring a given person. - Future> fetchAllPersonMedia(String personId) => - _fetchAllPages((start, size, abort) => getPersonMedia(personId, start: start, size: size, abort: abort)); + Future> _fetchAllPersonMediaDto(String personId) => + _fetchAllPages((start, size, abort) => _getPersonMedia(personId, start: start, size: size, abort: abort)); - /// Delete a collection - /// Deletes a library collection from the server - Future deleteCollection(String sectionId, String collectionId) async { + /// Delete a collection. Reads the section id from [collection.libraryId]. + @override + Future deleteCollection(MediaItem collection) async { + final sectionId = collection.libraryId ?? ''; + return deleteCollectionById(sectionId, collection.id); + } + + Future deleteCollectionById(String sectionId, String collectionId) async { appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId'); final result = await _wrapBoolApiCall( () => _http.delete('/library/collections/$collectionId'), @@ -2157,10 +2153,30 @@ class PlexClient { return result; } + /// Neutral [MediaServerClient.createCollection] — builds a Plex metadata + /// URI for [items] and maps [itemKind] to Plex's section type id. + @override + Future createCollection({ + required String libraryId, + required String title, + required List items, + MediaKind? itemKind, + }) async { + final uri = items.isEmpty ? '' : await buildMetadataUri(items.map((i) => i.id).join(',')); + final type = switch (itemKind) { + MediaKind.movie => 1, + MediaKind.show => 2, + MediaKind.season => 3, + MediaKind.episode => 4, + _ => null, + }; + return createCollectionFromUri(sectionId: libraryId, title: title, uri: uri, type: type); + } + /// Create a new collection /// Creates a new collection and optionally adds items to it /// Returns the created collection ID or null if failed - Future createCollection({ + Future createCollectionFromUri({ required String sectionId, required String title, required String uri, @@ -2193,9 +2209,18 @@ class PlexClient { } } + /// Neutral [MediaServerClient.addToCollection] — builds a Plex metadata URI + /// from [items] and delegates to [addItemsToCollectionByUri]. + @override + Future addToCollection({required String collectionId, required List items}) async { + if (items.isEmpty) return true; + final uri = await buildMetadataUri(items.map((i) => i.id).join(',')); + return addItemsToCollectionByUri(collectionId: collectionId, uri: uri); + } + /// Add items to an existing collection /// Adds one or more items (specified by URI) to an existing collection - Future addToCollection({required String collectionId, required String uri}) async { + Future addItemsToCollectionByUri({required String collectionId, required String uri}) async { appLogger.d('Adding items to collection: collectionId=$collectionId'); final result = await _wrapBoolApiCall( () => _http.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}), @@ -2209,10 +2234,11 @@ class PlexClient { /// Remove an item from a collection /// Removes a single item from an existing collection - Future removeFromCollection({required String collectionId, required String itemId}) async { - appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=$itemId'); + @override + Future removeFromCollection({required String collectionId, required MediaItem item}) async { + appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=${item.id}'); final result = await _wrapBoolApiCall( - () => _http.delete('/library/collections/$collectionId/items/$itemId'), + () => _http.delete('/library/collections/$collectionId/items/${item.id}'), 'Failed to remove item from collection', ); if (result) { @@ -2225,6 +2251,34 @@ class PlexClient { // Play Queue Methods // ============================================================================ + /// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with + /// MediaItem-typed entries. + PlayQueueResponse _parsePlayQueueResponse(dynamic data) { + final container = data is Map && data['MediaContainer'] is Map + ? data['MediaContainer'] as Map + : data as Map; + final metadata = container['Metadata']; + List? items; + if (metadata is List) { + items = [ + for (final e in metadata) + if (e is Map) PlexMappers.mediaItem(_createTaggedMetadata(e)), + ]; + } + return PlayQueueResponse( + playQueueID: (container['playQueueID'] as num).toInt(), + playQueueSelectedItemID: (container['playQueueSelectedItemID'] as num?)?.toInt(), + playQueueSelectedItemOffset: (container['playQueueSelectedItemOffset'] as num?)?.toInt(), + playQueueSelectedMetadataItemID: container['playQueueSelectedMetadataItemID'] as String?, + playQueueShuffled: flexibleBool(container['playQueueShuffled']), + playQueueSourceURI: container['playQueueSourceURI'] as String?, + playQueueTotalCount: (container['playQueueTotalCount'] as num?)?.toInt(), + playQueueVersion: (container['playQueueVersion'] as num).toInt(), + size: (container['size'] as num?)?.toInt(), + items: items, + ); + } + /// Create a new play queue /// Either uri or playlistID must be specified Future createPlayQueue({ @@ -2256,7 +2310,7 @@ class PlexClient { final response = await _http.post('/playQueues', queryParameters: queryParams); - return PlayQueueResponse.fromJson(response.data, serverId: serverId, serverName: serverName); + return _parsePlayQueueResponse(response.data); } catch (e) { appLogger.e('Failed to create play queue', error: e); return null; @@ -2285,7 +2339,7 @@ class PlexClient { final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams); - return PlayQueueResponse.fromJson(response.data, serverId: serverId, serverName: serverName); + return _parsePlayQueueResponse(response.data); } catch (e) { appLogger.e('Failed to get play queue: $e'); return null; @@ -2331,8 +2385,8 @@ class PlexClient { /// Extract both Metadata and Directory entries from response /// Folders can come back as either type /// Automatically tags all items with this client's serverId and serverName - List _extractMetadataAndDirectories(PlexResponse response) { - final List items = []; + List _extractMetadataAndDirectories(MediaServerResponse response) { + final List items = []; final container = _getMediaContainer(response); if (container != null) { @@ -2340,14 +2394,14 @@ class PlexClient { if (container['Metadata'] != null) { for (final json in container['Metadata'] as List) { try { - // Try to parse with full PlexMetadata.fromJson first + // Try to parse with full PlexMetadataDto.fromJson first items.add(_createTaggedMetadata(json)); } catch (e) { // If full parsing fails, use minimal safe parsing appLogger.d('Using minimal parsing for metadata item: $e'); try { items.add( - PlexMetadata( + PlexMetadataDto( ratingKey: json['key'] ?? json['ratingKey'] ?? '', key: json['key'] ?? '', type: json['type'] ?? 'folder', @@ -2370,13 +2424,13 @@ class PlexClient { if (container['Directory'] != null) { for (final json in container['Directory'] as List) { try { - // Try to parse as PlexMetadata first + // Try to parse as PlexMetadataDto first items.add(_createTaggedMetadata(json)); } catch (e) { // If that fails, use minimal folder representation try { items.add( - PlexMetadata( + PlexMetadataDto( ratingKey: json['key'] ?? json['ratingKey'] ?? '', key: json['key'] ?? '', type: json['type'] ?? 'folder', @@ -2400,7 +2454,7 @@ class PlexClient { /// Get root folders for a library section /// Returns the top-level folder structure for filesystem-based browsing - Future> getLibraryFolders(String sectionId) async { + Future> _getLibraryFolders(String sectionId) async { try { final response = await _getWithFailover( '/library/sections/$sectionId/folder', @@ -2415,7 +2469,7 @@ class PlexClient { /// Get children of a specific folder /// Returns files and subfolders within the given folder - Future> getFolderChildren(String folderKey) async { + Future> _getFolderChildren(String folderKey) async { try { final response = await _getWithFailover(folderKey); return _extractMetadataAndDirectories(response); @@ -2428,10 +2482,10 @@ class PlexClient { /// Get library-specific playlists /// Filters playlists by checking if they contain items from the specified library /// This is a client-side filter since the API doesn't support sectionId for playlists - Future> getLibraryPlaylists({String playlistType = 'video'}) { + Future> _getLibraryPlaylists({String playlistType = 'video'}) { // For now, return all video playlists // Future enhancement: filter by checking playlist items' library - return getPlaylists(playlistType: playlistType); + return _getPlaylists(playlistType: playlistType); } // ============================================================================ @@ -2444,6 +2498,7 @@ class PlexClient { } /// Refresh metadata for a library section + @override Future refreshLibraryMetadata(String sectionId) async { await _getWithFailover('/library/sections/$sectionId/refresh?force=1'); } @@ -2481,7 +2536,7 @@ class PlexClient { /// Get EPG channels using provider lineup endpoints (matches official Plex web client) Future> getEpgChannels({String? lineup}) async { - List parseChannels(PlexResponse response) { + List parseChannels(MediaServerResponse response) { final container = _getMediaContainer(response); if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); @@ -2511,7 +2566,7 @@ class PlexClient { } final allChannels = []; - for (final provider in _providerEpg) { + for (final provider in _epgProvidersForLineup(lineup)) { final isCloudGuide = provider.identifier.startsWith('tv.plex.providers.epg'); final legacyEndpoint = '/${provider.identifier}/lineups/dvr/channels'; @@ -2546,6 +2601,12 @@ class PlexClient { return _providerEpg; } + List<({String identifier, String gridEndpoint})> _epgProvidersForLineup(String? lineup) { + if (lineup == null || lineup.isEmpty) return _providerEpg; + final matching = _providerEpg.where((p) => p.identifier == lineup || p.gridEndpoint.contains(lineup)).toList(); + return matching.isNotEmpty ? matching : _providerEpg; + } + /// Parse a list of JSON items into [LiveTvProgram] objects, skipping any that fail. /// A single Metadata entry may carry multiple Media entries representing back-to-back /// airings of the same program on the same channel; emit one program per airing. @@ -2599,7 +2660,7 @@ class PlexClient { } /// Parse an EPG grid response into a list of [LiveTvProgram] objects. - List _parseEpgGridResponse(PlexResponse response, String providerIdentifier) { + List _parseEpgGridResponse(MediaServerResponse response, String providerIdentifier) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] is List && (container['Metadata'] as List).isNotEmpty) { appLogger.d('EPG grid sample from $providerIdentifier: ${(container['Metadata'] as List).first}'); @@ -2684,7 +2745,8 @@ class PlexClient { /// Parse a single metadata item into a [LiveTvHubEntry], or null if parsing fails. LiveTvHubEntry? _parseLiveTvHubEntry(Map itemJson) { try { - final metadata = PlexMetadata.fromJson(itemJson).copyWith(serverId: serverId, serverName: serverName); + final dto = PlexMetadataDto.fromJson(itemJson).copyWith(serverId: serverId, serverName: serverName); + final metadata = PlexMappers.mediaItem(dto); final program = LiveTvProgram.fromJson(itemJson); return LiveTvHubEntry(metadata: metadata, program: program); } catch (_) { @@ -2720,12 +2782,11 @@ class PlexClient { } } - /// Generate 24-char random alphanumeric string (matching official client format) - static String generateSessionIdentifier() { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - final rand = Random(); - return List.generate(24, (_) => chars[rand.nextInt(chars.length)]).join(); - } + /// Generate 24-char random alphanumeric string. Backend-neutral helper — + /// prefer importing `utils/session_identifier.dart` directly. This thin + /// forwarder stays for callers that already had a `PlexClient.` reference; + /// remove it once they migrate. + static String generateSessionIdentifier() => session_id.generateSessionIdentifier(); /// Coerce String values to num for fields that json_serializable expects as num. /// Plex tune responses use XML-to-JSON conversion where all values are strings. @@ -2765,7 +2826,7 @@ class PlexClient { /// to build the actual stream URL (with optional offset for time-shift). Future< ({ - PlexMetadata metadata, + PlexMetadataDto metadata, String sessionPath, String sessionIdentifier, CaptureBuffer? captureBuffer, @@ -2975,9 +3036,9 @@ class PlexClient { .join('&'); // Decision — separate client so no default X-Plex-* HTTP headers leak through. - final decisionClient = PlexHttpClient( - connectTimeout: ConnectionTimeouts.connect, - receiveTimeout: ConnectionTimeouts.receive, + final decisionClient = MediaServerHttpClient( + connectTimeout: MediaServerTimeouts.connect, + receiveTimeout: MediaServerTimeouts.receive, defaultHeaders: {'Accept-Language': 'en'}, ); final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; @@ -3010,6 +3071,15 @@ class PlexClient { } } + /// Compose a fully-qualified live stream URL from a relative + /// [streamPath] (returned by [buildLiveStreamPath]) by prefixing the + /// configured base URL and appending the Plex token. Centralizes the + /// `'${config.baseUrl}$streamPath'.withPlexToken(config.token)` pattern + /// so token placement / base-URL handling lives in one place. + String buildLiveStreamUrl(String streamPath) { + return '${config.baseUrl}$streamPath'.withPlexToken(config.token); + } + /// Checks whether the server has video transcoding enabled. /// /// Reads `transcoderVideo` from the root MediaContainer. Result is cached @@ -3156,9 +3226,9 @@ class PlexClient { final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); - final decisionClient = PlexHttpClient( - connectTimeout: ConnectionTimeouts.connect, - receiveTimeout: ConnectionTimeouts.receive, + final decisionClient = MediaServerHttpClient( + connectTimeout: MediaServerTimeouts.connect, + receiveTimeout: MediaServerTimeouts.receive, defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'}, ); final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; @@ -3249,8 +3319,8 @@ class PlexClient { } /// Get active live TV sessions - Future> getLiveTvSessions() { - return _wrapListApiCall( + Future> _getLiveTvSessions() { + return _wrapListApiCall( () => _http.get('/livetv/sessions'), _extractMetadataList, 'Failed to get live TV sessions', @@ -3261,8 +3331,8 @@ class PlexClient { static const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'}; /// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}` - Future buildFavoriteChannelSource() async { - final providers = await _discoverEpgProviders(); + Future buildFavoriteChannelSource({String? lineup}) async { + final providers = _epgProvidersForLineup(lineup); final providerIdentifier = providers.isNotEmpty ? providers.first.identifier : 'tv.plex.provider.epg'; final machineId = config.machineIdentifier ?? serverId; return 'server://$machineId/$providerIdentifier'; @@ -3312,4 +3382,743 @@ class PlexClient { await _onEndpointChanged(newBaseUrl); } } + + /// Apply a fresh per-server access token to this client *in place*. Used + /// by [MultiServerManager.refreshTokensForProfile] when switching the + /// active profile so the existing client picks up the new user's + /// identity without a teardown / reconnect. + /// + /// Updates both `config.token` and `_http.defaultHeaders` — without the + /// header refresh the next request still sends the previous user's + /// `X-Plex-Token`, so the server returns the *previous* user's view of + /// On Deck / hubs / watch state. + Future applyTokenUpdate(String newToken) async { + if (config.token == newToken) return; + config = config.copyWith(token: newToken); + _http.defaultHeaders = Map.of(config.headers); + LogRedactionManager.registerToken(newToken); + await _initMediaProviders(); + } + + // ──────────────────────────────────────────────────────────────────── + // MediaServerClient implementation + // + // These methods wrap the existing Plex-typed methods above and return + // backend-neutral types. They form a thin façade so providers and UI can + // be migrated off `PlexMetadataDto` without changing the underlying transport. + // ──────────────────────────────────────────────────────────────────── + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex.copyWith( + // Per-server probe: not every Plex install ships with a working + // transcoder (depends on Plex Pass + sufficient hardware). The + // cached value defaults to `true` until [serverSupportsVideoTranscoding] + // resolves — kicked off as a background probe at the end of + // [PlexClient.create] so the first quality-picker tap reflects + // reality on warm clients. + videoTranscoding: serverSupportsVideoTranscodingCached, + ); + + @override + Future> fetchLibraries() async { + final libraries = await _getLibraries(); + return libraries.map((l) => PlexMappers.mediaLibrary(l)).toList(); + } + + @override + Future> fetchLibraryContent(String libraryId, LibraryQuery query) async { + final filters = const PlexLibraryQueryTranslator().toQueryParameters(query); + final result = await _getLibraryContent(libraryId, start: query.offset, size: query.limit, filters: filters); + return LibraryPage( + items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), + totalCount: result.totalSize, + offset: query.offset, + ); + } + + @override + Future fetchItem(String id) async { + final metadata = await _getMetadataWithImages(id); + return metadata == null ? null : PlexMappers.mediaItem(metadata); + } + + @override + Future> fetchChildren(String parentId) async { + final children = await _getChildren(parentId); + return children.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + @override + Future> fetchPlayableDescendants(String parentId) async { + final leaves = await _getGrandchildren(parentId); + return leaves.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + /// Plex maintains episode queues server-side via `/playQueues`, so the + /// client-side window EpisodeNavigationService builds for Jellyfin isn't + /// needed here. + @override + Future?> fetchClientSideEpisodeQueue(String seriesId) async => null; + + /// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata, + /// then either runs the transcode-decision flow or returns the direct-play + /// URL. External subtitle tracks are absolutized with the server's auth + /// token; when transcoding, every source subtitle is sidecar-attached so + /// the player can hot-swap. + @override + Future getPlaybackInitialization(PlaybackInitializationOptions options) async { + try { + final data = await getVideoPlaybackData(options.metadata.id, mediaIndex: options.selectedMediaIndex); + + if (!data.hasValidVideoUrl) { + throw PlaybackException(t.messages.fileInfoNotAvailable); + } + + final wantTranscode = !options.qualityPreset.isOriginal; + if (wantTranscode && options.sessionIdentifier != null && options.transcodeSessionId != null) { + final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo); + // Note: no `offsetMs` — seeking is handled by the player via the HLS + // manifest, matching Plex Web's behavior. Baking `offset=` into the URL + // makes the server pre-position the transcoder, but the resulting + // segments and mpv's native HLS positioning fight each other, leaving + // the player clock at 0 and desyncing sidecar subtitles. + final result = await buildTranscodeStartPath( + ratingKey: options.metadata.id, + mediaIndex: options.selectedMediaIndex, + preset: options.qualityPreset, + sessionIdentifier: options.sessionIdentifier!, + transcodeSessionId: options.transcodeSessionId!, + audioStreamId: resolvedAudioId, + ); + + if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) { + final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token); + final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo); + return PlaybackInitializationResult( + availableVersions: data.availableVersions, + videoUrl: transcodeUrl, + mediaInfo: data.mediaInfo, + externalSubtitles: sidecarSubs, + isOffline: false, + isTranscoding: true, + activeAudioStreamId: resolvedAudioId, + playMethod: 'Transcode', + ); + } + + // Decision failed or said direct-play only — fall through to direct-play path + // and surface the fallback reason so the UI can notify the user. + final fallbackReason = result.outcome == TranscodeDecisionOutcome.directPlayOnly + ? TranscodeFallbackReason.directPlayOnly + : TranscodeFallbackReason.decisionFailed; + appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}'); + return PlaybackInitializationResult( + availableVersions: data.availableVersions, + videoUrl: data.videoUrl, + mediaInfo: data.mediaInfo, + externalSubtitles: _buildExternalSubtitles(data.mediaInfo), + isOffline: false, + isTranscoding: false, + fallbackReason: fallbackReason, + playMethod: 'DirectPlay', + ); + } + + return PlaybackInitializationResult( + availableVersions: data.availableVersions, + videoUrl: data.videoUrl, + mediaInfo: data.mediaInfo, + externalSubtitles: _buildExternalSubtitles(data.mediaInfo), + isOffline: false, + playMethod: 'DirectPlay', + ); + } catch (e) { + if (e is PlaybackException) rethrow; + throw PlaybackException(t.messages.errorLoading(error: e.toString())); + } + } + + /// Pick the audio stream ID to send to the transcoder. Preference order: + /// explicit [explicit] → audio track with `selected == true` → first → null. + int? _resolveAudioStreamId(int? explicit, MediaSourceInfo? info) { + if (explicit != null) return explicit; + if (info == null) return null; + final tracks = info.audioTracks; + if (tracks.isEmpty) return null; + for (final track in tracks) { + if (track.selected) return track.id; + } + return tracks.first.id; + } + + /// Build the absolute URL for an external subtitle track on this Plex + /// server. Returns `null` for tracks that aren't external (no `/library/ + /// streams/{id}` key) or when the server has no auth token. + /// + /// Used by the in-player OpenSubtitles polling flow which needs the URL + /// after the new track shows up in the metadata response. + String? buildExternalSubtitleUrl(MediaSubtitleTrack track) { + if (!track.isExternal) return null; + final token = config.token; + if (token == null) return null; + final ext = CodecUtils.getSubtitleExtension(track.codec); + return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token'; + } + + /// Sidecar URL for any subtitle track (internal or external), used in + /// transcode mode where embedded subtitle streams are stripped. Falls + /// back to `/library/streams/{id}.{ext}` when [track.key] is missing. + /// Returns `null` when no auth token is available. + String? _buildSidecarSubtitleUrl(MediaSubtitleTrack track) { + final token = config.token; + if (token == null) return null; + final ext = CodecUtils.getSubtitleExtension(track.codec); + final path = (track.key != null && track.key!.isNotEmpty) ? track.key! : '/library/streams/${track.id}'; + return '${config.baseUrl}$path.$ext?encoding=utf-8&X-Plex-Token=$token'; + } + + /// Build sidecar SubtitleTracks for ALL source subtitle streams (internal + + /// external) so the player can hot-swap between them when the main stream + /// is transcoded and has no embedded subs. + List _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) { + if (mediaInfo == null) return const []; + if (config.token == null) { + appLogger.w('No auth token available for transcode sidecar subtitles'); + return const []; + } + + final tracks = []; + for (final sub in mediaInfo.subtitleTracks) { + try { + final url = _buildSidecarSubtitleUrl(sub); + if (url == null) continue; + tracks.add( + SubtitleTrack.uri( + url, + title: sub.displayTitle ?? sub.language ?? 'Track ${sub.id}', + language: sub.languageCode, + ), + ); + } catch (e) { + appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e); + } + } + return tracks; + } + + /// Build list of external subtitle tracks from media info + List _buildExternalSubtitles(MediaSourceInfo? mediaInfo) { + final externalSubtitles = []; + + if (mediaInfo == null) { + return externalSubtitles; + } + + final externalTracks = mediaInfo.subtitleTracks.where((MediaSubtitleTrack track) => track.isExternal).toList(); + + if (externalTracks.isNotEmpty) { + appLogger.d('Found ${externalTracks.length} external subtitle track(s)'); + } + + for (final plexTrack in externalTracks) { + try { + final url = buildExternalSubtitleUrl(plexTrack); + if (url == null) { + appLogger.w('Could not build URL for external subtitle ${plexTrack.id}'); + continue; + } + + externalSubtitles.add( + SubtitleTrack.uri( + url, + title: plexTrack.displayTitle ?? plexTrack.language ?? 'Track ${plexTrack.id}', + language: plexTrack.languageCode, + ), + ); + } catch (e) { + appLogger.w('Failed to add external subtitle track ${plexTrack.id}', error: e); + } + } + + return externalSubtitles; + } + + /// Plex's filter listing is lazy: categories come from + /// `/library/sections/{id}/filters` and values are fetched per category + /// when the user opens a filter. The result has empty [LibraryFilterResult.cachedValues]; + /// the FiltersBottomSheet hits the per-category endpoint on demand. + @override + Future fetchLibraryFiltersWithValues(String libraryId) async { + final filters = await getLibraryFilters(libraryId); + return LibraryFilterResult(filters: filters, cachedValues: const {}); + } + + @override + Future fetchPlaybackExtras( + String itemId, { + String? introPattern, + String? creditsPattern, + bool forceRefresh = false, + }) => + getPlaybackExtras(itemId, introPattern: introPattern, creditsPattern: creditsPattern, forceRefresh: forceRefresh); + + @override + Future fetchPlaybackExtrasFromCacheOnly( + String itemId, { + String? introPattern, + String? creditsPattern, + }) async { + final cached = await cache.get(serverId, '/library/metadata/$itemId'); + if (cached == null) return null; + final metadataJson = _getFirstMetadataJsonFromData(cached); + if (metadataJson == null) return null; + return _parsePlaybackExtrasFromMetadataJson( + metadataJson, + introPattern: introPattern, + creditsPattern: creditsPattern, + ); + } + + @override + Future fetchCachedMediaSourceInfo(String itemId) async { + final cached = await cache.get(serverId, '/library/metadata/$itemId'); + if (cached == null) return null; + final metadataJson = _getFirstMetadataJsonFromData(cached); + if (metadataJson == null) return null; + return plexMediaSourceInfoFromCacheJson(metadataJson); + } + + @override + Future createScrubPreviewSource({ + required MediaItem item, + required MediaSourceInfo mediaSource, + }) async { + if (!capabilities.scrubThumbnails) return null; + final partId = mediaSource.partId; + if (partId == null) return null; + final service = BifThumbnailService(); + try { + await service.load(this, partId); + return service; + } catch (e, st) { + appLogger.w('BIF thumbnail load failed for part $partId', error: e, stackTrace: st); + service.dispose(); + return null; + } + } + + @override + Future> fetchLibraryPagedContent( + String libraryId, { + required LibraryQuery query, + MediaKind? libraryKind, + AbortController? abort, + }) async { + // Translate the neutral query back to Plex's flat key=value map. Plex's + // section endpoint takes filters verbatim — `PlexLibraryQueryTranslator` + // emits both typed slots (genre/year/contentRating/tag/alphaPrefix) and + // generic `query.filters` entries, matching what the legacy + // `plexStyleFilters` map carried. + final filters = const PlexLibraryQueryTranslator().toQueryParameters(query); + // Browse tab always asked for collections; preserve as Plex's default + // server behaviour can vary across versions. + filters['includeCollections'] = '1'; + final result = await fetchLibraryPage( + libraryId, + start: query.offset, + size: query.limit, + filters: filters, + abort: abort, + ); + return LibraryPage(items: result.items, totalCount: result.totalSize, offset: query.offset); + } + + @override + Future> fetchFirstCharacters(String libraryId, {Map? filters}) async { + return getFirstCharacters(libraryId, filters: filters); + } + + @override + Future> searchItems(String query, {int limit = 30}) async { + final results = await _search(query, limit: limit); + return results.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + @override + Future> fetchRecentlyAdded({int limit = 50}) async { + final items = await _getRecentlyAdded(limit: limit); + return items.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + @override + Future> fetchContinueWatching({int count = 20}) async { + final items = await _getContinueWatching(count: count); + return items.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + @override + Future> fetchGlobalHubs({int limit = 10}) async { + final hubs = await _getGlobalHubs(limit: limit); + return hubs.map((h) => PlexMappers.mediaHub(h)).toList(); + } + + @override + Future> fetchLibraryHubs(String libraryId, {int limit = 10}) async { + final hubs = await _getLibraryHubs(libraryId, limit: limit); + return hubs.map((h) => PlexMappers.mediaHub(h)).toList(); + } + + @override + Future> fetchRelatedHubs(String id, {int count = 10}) async { + final hubs = await _getRelatedHubs(id, count: count); + return hubs.map((h) => PlexMappers.mediaHub(h)).toList(); + } + + @override + Future markWatched(MediaItem item) => markAsWatched(item.id, item: item); + + @override + Future markUnwatched(MediaItem item) => markAsUnwatched(item.id, item: item); + + @override + Future removeFromContinueWatching(MediaItem item) => removeFromOnDeck(item.id); + + /// Rate a media item (0.0-10.0 scale, where each integer = half a star). + /// Pass `-1` to clear an existing rating. Throws [MediaServerHttpException] + /// on non-2xx — call sites surface a snackbar on the catch arm. + @override + Future rate(MediaItem item, double rating) async { + final response = await _http.put( + '/:/rate', + queryParameters: {'key': item.id, 'identifier': 'com.plexapp.plugins.library', 'rating': rating}, + ); + throwIfHttpError(response); + } + + @override + Future> fetchPlaylists({String playlistType = 'video', bool? smart}) async { + final playlists = await _getPlaylists(playlistType: playlistType, smart: smart); + return playlists.map((p) => PlexMappers.mediaPlaylist(p)).toList(); + } + + @override + Future fetchPlaylistMetadata(String id) async { + final p = await _getPlaylistMetadata(id); + return p == null ? null : PlexMappers.mediaPlaylist(p); + } + + @override + Future> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async { + final result = await _getPlaylist(id, start: offset, size: limit); + return result.items.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + /// Plex-specific: paginated playlist content. Returns neutral [MediaItem]s. + /// The total size from the server is needed for paginated UI; tests in + /// `playlist_detail_screen.dart` rely on this. + Future<({List items, int totalSize})> fetchPlaylistPage( + String playlistId, { + int? start, + int? size, + AbortController? abort, + }) async { + final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort); + return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize); + } + + @override + Future> fetchCollections(String libraryId) async { + final raw = await _getLibraryCollections(libraryId); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + @override + Future> fetchCollectionPage( + String collectionId, { + int? start, + int? size, + AbortController? abort, + }) async { + final result = await _getCollectionItems(collectionId, start: start, size: size, abort: abort); + return LibraryPage( + items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), + totalCount: result.totalSize, + offset: start ?? 0, + ); + } + + /// Plex-specific: full collection contents across pages. + Future> fetchAllCollectionItemsAsMediaItems(String collectionId) async { + final raw = await _fetchAllCollectionItemsDto(collectionId); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + /// Plex-specific: full playlist contents across pages. + Future> fetchAllPlaylistItemsAsMediaItems(String playlistId) async { + final raw = await _fetchAllPlaylistItemsDto(playlistId); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + /// Plex-specific: paginated person-media listing. + Future<({List items, int totalSize})> fetchPersonMediaPage( + String personId, { + int? start, + int? size, + AbortController? abort, + }) async { + final result = await _getPersonMedia(personId, start: start, size: size, abort: abort); + return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize); + } + + /// Plex-specific: full person-media listing across pages. + Future> fetchAllPersonMediaAsMediaItems(String personId) async { + final raw = await _fetchAllPersonMediaDto(personId); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + /// Plex-specific: hub content as neutral [MediaItem]s. + Future> fetchHubContent(String hubKey) async { + final raw = await _getHubContent(hubKey); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + @override + Future> fetchMoreHubItems(String hubId, {int? limit}) => fetchHubContent(hubId); + + /// Plex-specific: top-level folders in a library. + Future> fetchLibraryFolders(String sectionId) async { + final raw = await _getLibraryFolders(sectionId); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + /// Plex-specific: contents of a folder (files and subfolders). + Future> fetchFolderChildren(String folderKey) async { + final raw = await _getFolderChildren(folderKey); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + /// Plex-specific: extras (trailers, behind-the-scenes) for a media item. + Future> fetchExtras(String ratingKey) async { + final raw = await _getExtras(ratingKey); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + /// Plex-specific: live TV sessions (active recordings/playback). + Future> fetchLiveTvSessions() async { + final raw = await _getLiveTvSessions(); + return raw.map((m) => PlexMappers.mediaItem(m)).toList(); + } + + /// Plex-specific: library-scoped playlists. + Future> fetchLibraryPlaylists({String playlistType = 'video'}) async { + final raw = await _getLibraryPlaylists(playlistType: playlistType); + return raw.map((p) => PlexMappers.mediaPlaylist(p)).toList(); + } + + /// Plex-specific: paginated library content with raw Plex filter map, + /// returning neutral [MediaItem]s. The aggregation bridge uses this when it + /// has Plex-specific filter strings (`unwatched=1`, `genre=...`) to forward. + Future<({List items, int totalSize})> fetchLibraryPage( + String sectionId, { + int? start, + int? size, + Map? filters, + AbortController? abort, + }) async { + final result = await _getLibraryContent(sectionId, start: start, size: size, filters: filters, abort: abort); + return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize); + } + + /// Full item with on-deck episode from a single `/library/metadata/{id}` + /// round-trip. Implements [MediaServerClient.fetchItemWithOnDeck]; + /// Jellyfin has no analogous endpoint and returns onDeck=null there. + @override + Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async { + final result = await getMetadataWithImagesAndOnDeck(id); + final itemDto = result['metadata'] as PlexMetadataDto?; + final onDeckDto = result['onDeckEpisode'] as PlexMetadataDto?; + return ( + item: itemDto == null ? null : PlexMappers.mediaItem(itemDto), + onDeckEpisode: onDeckDto == null ? null : PlexMappers.mediaItem(onDeckDto), + ); + } + + @override + String thumbnailUrl(String? path, {int? width, int? height}) { + if (path == null || path.isEmpty) return ''; + // No sizing requested, or already-processed/external URL — passthrough. + if (width == null && height == null) return getThumbnailUrl(path); + if (path.startsWith('http://') || path.startsWith('https://')) { + // External URLs route through [externalImageUrl] for proxying. + // Direct callers without sizing get the raw URL. + return getThumbnailUrl(path); + } + final token = config.token; + if (token == null) return getThumbnailUrl(path); + final encoded = Uri.encodeComponent(path.withPlexToken(token)); + final parts = [ + if (width != null) 'width=$width', + if (height != null) 'height=$height', + 'minSize=1', + 'upscale=1', + 'url=$encoded', + 'X-Plex-Token=$token', + ]; + return '${config.baseUrl}/photo/:/transcode?${parts.join('&')}'; + } + + @override + String externalImageUrl(String url, {int? width, int? height}) { + final token = config.token; + if (token == null || (width == null && height == null)) return url; + final encoded = Uri.encodeComponent(url); + final parts = [ + if (width != null) 'width=$width', + if (height != null) 'height=$height', + 'minSize=1', + 'upscale=1', + 'url=$encoded', + 'X-Plex-Token=$token', + ]; + return '${config.baseUrl}/photo/:/transcode?${parts.join('&')}'; + } + + @override + double get watchedThreshold => watchedThresholdPercent / 100.0; + + @override + Map get streamHeaders => Map.unmodifiable(config.headers); + + @override + Future fetchExternalIds(String itemId) async { + final guids = await fetchExternalGuids(itemId); + return ExternalIds.fromGuids(guids); + } + + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) => updateProgress(itemId, time: position.inMilliseconds, state: 'playing', duration: duration?.inMilliseconds); + + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) => updateProgress( + itemId, + time: position.inMilliseconds, + state: isPaused ? 'paused' : 'playing', + duration: duration.inMilliseconds, + ); + + @override + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + }) => updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds); + + @override + LiveTvSupport get liveTv => _PlexLiveTvSupport(this); + + // ── Downloads ──────────────────────────────────────────────────── + + @override + Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async { + final playbackData = await getVideoPlaybackData(item.id, mediaIndex: mediaIndex); + return playbackData.hasValidVideoUrl ? playbackData.videoUrl : null; + } + + @override + Future resolveDownload(MediaItem item, {int mediaIndex = 0}) async { + final playbackData = await getVideoPlaybackData(item.id, mediaIndex: mediaIndex); + final subtitles = []; + final mediaInfo = playbackData.mediaInfo; + if (mediaInfo != null) { + for (final subtitle in mediaInfo.subtitleTracks) { + if (!subtitle.isExternal || subtitle.key == null) continue; + final url = buildExternalSubtitleUrl(subtitle); + if (url == null) continue; + subtitles.add( + DownloadSubtitleSpec( + id: subtitle.id, + url: url, + codec: subtitle.codec, + language: subtitle.language, + languageCode: subtitle.languageCode, + forced: subtitle.forced, + displayTitle: subtitle.displayTitle, + ), + ); + } + } + return DownloadResolution(videoUrl: playbackData.videoUrl, externalSubtitles: subtitles); + } + + @override + List resolveDownloadArtwork(MediaItem item) { + return buildArtworkSpecs(item, getThumbnailUrl); + } +} + +/// Plex implementation of [LiveTvSupport] — wraps the existing per-DVR +/// methods. The legacy `tuneChannel` / `buildLiveStreamPath` flow remains on +/// [PlexClient] itself because the player consumes those rich session +/// outputs directly; [resolveStreamUrl] returns `null` so callers route +/// through `client + dvrKey`. +class _PlexLiveTvSupport implements LiveTvSupport { + final PlexClient _client; + _PlexLiveTvSupport(this._client); + + @override + Future isAvailable() => _client.hasDvr(); + + @override + Future> fetchDvrs() => _client.getDvrs(); + + @override + Future> fetchChannels({String? lineup}) => _client.getEpgChannels(lineup: lineup); + + @override + Future> fetchSchedule({DateTime? from, DateTime? to}) { + int? toEpoch(DateTime? dt) => dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; + return _client.getEpgGrid(beginsAt: toEpoch(from), endsAt: toEpoch(to)); + } + + @override + Future resolveStreamUrl(String channelKey, {String? dvrKey}) async => null; + + @override + Future buildFavoriteChannelSource({String? lineup}) => _client.buildFavoriteChannelSource(lineup: lineup); + + @override + String get favoriteStoreKey => 'plex:${_client.config.clientIdentifier}'; + + @override + FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.sharedFullList; + + @override + Future> fetchFavoriteChannels() => _client.getFavoriteChannels(); + + @override + Future setFavoriteChannels(List channels) => _client.setFavoriteChannels(channels); } diff --git a/lib/services/plex_constants.dart b/lib/services/plex_constants.dart new file mode 100644 index 00000000..73cdd27e --- /dev/null +++ b/lib/services/plex_constants.dart @@ -0,0 +1,32 @@ +/// Plex `streamType` integer codes used in the `Stream` array on a Part. +/// +/// Lifted into a shared module so [plex_mappers.dart] doesn't need to +/// reach back into [plex_client.dart] (which would close a circular import +/// — the mapper file is consumed by the client). The values match what +/// the Plex Media Server API returns; do not renumber. +class PlexStreamType { + static const int video = 1; + static const int audio = 2; + static const int subtitle = 3; +} + +/// Plex metadata `type` integer codes — the value that goes in the +/// `?type=` query param on `/library/sections/{id}/all` and friends. +/// +/// Centralised here so call sites can reference a named constant instead +/// of an inline magic number. Verified against +/// `library_query_translator.dart`'s switch. +class PlexMetadataType { + static const int movie = 1; + static const int show = 2; + static const int season = 3; + static const int episode = 4; + static const int artist = 8; + static const int album = 9; + static const int track = 10; + + /// `type=1,2,3,4` — the standard "everything except music" filter used + /// by the All / shared-library views, where music libraries surface as + /// their own top-level kind. + static const String videoCsv = '1,2,3,4'; +} diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart new file mode 100644 index 00000000..105f0d76 --- /dev/null +++ b/lib/services/plex_mappers.dart @@ -0,0 +1,1123 @@ +// Pure JSON/DTO→neutral-type mappers for Plex. Mirrors [JellyfinMappers]. +// +// The DTO layer ([PlexMetadataDto] etc.) is a typed shim over the raw +// `/library/metadata` JSON shape that exists so the Plex-specific quirks +// (heterogeneous tags, obfuscation, the OnDeck nesting) can be handled +// once. The [PlexMappers] class is a thin public wrapper that converts +// either parsed DTOs or raw JSON into the neutral +// [MediaItem] / [MediaLibrary] / [MediaHub] / [MediaPlaylist] types. +// +// Pure: no HTTP, no client state, no token-aware image-URL resolution. +// The client wraps the static methods with per-instance image-URL +// resolution and server-tagging. + +import 'package:sentry_flutter/sentry_flutter.dart'; + +import '../media/media_backend.dart'; +import '../media/media_hub.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_library.dart'; +import '../media/media_part.dart'; +import '../media/media_playlist.dart'; +import '../media/media_role.dart'; +import '../media/media_source_info.dart'; +import '../media/media_version.dart'; +import '../utils/app_logger.dart'; +import '../utils/global_key_utils.dart'; +import '../utils/json_utils.dart'; +import 'plex_constants.dart'; +import '../utils/obfuscation_utils.dart'; + +/// Shared suffix of both unmatched-agent URL schemes: legacy +/// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`. +const _unmatchedAgentMarker = 'agents.none://'; + +List? _tagsFromJsonList(List? json) => + json?.cast>().map((e) => e['tag'] as String).toList(); + +bool? _flexibleBoolNullable(Object? v) => switch (v) { + final bool b => b, + final int n => n == 1, + final String s => s == '1', + _ => null, +}; + +class PlexRoleDto { + final int? id; + final String? filter; + final String tag; + final String? tagKey; + final String? role; + final String? thumb; + final int? count; + + const PlexRoleDto({this.id, this.filter, required this.tag, this.tagKey, this.role, this.thumb, this.count}); + + factory PlexRoleDto.fromJson(Map json) => PlexRoleDto( + id: flexibleInt(json['id']), + filter: json['filter'] as String?, + tag: json['tag'] as String, + tagKey: json['tagKey'] as String?, + role: json['role'] as String?, + thumb: json['thumb'] as String?, + count: flexibleInt(json['count']), + ); +} + +class PlexMediaVersionDto { + final int id; + final String? videoResolution; + final String? videoCodec; + final int? bitrate; + final int? width; + final int? height; + final String? container; + final String partKey; + final bool? accessible; + final bool? exists; + + const PlexMediaVersionDto({ + required this.id, + this.videoResolution, + this.videoCodec, + this.bitrate, + this.width, + this.height, + this.container, + required this.partKey, + this.accessible, + this.exists, + }); + + factory PlexMediaVersionDto.fromJson(Map json) { + final parts = flexibleList(json['Part']); + final part = parts != null && parts.isNotEmpty && parts.first is Map ? parts.first as Map : null; + final partKey = part?['key']?.toString() ?? ''; + return PlexMediaVersionDto( + id: flexibleInt(json['id']) ?? 0, + videoResolution: json['videoResolution']?.toString(), + videoCodec: json['videoCodec']?.toString(), + bitrate: flexibleInt(json['bitrate']), + width: flexibleInt(json['width']), + height: flexibleInt(json['height']), + container: json['container']?.toString(), + partKey: partKey, + accessible: _flexibleBoolNullable(part?['accessible']), + exists: _flexibleBoolNullable(part?['exists']), + ); + } +} + +class PlexLibraryDto { + final String key; + final String title; + final String type; + final String? agent; + final String? scanner; + final String? language; + final String? uuid; + final int? updatedAt; + final int? createdAt; + final int? hidden; + final String? serverId; + final String? serverName; + final bool isShared; + + const PlexLibraryDto({ + required this.key, + required this.title, + required this.type, + this.agent, + this.scanner, + this.language, + this.uuid, + this.updatedAt, + this.createdAt, + this.hidden, + this.serverId, + this.serverName, + this.isShared = false, + }); + + factory PlexLibraryDto.fromJson(Map json) { + return PlexLibraryDto( + key: json['key']?.toString() ?? '', + title: json['title'] as String? ?? '', + type: json['type'] as String? ?? '', + agent: json['agent'] as String?, + scanner: json['scanner'] as String?, + language: json['language'] as String?, + uuid: json['uuid'] as String?, + updatedAt: flexibleInt(json['updatedAt']), + createdAt: flexibleInt(json['createdAt']), + hidden: flexibleInt(json['hidden']), + ); + } + + PlexLibraryDto copyWith({String? serverId, String? serverName, bool? isShared}) { + return PlexLibraryDto( + key: key, + title: title, + type: type, + agent: agent, + scanner: scanner, + language: language, + uuid: uuid, + updatedAt: updatedAt, + createdAt: createdAt, + hidden: hidden, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + isShared: isShared ?? this.isShared, + ); + } + + String get globalKey => serverId != null ? buildGlobalKey(serverId!, key) : key; +} + +class PlexPlaylistDto { + final String ratingKey; + final String key; + final String type; + final String title; + final String? summary; + final bool smart; + final String playlistType; + final int? duration; + final int? leafCount; + final String? composite; + final int? addedAt; + final int? updatedAt; + final int? lastViewedAt; + final int? viewCount; + final String? content; + final String? guid; + final String? thumb; + final String? serverId; + final String? serverName; + + const PlexPlaylistDto({ + required this.ratingKey, + required this.key, + required this.type, + required this.title, + this.summary, + required this.smart, + required this.playlistType, + this.duration, + this.leafCount, + this.composite, + this.addedAt, + this.updatedAt, + this.lastViewedAt, + this.viewCount, + this.content, + this.guid, + this.thumb, + this.serverId, + this.serverName, + }); + + factory PlexPlaylistDto.fromJson(Map json) { + String? title = json['title'] as String?; + String? summary = json['summary'] as String?; + if (kBlurArtwork) { + if (title != null) title = obfuscateText(title); + if (summary != null) summary = obfuscateText(summary); + } + return PlexPlaylistDto( + ratingKey: json['ratingKey']?.toString() ?? '', + key: json['key'] as String? ?? '', + type: json['type'] as String? ?? '', + title: title ?? '', + summary: summary, + smart: json['smart'] as bool? ?? false, + playlistType: json['playlistType'] as String? ?? '', + duration: flexibleInt(json['duration']), + leafCount: flexibleInt(json['leafCount']), + composite: json['composite'] as String?, + addedAt: flexibleInt(json['addedAt']), + updatedAt: flexibleInt(json['updatedAt']), + lastViewedAt: flexibleInt(json['lastViewedAt']), + viewCount: flexibleInt(json['viewCount']), + content: json['content'] as String?, + guid: json['guid'] as String?, + thumb: json['thumb'] as String?, + ); + } + + PlexPlaylistDto copyWith({String? serverId, String? serverName}) { + return PlexPlaylistDto( + ratingKey: ratingKey, + key: key, + type: type, + title: title, + summary: summary, + smart: smart, + playlistType: playlistType, + duration: duration, + leafCount: leafCount, + composite: composite, + addedAt: addedAt, + updatedAt: updatedAt, + lastViewedAt: lastViewedAt, + viewCount: viewCount, + content: content, + guid: guid, + thumb: thumb, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + ); + } +} + +class PlexHubDto { + final String hubKey; + final String title; + final String type; + final String? hubIdentifier; + final int size; + final bool more; + final List items; + final String? serverId; + final String? serverName; + + const PlexHubDto({ + required this.hubKey, + required this.title, + required this.type, + this.hubIdentifier, + required this.size, + required this.more, + required this.items, + this.serverId, + this.serverName, + }); + + factory PlexHubDto.fromJson(Map json, {String? serverId, String? serverName}) { + final items = []; + void parseEntries(List? entries, {bool isDirectory = false}) { + if (entries == null) return; + for (final item in entries) { + try { + Map entry = item as Map; + if (isDirectory && !entry.containsKey('type')) { + entry = Map.from(entry); + entry['type'] = (entry.containsKey('leafCount') || entry.containsKey('childCount')) ? 'show' : 'folder'; + } + var parsed = PlexMetadataDto.fromJsonWithImages(entry); + if (serverId != null || serverName != null) { + parsed = parsed.copyWith(serverId: serverId, serverName: serverName); + } + items.add(parsed); + } catch (_) { + // Skip items that fail to parse + } + } + } + + parseEntries(json['Metadata'] as List?); + parseEntries(json['Directory'] as List?, isDirectory: true); + + return PlexHubDto( + hubKey: json['key'] as String? ?? '', + title: kBlurArtwork + ? obfuscateText(json['title'] as String? ?? 'Unknown') + : json['title'] as String? ?? 'Unknown', + type: json['type'] as String? ?? 'hub', + hubIdentifier: json['hubIdentifier'] as String?, + size: flexibleInt(json['size']) ?? items.length, + more: flexibleBool(json['more']), + items: items, + serverId: serverId, + serverName: serverName, + ); + } +} + +class PlexMetadataDto { + final String ratingKey; + final String? key; + final String? guid; + final String? studio; + final String? type; + final String? title; + final String? titleSort; + final String? contentRating; + final String? summary; + final double? rating; + final double? audienceRating; + final double? userRating; + final int? year; + final String? originallyAvailableAt; + final String? thumb; + final String? art; + final int? duration; + final int? addedAt; + final int? updatedAt; + final int? lastViewedAt; + final String? grandparentTitle; + final String? grandparentThumb; + final String? grandparentArt; + final String? grandparentRatingKey; + final String? parentTitle; + final String? parentThumb; + final String? parentRatingKey; + final int? parentIndex; + final int? index; + final String? grandparentTheme; + final int? viewOffset; + final int? viewCount; + final int? leafCount; + final int? viewedLeafCount; + final int? childCount; + final List? role; + final List? mediaVersions; + final List? genre; + final List? director; + final List? writer; + final List? producer; + final List? country; + final List? collection; + final List? label; + final List? style; + final List? mood; + final String? audioLanguage; + final String? subtitleLanguage; + final int? subtitleMode; + final int? playlistItemID; + final int? playQueueItemID; + final int? librarySectionID; + final String? librarySectionTitle; + final String? ratingImage; + final String? audienceRatingImage; + final String? tagline; + final String? originalTitle; + final String? editionTitle; + final String? subtype; + final int? extraType; + final String? primaryExtraKey; + final String? serverId; + final String? serverName; + final String? clearLogo; + final String? backgroundSquare; + + const PlexMetadataDto({ + required this.ratingKey, + this.key, + this.guid, + this.studio, + this.type, + this.title, + this.titleSort, + this.contentRating, + this.summary, + this.rating, + this.audienceRating, + this.userRating, + this.year, + this.originallyAvailableAt, + this.thumb, + this.art, + this.duration, + this.addedAt, + this.updatedAt, + this.lastViewedAt, + this.grandparentTitle, + this.grandparentThumb, + this.grandparentArt, + this.grandparentRatingKey, + this.parentTitle, + this.parentThumb, + this.parentRatingKey, + this.parentIndex, + this.index, + this.grandparentTheme, + this.viewOffset, + this.viewCount, + this.leafCount, + this.viewedLeafCount, + this.childCount, + this.role, + this.mediaVersions, + this.genre, + this.director, + this.writer, + this.producer, + this.country, + this.collection, + this.label, + this.style, + this.mood, + this.audioLanguage, + this.subtitleLanguage, + this.subtitleMode, + this.playlistItemID, + this.playQueueItemID, + this.librarySectionID, + this.librarySectionTitle, + this.ratingImage, + this.audienceRatingImage, + this.tagline, + this.originalTitle, + this.editionTitle, + this.subtype, + this.extraType, + this.primaryExtraKey, + this.serverId, + this.serverName, + this.clearLogo, + this.backgroundSquare, + }); + + factory PlexMetadataDto.fromJson(Map rawJson) { + final json = kBlurArtwork ? _obfuscateJson(rawJson) : rawJson; + try { + final roleList = (json['Role'] as List?)?.map((e) => PlexRoleDto.fromJson(e as Map)).toList(); + final mediaList = (json['Media'] as List?) + ?.map((e) => PlexMediaVersionDto.fromJson(e as Map)) + .toList(); + return PlexMetadataDto( + ratingKey: (json['ratingKey'] ?? json['key'] ?? '').toString(), + key: json['key'] as String?, + guid: json['guid'] as String?, + studio: json['studio'] as String?, + type: json['type'] as String?, + title: json['title'] as String?, + titleSort: json['titleSort'] as String?, + contentRating: json['contentRating'] as String?, + summary: json['summary'] as String?, + rating: (json['rating'] as num?)?.toDouble(), + audienceRating: (json['audienceRating'] as num?)?.toDouble(), + userRating: (json['userRating'] as num?)?.toDouble(), + year: flexibleInt(json['year']), + originallyAvailableAt: json['originallyAvailableAt'] as String?, + thumb: json['thumb'] as String?, + art: json['art'] as String?, + duration: flexibleInt(json['duration']), + addedAt: flexibleInt(json['addedAt']), + updatedAt: flexibleInt(json['updatedAt']), + lastViewedAt: flexibleInt(json['lastViewedAt']), + grandparentTitle: json['grandparentTitle'] as String?, + grandparentThumb: json['grandparentThumb'] as String?, + grandparentArt: json['grandparentArt'] as String?, + grandparentRatingKey: json['grandparentRatingKey']?.toString(), + parentTitle: json['parentTitle'] as String?, + parentThumb: json['parentThumb'] as String?, + parentRatingKey: json['parentRatingKey']?.toString(), + parentIndex: flexibleInt(json['parentIndex']), + index: flexibleInt(json['index']), + grandparentTheme: json['grandparentTheme'] as String?, + viewOffset: flexibleInt(json['viewOffset']), + viewCount: flexibleInt(json['viewCount']), + leafCount: flexibleInt(json['leafCount']), + viewedLeafCount: flexibleInt(json['viewedLeafCount']), + childCount: flexibleInt(json['childCount']), + role: roleList, + mediaVersions: mediaList, + genre: _tagsFromJsonList(json['Genre'] as List?), + director: _tagsFromJsonList(json['Director'] as List?), + writer: _tagsFromJsonList(json['Writer'] as List?), + producer: _tagsFromJsonList(json['Producer'] as List?), + country: _tagsFromJsonList(json['Country'] as List?), + collection: _tagsFromJsonList(json['Collection'] as List?), + label: _tagsFromJsonList(json['Label'] as List?), + style: _tagsFromJsonList(json['Style'] as List?), + mood: _tagsFromJsonList(json['Mood'] as List?), + audioLanguage: json['audioLanguage'] as String?, + subtitleLanguage: json['subtitleLanguage'] as String?, + subtitleMode: flexibleInt(json['subtitleMode']), + playlistItemID: flexibleInt(json['playlistItemID']), + playQueueItemID: flexibleInt(json['playQueueItemID']), + librarySectionID: flexibleInt(json['librarySectionID']), + librarySectionTitle: json['librarySectionTitle'] as String?, + ratingImage: json['ratingImage'] as String?, + audienceRatingImage: json['audienceRatingImage'] as String?, + tagline: json['tagline'] as String?, + originalTitle: json['originalTitle'] as String?, + editionTitle: json['editionTitle'] as String?, + subtype: json['subtype'] as String?, + extraType: flexibleInt(json['extraType']), + primaryExtraKey: json['primaryExtraKey'] as String?, + clearLogo: json['clearLogo'] as String?, + backgroundSquare: json['backgroundSquare'] as String?, + ); + } on TypeError catch (e, st) { + Sentry.captureException( + e, + stackTrace: st, + withScope: (scope) { + scope.setContexts('json', json); + }, + ); + rethrow; + } + } + + factory PlexMetadataDto.fromJsonWithImages(Map json) { + String? clearLogoUrl; + String? backgroundSquareUrl; + final images = json['Image'] as List?; + if (images != null) { + for (final image in images) { + if (image is Map) { + final type = image['type']; + final url = image['url'] as String?; + if (url == null) continue; + if (type == 'clearLogo') clearLogoUrl = url; + if (type == 'backgroundSquare') backgroundSquareUrl = url; + } + } + } + if (clearLogoUrl == null && backgroundSquareUrl == null) { + return PlexMetadataDto.fromJson(json); + } + final enriched = Map.from(json); + if (clearLogoUrl != null) enriched['clearLogo'] = clearLogoUrl; + if (backgroundSquareUrl != null) enriched['backgroundSquare'] = backgroundSquareUrl; + return PlexMetadataDto.fromJson(enriched); + } + + static Map _obfuscateJson(Map json) { + final copy = Map.from(json); + for (final key in const ['title', 'summary', 'tagline', 'grandparentTitle', 'parentTitle', 'studio']) { + if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String); + } + return copy; + } + + String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey; + + bool get isLibrarySection => key != null && key!.startsWith('/library/sections/'); + + bool get isUnmatched => guid == null || guid!.isEmpty || guid!.contains(_unmatchedAgentMarker); + + /// Top-level scalar fields surface as a plain Plex JSON map. Used by the + /// download-manager cache layer to overlay scalar updates on top of an + /// existing Plex response without losing Chapter/Marker/Media arrays. + Map toJson() { + return { + 'ratingKey': ratingKey, + if (key != null) 'key': key, + if (guid != null) 'guid': guid, + if (studio != null) 'studio': studio, + if (type != null) 'type': type, + if (title != null) 'title': title, + if (titleSort != null) 'titleSort': titleSort, + if (contentRating != null) 'contentRating': contentRating, + if (summary != null) 'summary': summary, + if (rating != null) 'rating': rating, + if (audienceRating != null) 'audienceRating': audienceRating, + if (userRating != null) 'userRating': userRating, + if (year != null) 'year': year, + if (originallyAvailableAt != null) 'originallyAvailableAt': originallyAvailableAt, + if (thumb != null) 'thumb': thumb, + if (art != null) 'art': art, + if (duration != null) 'duration': duration, + if (addedAt != null) 'addedAt': addedAt, + if (updatedAt != null) 'updatedAt': updatedAt, + if (lastViewedAt != null) 'lastViewedAt': lastViewedAt, + if (grandparentTitle != null) 'grandparentTitle': grandparentTitle, + if (grandparentThumb != null) 'grandparentThumb': grandparentThumb, + if (grandparentArt != null) 'grandparentArt': grandparentArt, + if (grandparentRatingKey != null) 'grandparentRatingKey': grandparentRatingKey, + if (parentTitle != null) 'parentTitle': parentTitle, + if (parentThumb != null) 'parentThumb': parentThumb, + if (parentRatingKey != null) 'parentRatingKey': parentRatingKey, + if (parentIndex != null) 'parentIndex': parentIndex, + if (index != null) 'index': index, + if (grandparentTheme != null) 'grandparentTheme': grandparentTheme, + if (viewOffset != null) 'viewOffset': viewOffset, + if (viewCount != null) 'viewCount': viewCount, + if (leafCount != null) 'leafCount': leafCount, + if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount, + if (childCount != null) 'childCount': childCount, + if (audioLanguage != null) 'audioLanguage': audioLanguage, + if (subtitleLanguage != null) 'subtitleLanguage': subtitleLanguage, + if (subtitleMode != null) 'subtitleMode': subtitleMode, + if (playlistItemID != null) 'playlistItemID': playlistItemID, + if (playQueueItemID != null) 'playQueueItemID': playQueueItemID, + if (librarySectionID != null) 'librarySectionID': librarySectionID, + if (librarySectionTitle != null) 'librarySectionTitle': librarySectionTitle, + if (ratingImage != null) 'ratingImage': ratingImage, + if (audienceRatingImage != null) 'audienceRatingImage': audienceRatingImage, + if (tagline != null) 'tagline': tagline, + if (originalTitle != null) 'originalTitle': originalTitle, + if (editionTitle != null) 'editionTitle': editionTitle, + if (subtype != null) 'subtype': subtype, + if (extraType != null) 'extraType': extraType, + if (primaryExtraKey != null) 'primaryExtraKey': primaryExtraKey, + if (clearLogo != null) 'clearLogo': clearLogo, + if (backgroundSquare != null) 'backgroundSquare': backgroundSquare, + }; + } + + PlexMetadataDto copyWith({ + String? ratingKey, + String? key, + String? guid, + String? studio, + String? type, + String? title, + String? titleSort, + String? contentRating, + String? summary, + double? rating, + double? audienceRating, + double? userRating, + int? year, + String? originallyAvailableAt, + String? thumb, + String? art, + int? duration, + int? addedAt, + int? updatedAt, + int? lastViewedAt, + String? grandparentTitle, + String? grandparentThumb, + String? grandparentArt, + String? grandparentRatingKey, + String? parentTitle, + String? parentThumb, + String? parentRatingKey, + int? parentIndex, + int? index, + String? grandparentTheme, + int? viewOffset, + int? viewCount, + int? leafCount, + int? viewedLeafCount, + int? childCount, + List? role, + List? mediaVersions, + List? genre, + List? director, + List? writer, + List? producer, + List? country, + List? collection, + List? label, + List? style, + List? mood, + String? audioLanguage, + String? subtitleLanguage, + int? subtitleMode, + int? playlistItemID, + int? playQueueItemID, + int? librarySectionID, + String? librarySectionTitle, + String? ratingImage, + String? audienceRatingImage, + String? tagline, + String? originalTitle, + String? editionTitle, + String? subtype, + int? extraType, + String? primaryExtraKey, + String? serverId, + String? serverName, + String? clearLogo, + String? backgroundSquare, + }) { + return PlexMetadataDto( + ratingKey: ratingKey ?? this.ratingKey, + key: key ?? this.key, + guid: guid ?? this.guid, + studio: studio ?? this.studio, + type: type ?? this.type, + title: title ?? this.title, + titleSort: titleSort ?? this.titleSort, + contentRating: contentRating ?? this.contentRating, + summary: summary ?? this.summary, + rating: rating ?? this.rating, + audienceRating: audienceRating ?? this.audienceRating, + userRating: userRating ?? this.userRating, + year: year ?? this.year, + originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt, + thumb: thumb ?? this.thumb, + art: art ?? this.art, + duration: duration ?? this.duration, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, + grandparentTitle: grandparentTitle ?? this.grandparentTitle, + grandparentThumb: grandparentThumb ?? this.grandparentThumb, + grandparentArt: grandparentArt ?? this.grandparentArt, + grandparentRatingKey: grandparentRatingKey ?? this.grandparentRatingKey, + parentTitle: parentTitle ?? this.parentTitle, + parentThumb: parentThumb ?? this.parentThumb, + parentRatingKey: parentRatingKey ?? this.parentRatingKey, + parentIndex: parentIndex ?? this.parentIndex, + index: index ?? this.index, + grandparentTheme: grandparentTheme ?? this.grandparentTheme, + viewOffset: viewOffset ?? this.viewOffset, + viewCount: viewCount ?? this.viewCount, + leafCount: leafCount ?? this.leafCount, + viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + childCount: childCount ?? this.childCount, + role: role ?? this.role, + mediaVersions: mediaVersions ?? this.mediaVersions, + genre: genre ?? this.genre, + director: director ?? this.director, + writer: writer ?? this.writer, + producer: producer ?? this.producer, + country: country ?? this.country, + collection: collection ?? this.collection, + label: label ?? this.label, + style: style ?? this.style, + mood: mood ?? this.mood, + audioLanguage: audioLanguage ?? this.audioLanguage, + subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, + subtitleMode: subtitleMode ?? this.subtitleMode, + playlistItemID: playlistItemID ?? this.playlistItemID, + playQueueItemID: playQueueItemID ?? this.playQueueItemID, + librarySectionID: librarySectionID ?? this.librarySectionID, + librarySectionTitle: librarySectionTitle ?? this.librarySectionTitle, + ratingImage: ratingImage ?? this.ratingImage, + audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage, + tagline: tagline ?? this.tagline, + originalTitle: originalTitle ?? this.originalTitle, + editionTitle: editionTitle ?? this.editionTitle, + subtype: subtype ?? this.subtype, + extraType: extraType ?? this.extraType, + primaryExtraKey: primaryExtraKey ?? this.primaryExtraKey, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + clearLogo: clearLogo ?? this.clearLogo, + backgroundSquare: backgroundSquare ?? this.backgroundSquare, + ); + } +} + +/// Pure JSON/DTO→neutral-type mappers for Plex. Mirrors [JellyfinMappers]. +/// +/// Methods come in two flavours: +/// * `FromJson` — accept raw Plex JSON and parse + map in one step. +/// Used by tests and by callers that haven't already parsed a DTO. +/// * `` (DTO-typed) — accept an already-parsed DTO. Used by the +/// [PlexClient] which keeps a DTO step internally for caching, copying, +/// and OnDeck composition. +/// +/// Pure: no HTTP, no client state, no token-aware image-URL resolution. +/// Token-aware image URLs are layered on at the [PlexClient] boundary via +/// `thumbnailUrl`/`externalImageUrl` — this layer leaves the relative +/// `thumb`/`art`/`clearLogo` paths intact so they can be resolved per +/// instance. +class PlexMappers { + PlexMappers._(); + + /// Map a Plex `Metadata` JSON entry directly into a [PlexMediaItem]. + static PlexMediaItem mediaItemFromJson(Map json, {String? serverId, String? serverName}) { + final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId, serverName: serverName); + return mediaItem(dto); + } + + /// Parse a Plex `/library/metadata/{id}` JSON object into a neutral + /// [MediaItem]. Used by the offline cache layer to convert persisted Plex + /// JSON back into MediaItem without depending on the Plex client surface. + static MediaItem mediaItemFromCacheJson(Map json, {required String serverId}) { + final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: serverId); + return mediaItem(dto); + } + + /// Map a parsed [PlexMetadataDto] into a [PlexMediaItem]. + static PlexMediaItem mediaItem(PlexMetadataDto dto) { + return PlexMediaItem( + id: dto.ratingKey, + kind: MediaKind.fromString(dto.type), + guid: dto.guid, + title: dto.title, + titleSort: dto.titleSort, + summary: dto.summary, + tagline: dto.tagline, + originalTitle: dto.originalTitle, + editionTitle: dto.editionTitle, + studio: dto.studio, + year: dto.year, + originallyAvailableAt: dto.originallyAvailableAt, + contentRating: dto.contentRating, + parentId: dto.parentRatingKey, + parentTitle: dto.parentTitle, + parentThumbPath: dto.parentThumb, + parentIndex: dto.parentIndex, + index: dto.index, + grandparentId: dto.grandparentRatingKey, + grandparentTitle: dto.grandparentTitle, + grandparentThumbPath: dto.grandparentThumb, + grandparentArtPath: dto.grandparentArt, + thumbPath: dto.thumb, + artPath: dto.art, + clearLogoPath: dto.clearLogo, + backgroundSquarePath: dto.backgroundSquare, + durationMs: dto.duration, + viewOffsetMs: dto.viewOffset, + viewCount: dto.viewCount, + lastViewedAt: dto.lastViewedAt, + leafCount: dto.leafCount, + viewedLeafCount: dto.viewedLeafCount, + childCount: dto.childCount, + addedAt: dto.addedAt, + updatedAt: dto.updatedAt, + rating: dto.rating, + audienceRating: dto.audienceRating, + userRating: dto.userRating, + ratingImage: dto.ratingImage, + audienceRatingImage: dto.audienceRatingImage, + genres: dto.genre, + directors: dto.director, + writers: dto.writer, + producers: dto.producer, + countries: dto.country, + collections: dto.collection, + labels: dto.label, + styles: dto.style, + moods: dto.mood, + roles: dto.role?.map(role).toList(), + mediaVersions: dto.mediaVersions?.map(mediaVersion).toList(), + libraryId: dto.librarySectionID?.toString(), + libraryTitle: dto.librarySectionTitle, + audioLanguage: dto.audioLanguage, + subtitleLanguage: dto.subtitleLanguage, + subtitleMode: dto.subtitleMode, + trailerKey: dto.primaryExtraKey, + playlistItemId: dto.playlistItemID, + playQueueItemId: dto.playQueueItemID, + subtype: dto.subtype, + extraType: dto.extraType, + serverId: dto.serverId, + serverName: dto.serverName, + raw: dto.key != null ? {'key': dto.key} : null, + ); + } + + /// Map a parsed [PlexRoleDto] into a [MediaRole]. + static MediaRole role(PlexRoleDto dto) { + return MediaRole(id: dto.id?.toString(), tag: dto.tag, role: dto.role, thumbPath: dto.thumb); + } + + /// Map a parsed [PlexMediaVersionDto] into a [MediaVersion]. + static MediaVersion mediaVersion(PlexMediaVersionDto dto) { + final part = MediaPart( + id: dto.id.toString(), + streamPath: dto.partKey, + container: dto.container, + accessible: dto.accessible, + exists: dto.exists, + ); + return MediaVersion( + id: dto.id.toString(), + width: dto.width, + height: dto.height, + videoResolution: dto.videoResolution, + videoCodec: dto.videoCodec, + bitrate: dto.bitrate, + container: dto.container, + parts: [part], + ); + } + + /// Map a Plex Media JSON entry directly into a [MediaVersion]. + static MediaVersion mediaVersionFromJson(Map json) { + return mediaVersion(PlexMediaVersionDto.fromJson(json)); + } + + /// Map a parsed [PlexLibraryDto] into a [MediaLibrary]. + static MediaLibrary mediaLibrary(PlexLibraryDto dto) { + return MediaLibrary( + id: dto.key, + backend: MediaBackend.plex, + title: dto.title, + kind: MediaKind.fromString(dto.type), + language: dto.language, + updatedAt: dto.updatedAt, + createdAt: dto.createdAt, + hidden: dto.hidden == 1, + isShared: dto.isShared, + serverId: dto.serverId, + serverName: dto.serverName, + ); + } + + /// Map a Plex `/library/sections` Directory entry into a [MediaLibrary]. + static MediaLibrary mediaLibraryFromJson( + Map json, { + String? serverId, + String? serverName, + bool isShared = false, + }) { + final dto = PlexLibraryDto.fromJson(json).copyWith(serverId: serverId, serverName: serverName, isShared: isShared); + return mediaLibrary(dto); + } + + /// Map a parsed [PlexHubDto] into a [MediaHub]. + static MediaHub mediaHub(PlexHubDto dto) { + return MediaHub( + id: dto.hubKey, + identifier: dto.hubIdentifier, + title: dto.title, + type: dto.type, + items: dto.items.map(mediaItem).toList(), + size: dto.size, + more: dto.more, + serverId: dto.serverId, + serverName: dto.serverName, + ); + } + + /// Map a Plex `/hubs` Hub JSON entry directly into a [MediaHub]. + static MediaHub mediaHubFromJson(Map json, {String? serverId, String? serverName}) { + return mediaHub(PlexHubDto.fromJson(json, serverId: serverId, serverName: serverName)); + } + + /// Map a parsed [PlexPlaylistDto] into a [MediaPlaylist]. + static MediaPlaylist mediaPlaylist(PlexPlaylistDto dto) { + return MediaPlaylist( + id: dto.ratingKey, + backend: MediaBackend.plex, + title: dto.title, + summary: dto.summary, + guid: dto.guid, + smart: dto.smart, + playlistType: dto.playlistType, + durationMs: dto.duration, + leafCount: dto.leafCount, + viewCount: dto.viewCount, + addedAt: dto.addedAt, + updatedAt: dto.updatedAt, + lastViewedAt: dto.lastViewedAt, + compositeImagePath: dto.composite, + thumbPath: dto.thumb, + serverId: dto.serverId, + serverName: dto.serverName, + ); + } + + /// Map a Plex `/playlists` Metadata entry directly into a [MediaPlaylist]. + static MediaPlaylist mediaPlaylistFromJson(Map json, {String? serverId, String? serverName}) { + final dto = PlexPlaylistDto.fromJson(json).copyWith(serverId: serverId, serverName: serverName); + return mediaPlaylist(dto); + } +} + +/// Build a [MediaSourceInfo] from a Plex `/library/metadata/{id}` JSON +/// envelope as stored by [PlexApiCache]. Parses audio/subtitle tracks from +/// `Media[0].Part[0].Stream[]` so that offline playback can still apply +/// language-based track selection. +/// +/// Returns `null` when the JSON shape is missing the `Media`/`Part` arrays. +/// Plex-only — the on-disk format mirrors what the Plex API returns and +/// uses Plex `streamType` int codes (1=video, 2=audio, 3=subtitle). +MediaSourceInfo? plexMediaSourceInfoFromCacheJson(Map metadata, {int mediaIndex = 0}) { + final media = flexibleList(metadata['Media']); + if (media == null || media.isEmpty) return null; + final selectedMedia = mediaIndex >= 0 && mediaIndex < media.length ? media[mediaIndex] : media.first; + final parts = flexibleList(selectedMedia['Part']); + if (parts == null || parts.isEmpty) return null; + final streams = flexibleList(parts.first['Stream']); + + final audioTracks = []; + final subtitleTracks = []; + double? frameRate; + + if (streams != null) { + for (final s in streams) { + try { + final streamType = s['streamType'] as int?; + if (streamType == PlexStreamType.video) { + frameRate ??= (s['frameRate'] as num?)?.toDouble(); + } else if (streamType == PlexStreamType.audio) { + audioTracks.add( + MediaAudioTrack( + id: s['id'] as int, + index: s['index'] as int?, + codec: s['codec'] as String?, + language: s['language'] as String?, + languageCode: s['languageCode'] as String?, + title: s['title'] as String?, + displayTitle: s['displayTitle'] as String?, + channels: s['channels'] as int?, + selected: flexibleBool(s['selected']), + ), + ); + } else if (streamType == PlexStreamType.subtitle) { + subtitleTracks.add( + MediaSubtitleTrack( + id: s['id'] as int, + index: s['index'] as int?, + codec: s['codec'] as String?, + language: s['language'] as String?, + languageCode: s['languageCode'] as String?, + title: s['title'] as String?, + displayTitle: s['displayTitle'] as String?, + selected: flexibleBool(s['selected']), + forced: flexibleBool(s['forced']), + key: s['key'] as String?, + ), + ); + } + } catch (e) { + appLogger.d('Skipping malformed stream in cached metadata', error: e); + } + } + } + + return MediaSourceInfo( + videoUrl: '', + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: const [], + frameRate: frameRate, + ); +} + +PlaybackExtras plexPlaybackExtrasFromCacheJson( + Map? metadataJson, { + String? introPattern, + String? creditsPattern, +}) { + return PlaybackExtras.withChapterFallback( + chapters: plexChaptersFromCacheJson(metadataJson), + markers: plexMarkersFromCacheJson(metadataJson), + introPatternStr: introPattern, + creditsPatternStr: creditsPattern, + ); +} + +List plexChaptersFromCacheJson(Map? metadataJson) { + final chapterList = metadataJson?['Chapter']; + if (chapterList is! List) return const []; + + final out = []; + for (final chapter in chapterList.whereType>()) { + final id = flexibleInt(chapter['id']); + if (id == null) continue; + out.add( + MediaChapter( + id: id, + index: flexibleInt(chapter['index']), + startTimeOffset: flexibleInt(chapter['startTimeOffset']), + endTimeOffset: flexibleInt(chapter['endTimeOffset']), + title: chapter['tag']?.toString() ?? chapter['title']?.toString(), + thumb: chapter['thumb'] as String?, + ), + ); + } + return out; +} + +List plexMarkersFromCacheJson(Map? metadataJson) { + final markerList = metadataJson?['Marker']; + if (markerList is! List) return const []; + + final out = []; + for (final marker in markerList.whereType>()) { + final id = flexibleInt(marker['id']); + final type = marker['type']?.toString(); + final start = flexibleInt(marker['startTimeOffset']); + final end = flexibleInt(marker['endTimeOffset']); + if (id == null || type == null || start == null || end == null) continue; + out.add(MediaMarker(id: id, type: type, startTimeOffset: start, endTimeOffset: end)); + } + return out; +} diff --git a/lib/services/scrub_preview_source.dart b/lib/services/scrub_preview_source.dart new file mode 100644 index 00000000..05a70b0c --- /dev/null +++ b/lib/services/scrub_preview_source.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; + +/// One frame of scrub-bar preview imagery, produced by a +/// [ScrubPreviewSource] for a given timestamp. +/// +/// Plex's BIF returns standalone JPEG bytes per timestamp. +/// Jellyfin's Trickplay returns a sprite-sheet URL plus the row/column of +/// the right tile. The tooltip sizes itself to [aspectRatio] so the frame +/// renders without letterboxing or cropping. +sealed class ScrubFrame { + const ScrubFrame(); + + /// Source frame aspect ratio (width / height). The tooltip uses this to + /// pick its own height; the renderer assumes the box already matches. + double get aspectRatio; +} + +/// Plex BIF: standalone JPEG bytes ready for [Image.memory]. Plex doesn't +/// expose per-BIF dimensions out-of-band; servers virtually always use +/// 16:9, which matches the default tooltip box. +class BytesScrubFrame extends ScrubFrame { + final Uint8List bytes; + @override + final double aspectRatio; + const BytesScrubFrame(this.bytes, {this.aspectRatio = 16 / 9}); +} + +/// Jellyfin Trickplay: a single sprite sheet plus the position of the tile +/// to display within it. Aspect comes from [sourceTileSize]; the tooltip +/// adopts that aspect so each source tile fills the box exactly. +class SheetScrubFrame extends ScrubFrame { + final ImageProvider sheet; + final int tileColumn; + final int tileRow; + final int sheetColumns; + final int sheetRows; + final Size sourceTileSize; + const SheetScrubFrame({ + required this.sheet, + required this.tileColumn, + required this.tileRow, + required this.sheetColumns, + required this.sheetRows, + required this.sourceTileSize, + }); + + @override + double get aspectRatio => sourceTileSize.width / sourceTileSize.height; +} + +/// Backend-neutral source of [ScrubFrame]s for the timeline tooltip. +/// Plex implements via BIF bytes; Jellyfin via Trickplay sheet crops. +abstract class ScrubPreviewSource { + bool get isAvailable; + ScrubFrame? getFrame(Duration time); + void dispose(); +} diff --git a/lib/services/server_connection_orchestrator.dart b/lib/services/server_connection_orchestrator.dart deleted file mode 100644 index a7c9e836..00000000 --- a/lib/services/server_connection_orchestrator.dart +++ /dev/null @@ -1,80 +0,0 @@ -import '../providers/libraries_provider.dart'; -import '../providers/multi_server_provider.dart'; -import '../utils/app_logger.dart'; -import '../utils/connection_constants.dart'; -import 'offline_watch_sync_service.dart'; -import 'plex_auth_service.dart'; -import 'plex_client.dart'; - -/// Result of a connection attempt to one or more servers. -class ConnectionResult { - final int connectedCount; - - /// The first client that connected successfully, or null if none did. - final PlexClient? firstClient; - - ConnectionResult({required this.connectedCount, this.firstClient}); - - bool get hasConnections => connectedCount > 0; -} - -/// Shared logic for connecting to saved servers, initializing libraries, -/// and triggering offline-watch sync. -/// -/// Both [SetupScreen] and [AuthScreen] delegate to this helper so the -/// sequence isn't duplicated. Navigation and error UI remain the caller's -/// responsibility. -class ServerConnectionOrchestrator { - /// Connect to [servers], initialize libraries, and kick off sync. - /// - /// Returns a [ConnectionResult] so the caller can decide what to show. - /// Throws only on unexpected errors; individual server failures are - /// handled internally (logged + counted). - static Future connectAndInitialize({ - required List servers, - required MultiServerProvider multiServerProvider, - required LibrariesProvider librariesProvider, - required OfflineWatchSyncService syncService, - String? clientIdentifier, - Duration timeout = ConnectionTimeouts.perServerConnect, - void Function(String serverId, bool success)? onServerStatus, - }) async { - appLogger.i('Connecting to ${servers.length} servers...'); - - final connectedCount = await multiServerProvider.serverManager.connectToAllServers( - servers, - clientIdentifier: clientIdentifier, - timeout: timeout, - onServerConnected: onServerStatus != null ? (serverId, _) => onServerStatus(serverId, true) : null, - onServerFailed: onServerStatus != null ? (serverId, _) => onServerStatus(serverId, false) : null, - ); - - PlexClient? firstClient; - - if (connectedCount > 0) { - appLogger.i('Successfully connected to $connectedCount servers'); - - // Initialize and load libraries - librariesProvider.initialize(multiServerProvider.aggregationService); - try { - await librariesProvider.loadLibraries(); - } catch (e) { - appLogger.w('Failed to load libraries during connection', error: e); - // Continue anyway — MainScreen will retry - } - - // Trigger initial watch sync - syncService.onServersConnected(); - - // Grab first online client for backward-compat navigation - final onlineClients = multiServerProvider.serverManager.onlineClients; - if (onlineClients.isNotEmpty) { - firstClient = onlineClients.values.first; - } - } else { - appLogger.w('Failed to connect to any servers'); - } - - return ConnectionResult(connectedCount: connectedCount, firstClient: firstClient); - } -} diff --git a/lib/services/server_registry.dart b/lib/services/server_registry.dart index ed0b43d7..1e537afd 100644 --- a/lib/services/server_registry.dart +++ b/lib/services/server_registry.dart @@ -1,20 +1,25 @@ import 'dart:convert'; import '../utils/app_logger.dart'; -import '../utils/plex_http_exception.dart'; import 'plex_auth_service.dart'; import 'storage_service.dart'; -enum ServerRefreshResult { success, networkError, authError, noToken } - -/// Centralized server configuration registry -/// Manages which servers are available and their configurations +/// Per-Plex-account servers list, persisted as JSON in [StorageService]. +/// +/// Historically this was the single source of truth for "the user's Plex +/// servers". The new pipeline stores servers on +/// [PlexAccountConnection.servers] in [ConnectionRegistry] instead, so the +/// only remaining responsibility here is reading the legacy list during +/// the one-shot bootstrap migration in [ConnectionBootstrap]. class ServerRegistry { final StorageService _storage; ServerRegistry(this._storage); - /// Get all registered servers + /// Read the legacy servers list from storage. Returns an empty list when + /// there is no legacy data (post-migration installs and fresh installs). + /// + /// Called only by [ConnectionBootstrap.migrateLegacyPlexAccount]. Future> getServers() async { try { final serversJson = _storage.getServersListJson(); @@ -29,114 +34,4 @@ class ServerRegistry { return []; } } - - /// Save all servers to storage - Future saveServers(List servers) async { - try { - final serversJson = jsonEncode(servers.map((s) => s.toJson()).toList()); - await _storage.saveServersListJson(serversJson); - appLogger.d('Saved ${servers.length} servers to storage'); - } catch (e, stackTrace) { - appLogger.e('Failed to save servers to storage', error: e, stackTrace: stackTrace); - rethrow; - } - } - - /// Get a specific server by ID - Future getServer(String serverId) async { - final servers = await getServers(); - try { - return servers.firstWhere((s) => s.clientIdentifier == serverId); - } catch (e) { - return null; - } - } - - /// Add or update a single server - Future upsertServer(PlexServer server) async { - final servers = await getServers(); - final index = servers.indexWhere((s) => s.clientIdentifier == server.clientIdentifier); - - if (index >= 0) { - servers[index] = server; - appLogger.d('Updated server: ${server.name}'); - } else { - servers.add(server); - appLogger.d('Added new server: ${server.name}'); - } - - await saveServers(servers); - } - - /// Remove a server - Future removeServer(String serverId) async { - final servers = await getServers(); - servers.removeWhere((s) => s.clientIdentifier == serverId); - await saveServers(servers); - - appLogger.i('Removed server: $serverId'); - } - - /// Clear all servers - Future clearAllServers() async { - await _storage.clearServersList(); - appLogger.i('Cleared all servers from registry'); - } - - /// Refresh servers from Plex API and update storage. - /// This updates connection info (IPs, ports) that may have changed. - /// Returns [ServerRefreshResult.authError] when the stored token is rejected - /// (e.g. after removing a Plex profile PIN), so the caller can redirect to re-auth. - Future refreshServersFromApi() async { - final token = _storage.getPlexToken(); - if (token == null || token.isEmpty) { - appLogger.d('No Plex token available, skipping server refresh'); - return ServerRefreshResult.noToken; - } - - PlexAuthService? authService; - try { - appLogger.d('Refreshing servers from Plex API...'); - authService = await PlexAuthService.create(); - final freshServers = await authService.fetchServers(token); - - if (freshServers.isEmpty) { - appLogger.w('API returned no servers, keeping existing data'); - return ServerRefreshResult.success; - } - - // Get existing servers to preserve any local-only data - final existingServers = await getServers(); - final existingIds = existingServers.map((s) => s.clientIdentifier).toSet(); - - // Update existing servers with fresh connection info, add new ones - final updatedServers = []; - for (final fresh in freshServers) { - if (existingIds.contains(fresh.clientIdentifier)) { - // Server exists - use fresh data (updated IPs, connections) - updatedServers.add(fresh); - } else { - // New server - add it - updatedServers.add(fresh); - appLogger.i('Discovered new server: ${fresh.name}'); - } - } - - await saveServers(updatedServers); - appLogger.i('Refreshed ${updatedServers.length} servers from API'); - return ServerRefreshResult.success; - } on PlexHttpException catch (e) { - if (e.statusCode == 401) { - appLogger.w('Plex token is invalid (401), re-authentication required'); - return ServerRefreshResult.authError; - } - appLogger.w('Failed to refresh servers from API, using cached data', error: e); - return ServerRefreshResult.networkError; - } catch (e, stackTrace) { - appLogger.w('Failed to refresh servers from API, using cached data', error: e, stackTrace: stackTrace); - return ServerRefreshResult.networkError; - } finally { - authService?.dispose(); - } - } } diff --git a/lib/services/settings_export_service.dart b/lib/services/settings_export_service.dart index 12ae6131..c7ef63e6 100644 --- a/lib/services/settings_export_service.dart +++ b/lib/services/settings_export_service.dart @@ -69,9 +69,12 @@ class SettingsExportService { 'current_user_uuid', 'home_users_cache', 'home_users_cache_expiry', + 'active_app_profile_id', // Multi-server routing 'servers_list', 'server_order', + // CredentialVault encryption key for DB-stored connection tokens + 'credential_vault_key_v1', // View state, not settings 'selected_library_index', 'selected_library_key', @@ -82,7 +85,8 @@ class SettingsExportService { /// Prefix denylist. A key is excluded if it starts with any of these. /// The tracker prefixes (`trakt_`, `mal_`, `anilist_`, `simkl_`) cover /// OAuth session tokens and runtime sync queues. The `enable_*` feature - /// toggles use a different prefix and stay exportable. + /// toggles use a different prefix and stay exportable. Profile runtime + /// caches are also excluded because they belong to local connection state. static const List _denyPrefixes = [ 'server_endpoint_', 'episode_count_', @@ -91,6 +95,8 @@ class SettingsExportService { 'mal_', 'anilist_', 'simkl_', + 'plex_home_users_', + 'profile_last_used_', ]; /// Literal prefix used by [StorageService._userPrefix] for any scoped key. @@ -289,7 +295,7 @@ class SettingsExportService { // best-effort; tolerate platforms without PackageInfo } - final exportMap = buildExportMap(prefs, currentUserUuid: storage.getCurrentUserUUID(), appVersion: appVersion); + final exportMap = buildExportMap(prefs, currentUserUuid: storage.activeUserScope(), appVersion: appVersion); final jsonString = const JsonEncoder.withIndent(' ').convert(exportMap); final bytes = Uint8List.fromList(utf8.encode(jsonString)); final fileName = await _defaultFileName(); @@ -329,7 +335,7 @@ class SettingsExportService { /// malformed files or unsupported versions. static Future importFromFile() async { final storage = await StorageService.getInstance(); - final uuid = storage.getCurrentUserUUID(); + final uuid = storage.activeUserScope(); if (uuid == null || uuid.isEmpty) { throw const NoUserSignedInException(); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index b2fb03a6..a578661e 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -2,26 +2,21 @@ import 'dart:convert'; import 'package:uuid/uuid.dart'; +import '../profiles/profile.dart'; import '../utils/log_redaction_manager.dart'; import 'base_shared_preferences_service.dart'; class StorageService extends BaseSharedPreferencesService { - static const String _keyServerUrl = 'server_url'; - static const String _keyToken = 'token'; static const String _keyPlexToken = 'plex_token'; - static const String _keyServerData = 'server_data'; static const String _keyClientId = 'client_identifier'; - static const String _keySelectedLibraryIndex = 'selected_library_index'; static const String _keySelectedLibraryKey = 'selected_library_key'; static const String _keyLibraryFilters = 'library_filters'; static const String _keyLibraryOrder = 'library_order'; - static const String _keyUserProfile = 'user_profile'; static const String _keyCurrentUserUUID = 'current_user_uuid'; - static const String _keyHomeUsersCache = 'home_users_cache'; - static const String _keyHomeUsersCacheExpiry = 'home_users_cache_expiry'; static const String _keyHiddenLibraries = 'hidden_libraries'; static const String _keyServersList = 'servers_list'; static const String _keyServerOrder = 'server_order'; + static const String _keyActiveProfileId = 'active_app_profile_id'; // Key prefixes for per-id storage static const String _prefixServerEndpoint = 'server_endpoint_'; @@ -29,25 +24,12 @@ class StorageService extends BaseSharedPreferencesService { static const String _prefixLibrarySort = 'library_sort_'; static const String _prefixLibraryGrouping = 'library_grouping_'; static const String _prefixLibraryTab = 'library_tab_'; + static const String _prefixPlexHomeUsers = 'plex_home_users_'; + static const String _prefixProfileLastUsed = 'profile_last_used_'; // Key groups for bulk clearing - static const List _credentialKeys = [ - _keyServerUrl, - _keyToken, - _keyPlexToken, - _keyServerData, - _keyClientId, - _keyUserProfile, - _keyCurrentUserUUID, - _keyHomeUsersCache, - _keyHomeUsersCacheExpiry, - ]; + static const List _credentialKeys = [_keyPlexToken, _keyClientId, _keyCurrentUserUUID]; - static const List _libraryPreferenceKeys = [ - _keySelectedLibraryIndex, - _keyLibraryFilters, - _keyLibraryOrder, - _keyHiddenLibraries, - ]; + static const List _libraryPreferenceKeys = [_keyLibraryFilters, _keyLibraryOrder, _keyHiddenLibraries]; StorageService._(); @@ -57,18 +39,36 @@ class StorageService extends BaseSharedPreferencesService { @override Future onInit() async { - // Seed known values so logs can redact immediately on startup. - LogRedactionManager.registerServerUrl(prefs.getString(_keyServerUrl)); - LogRedactionManager.registerToken(prefs.getString(_keyToken)); + // Seed known values so logs can redact immediately on startup. Reading + // the legacy plex_token slot here is acceptable: it's a one-shot + // redaction-priming read for any tokens lingering from before the + // migration ran (after migration the slot is empty so this is a no-op). + // ignore: deprecated_member_use_from_same_package LogRedactionManager.registerToken(getPlexToken()); } // User-scoped storage for per-profile library settings - /// Returns `'user_{uuid}_'` for the current user, or `''` if no user is set. + /// Returns the scope identifier for the active profile, or `null` if no + /// profile is active. + /// + /// For Plex Home profiles (id format `plex-home-{connId}-{homeUserUuid}`) + /// the scope is the home-user UUID — keeps per-user library prefs working + /// the same way the legacy `currentUserUUID` did. For local profiles, the + /// full profile id is the scope. + String? activeUserScope() => _activeUserScope(); + + String? _activeUserScope() { + final id = getActiveProfileId(); + if (id == null) return null; + return parsePlexHomeProfileId(id)?.homeUserUuid ?? id; + } + + /// Returns `'user_{scope}_'` for the active profile, or `''` if no + /// profile is active. String get _userPrefix { - final uuid = getCurrentUserUUID(); - return uuid != null ? 'user_${uuid}_' : ''; + final scope = _activeUserScope(); + return scope != null ? 'user_${scope}_' : ''; } /// Read a string with user-scoped key, migrating from legacy key if needed. @@ -78,17 +78,10 @@ class StorageService extends BaseSharedPreferencesService { if (value != null || _userPrefix.isEmpty) return value; // One-time migration from legacy global key final legacy = prefs.getString(baseKey); - if (legacy != null) prefs.setString(scopedKey, legacy); - return legacy; - } - - /// Read an int with user-scoped key, migrating from legacy key if needed. - int? _getScopedInt(String baseKey) { - final scopedKey = '$_userPrefix$baseKey'; - final value = prefs.getInt(scopedKey); - if (value != null || _userPrefix.isEmpty) return value; - final legacy = prefs.getInt(baseKey); - if (legacy != null) prefs.setInt(scopedKey, legacy); + if (legacy != null) { + prefs.setString(scopedKey, legacy); + prefs.remove(baseKey); + } return legacy; } @@ -106,32 +99,35 @@ class StorageService extends BaseSharedPreferencesService { await prefs.remove('$_prefixServerEndpoint$serverId'); } - // Plex.tv Token (for API access) - Future savePlexToken(String token) async { - await prefs.setString(_keyPlexToken, token); - LogRedactionManager.registerToken(token); - } - + // Plex.tv Token — read once by [ConnectionBootstrap.migrateLegacyPlexAccount] + // on the upgrade run. The new pipeline stores Plex account tokens on + // [PlexAccountConnection.accountToken] in [ConnectionRegistry]. + @Deprecated( + 'Read PlexAccountConnection.accountToken from ConnectionRegistry instead. ' + 'Only ConnectionBootstrap.migrateLegacyPlexAccount may use this.', + ) String? getPlexToken() { return prefs.getString(_keyPlexToken); } - // Client Identifier - Future saveClientIdentifier(String clientId) async { - await prefs.setString(_keyClientId, clientId); + /// Drop the legacy `plex_token` slot. Called by + /// [ConnectionBootstrap.migrateLegacyPlexAccount] after the token has + /// been moved into a [PlexAccountConnection] row, so a later sign-out + /// doesn't get resurrected on next launch (the migration would + /// otherwise see the orphaned token and re-create the connection). + Future clearLegacyPlexToken() async { + await prefs.remove(_keyPlexToken); } - String? getClientIdentifier() { - return prefs.getString(_keyClientId); - } - - /// Return the persisted client identifier, generating and saving a UUID on - /// first call. Ensures Plex sees the same device across reconnects. + /// Return the persisted device identifier, generating and saving a UUID on + /// first call. Used by Plex's `X-Plex-Client-Identifier` header so plex.tv + /// sees the same device across launches; not Plex-specific in itself — + /// Jellyfin's `DeviceId` header reuses the same value too. Future getOrCreateClientIdentifier() async { - final existing = getClientIdentifier(); + final existing = prefs.getString(_keyClientId); if (existing != null && existing.isNotEmpty) return existing; final generated = const Uuid().v4(); - await saveClientIdentifier(generated); + await prefs.setString(_keyClientId, generated); return generated; } @@ -141,10 +137,6 @@ class StorageService extends BaseSharedPreferencesService { LogRedactionManager.clearTrackedValues(); } - int? getSelectedLibraryIndex() { - return _getScopedInt(_keySelectedLibraryIndex); - } - // Selected Library Key (replaces index-based selection) Future saveSelectedLibraryKey(String key) async { await prefs.setString('$_userPrefix$_keySelectedLibraryKey', key); @@ -190,7 +182,10 @@ class StorageService extends BaseSharedPreferencesService { if (result != null || _userPrefix.isEmpty) return result; // One-time migration from legacy key result = _readJsonMap(baseKey, legacyStringOk: true); - if (result != null) _setJsonMap(scopedKey, result); + if (result != null) { + _setJsonMap(scopedKey, result); + prefs.remove(baseKey); + } return result; } @@ -246,6 +241,14 @@ class StorageService extends BaseSharedPreferencesService { _clearKeysWithPrefix('$prefix$_prefixLibraryFilters'), _clearKeysWithPrefix('$prefix$_prefixLibraryGrouping'), _clearKeysWithPrefix('$prefix$_prefixLibraryTab'), + if (prefix.isNotEmpty) ...[ + ..._libraryPreferenceKeys.map(prefs.remove), + prefs.remove(_keySelectedLibraryKey), + _clearKeysWithPrefix(_prefixLibrarySort), + _clearKeysWithPrefix(_prefixLibraryFilters), + _clearKeysWithPrefix(_prefixLibraryGrouping), + _clearKeysWithPrefix(_prefixLibraryTab), + ], ]); } @@ -261,53 +264,25 @@ class StorageService extends BaseSharedPreferencesService { if (value != null || _userPrefix.isEmpty) return value; // One-time migration from legacy key final legacy = _getStringList(baseKey); - if (legacy != null) _setStringList(scopedKey, legacy); + if (legacy != null) { + _setStringList(scopedKey, legacy); + prefs.remove(baseKey); + } return legacy; } - // User Profile (stored as JSON string) - Future saveUserProfile(Map profileJson) async { - await _setJsonMap(_keyUserProfile, profileJson); - } - - Map? getUserProfile() { - return _readJsonMap(_keyUserProfile); - } - - // Current User UUID - Future saveCurrentUserUUID(String uuid) async { - await prefs.setString(_keyCurrentUserUUID, uuid); - } - + // Current User UUID — read once by [ConnectionBootstrap._promoteActiveProfileFromLegacy] + // on the upgrade run, then cleared. Replaced by + // [getActiveProfileId] / [setActiveProfileId]. + @Deprecated( + 'Use setActiveProfileId / getActiveProfileId. ' + 'Only ConnectionBootstrap._promoteActiveProfileFromLegacy may read this.', + ) String? getCurrentUserUUID() { return prefs.getString(_keyCurrentUserUUID); } - // Home Users Cache (stored as JSON string with expiry) - Future saveHomeUsersCache(Map homeData) async { - await _setJsonMap(_keyHomeUsersCache, homeData); - - // Set cache expiry to 1 hour from now - final expiry = DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch; - await prefs.setInt(_keyHomeUsersCacheExpiry, expiry); - } - - Map? getHomeUsersCache() { - final expiry = prefs.getInt(_keyHomeUsersCacheExpiry); - if (expiry == null || DateTime.now().millisecondsSinceEpoch > expiry) { - // Cache expired, clear it - clearHomeUsersCache(); - return null; - } - - return _readJsonMap(_keyHomeUsersCache); - } - - Future clearHomeUsersCache() async { - await Future.wait([prefs.remove(_keyHomeUsersCache), prefs.remove(_keyHomeUsersCacheExpiry)]); - } - - // Clear current user UUID (for server switching) + /// Clears the legacy `currentUserUUID` slot. Used by the upgrade migration. Future clearCurrentUserUUID() async { await prefs.remove(_keyCurrentUserUUID); } @@ -318,18 +293,22 @@ class StorageService extends BaseSharedPreferencesService { } // Multi-Server Support Methods + // + // Servers now live on [PlexAccountConnection.servers] in + // [ConnectionRegistry]. The legacy `servers_list` JSON slot is read once + // by [ConnectionBootstrap.migrateLegacyPlexAccount] and then dropped. - /// Get servers list as JSON string + /// Get legacy servers list as JSON string. Use [ConnectionRegistry] for + /// fresh data; this exists only for the boot-time migration. + @Deprecated( + 'Read PlexAccountConnection.servers from ConnectionRegistry. ' + 'Only ConnectionBootstrap.migrateLegacyPlexAccount may use this.', + ) String? getServersListJson() { return prefs.getString(_keyServersList); } - /// Save servers list as JSON string - Future saveServersListJson(String serversJson) async { - await prefs.setString(_keyServersList, serversJson); - } - - /// Clear servers list + /// Clear the legacy servers list. Future clearServersList() async { await prefs.remove(_keyServersList); } @@ -339,18 +318,61 @@ class StorageService extends BaseSharedPreferencesService { await Future.wait([clearServersList(), clearServerOrder(), _clearKeysWithPrefix(_prefixServerEndpoint)]); } - /// Server Order (stored as JSON list of server IDs) - Future saveServerOrder(List serverIds) async { - await _setStringList(_keyServerOrder, serverIds); - } - - List? getServerOrder() => _getStringList(_keyServerOrder); - - /// Clear server order + /// Clear legacy server order. Future clearServerOrder() async { await prefs.remove(_keyServerOrder); } + // Active app-level profile (kids mode / multi-user gating) + + String? getActiveProfileId() => prefs.getString(_keyActiveProfileId); + + Future setActiveProfileId(String id) async { + await prefs.setString(_keyActiveProfileId, id); + } + + Future clearActiveProfileId() async { + await prefs.remove(_keyActiveProfileId); + } + + // Per-connection Plex Home users cache. Plex Home profiles are not + // persisted as Profile rows — they're fetched live by [PlexHomeService] + // and cached here so the picker can paint immediately on cold start. + // Stored as a JSON list of [PlexHomeUser] payloads, no TTL — the service + // refreshes in the background via stale-while-revalidate. + Future savePlexHomeUsersCache(String connectionId, List> users) async { + await prefs.setString('$_prefixPlexHomeUsers$connectionId', json.encode(users)); + } + + String? getPlexHomeUsersCacheJson(String connectionId) { + return prefs.getString('$_prefixPlexHomeUsers$connectionId'); + } + + Future clearPlexHomeUsersCache(String connectionId) async { + await prefs.remove('$_prefixPlexHomeUsers$connectionId'); + } + + Future clearAllPlexHomeUsersCache() async { + await _clearKeysWithPrefix(_prefixPlexHomeUsers); + } + + // `lastUsedAt` for ordering and future filtering of profiles by recency + // (currently surfaced via `Profile.lastUsedAt`). Stored separately so it + // works for both DB-backed local profiles and virtual Plex Home profiles + // (which don't have a Profile row to update). + Future markProfileUsed(String profileId, DateTime at) async { + await prefs.setInt('$_prefixProfileLastUsed$profileId', at.millisecondsSinceEpoch); + } + + DateTime? getProfileLastUsed(String profileId) { + final ms = prefs.getInt('$_prefixProfileLastUsed$profileId'); + return ms == null ? null : DateTime.fromMillisecondsSinceEpoch(ms); + } + + Future clearAllProfileLastUsed() async { + await _clearKeysWithPrefix(_prefixProfileLastUsed); + } + // Episode Count Persistence (for partial download detection) static const String _prefixEpisodeCount = 'episode_count_'; diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 0fa8e5c1..e7fd61bb 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -1,8 +1,10 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import '../database/app_database.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; import '../models/download_models.dart'; -import '../models/plex_metadata.dart'; import '../utils/app_logger.dart'; import '../utils/content_utils.dart'; import '../utils/episode_collection.dart'; @@ -10,7 +12,7 @@ import '../utils/global_key_utils.dart'; import 'download_manager_service.dart'; import 'multi_server_manager.dart'; import 'offline_mode_source.dart'; -import 'plex_client.dart'; +import 'playlist_items_loader.dart'; /// Sync-rule filter values stored in `SyncRules.downloadFilter`. class SyncRuleFilter { @@ -66,10 +68,11 @@ class SyncRuleExecutor { /// [queueSingleDownload] queues a single movie/episode and returns `true` if it /// was actually queued (false when the item was already present). Future> executeSyncRules({ + required String profileId, required MultiServerManager serverManager, required Map downloads, - required Map metadata, - required Future Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload, + required Map metadata, + required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, bool force = false, }) async { if (_isExecuting) { @@ -104,7 +107,7 @@ class SyncRuleExecutor { _isExecuting = true; try { - final rules = await _database.getSyncRules(); + final rules = await _database.getSyncRules(profileId: profileId); if (rules.isEmpty) return []; appLogger.i('Executing ${rules.length} sync rules'); @@ -138,11 +141,12 @@ class SyncRuleExecutor { /// Execute one rule by global key. Used for the eager trigger after /// `addToPlaylist` / `addToCollection`. Not throttled by the cooldown. Future executeSingleRule({ + required String profileId, required String globalKey, required MultiServerManager serverManager, required Map downloads, - required Map metadata, - required Future Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload, + required Map metadata, + required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, }) async { if (_isExecuting) { appLogger.d('Sync rule execution already in progress, skipping single-rule run for $globalKey'); @@ -160,7 +164,7 @@ class SyncRuleExecutor { } final rule = await _database.getSyncRule(globalKey); - if (rule == null || !rule.enabled) { + if (rule == null || !rule.enabled || rule.profileId != profileId) { return null; } @@ -185,23 +189,40 @@ class SyncRuleExecutor { required SyncRuleItem rule, required MultiServerManager serverManager, required Map downloads, - required Map metadata, - required Future Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload, + required Map metadata, + required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, }) async { final client = serverManager.getClient(rule.serverId); if (client == null || !serverManager.isServerOnline(rule.serverId)) { - appLogger.d('Skipping sync rule ${rule.globalKey} — server offline'); + appLogger.d('Skipping sync rule ${rule.globalKey} — server offline or unavailable'); return null; } + // Migration safety net for rules created before targetMetadata was passed + // at creation time: fetch the rule target's title once so the snackbar + // doesn't fall back to "Unknown". On failure, keep the original map. + var resolvedMetadata = metadata; + if (metadata[rule.globalKey]?.title == null) { + try { + final fetched = await client.fetchItem(rule.ratingKey); + if (fetched != null) { + resolvedMetadata = {...metadata, rule.globalKey: fetched}; + } + } catch (e) { + appLogger.d('Sync rule ${rule.globalKey}: title fetch failed', error: e); + } + } + switch (rule.targetType) { case ContentTypes.show: case ContentTypes.season: return _executeEpisodeRule( rule: rule, client: client, + clientScopeId: _clientScopeIdFor(client, rule.serverId), + profileId: rule.profileId, downloads: downloads, - metadata: metadata, + metadata: resolvedMetadata, queueSingleDownload: queueSingleDownload, ); case ContentTypes.collection: @@ -209,8 +230,10 @@ class SyncRuleExecutor { return _executeListRule( rule: rule, client: client, + clientScopeId: _clientScopeIdFor(client, rule.serverId), + profileId: rule.profileId, downloads: downloads, - metadata: metadata, + metadata: resolvedMetadata, queueSingleDownload: queueSingleDownload, ); default: @@ -219,22 +242,36 @@ class SyncRuleExecutor { } } + String? _clientScopeIdFor(MediaServerClient client, String serverId) { + final cacheServerId = client.cacheServerId; + return cacheServerId == serverId || cacheServerId.isEmpty ? null : cacheServerId; + } + /// Keep [rule.episodeCount] unwatched episodes queued for a show/season /// (0 = all). Always "unwatched" — watched/all filtering doesn't apply here. Future _executeEpisodeRule({ required SyncRuleItem rule, - required PlexClient client, + required MediaServerClient client, + required String? clientScopeId, + required String profileId, required Map downloads, - required Map metadata, - required Future Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload, + required Map metadata, + required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, }) async { - final unwatchedEpisodes = []; + final fromServer = []; if (rule.targetType == ContentTypes.show) { - await collectEpisodesForShow(client, rule.ratingKey, unwatchedOnly: true, out: unwatchedEpisodes); + await collectEpisodesForShow(client, rule.ratingKey, unwatchedOnly: true, out: fromServer); } else { - await collectEpisodesForSeason(client, rule.ratingKey, unwatchedOnly: true, out: unwatchedEpisodes); + await collectEpisodesForSeason(client, rule.ratingKey, unwatchedOnly: true, out: fromServer); } + final unwatchedEpisodes = await _excludeLocallyWatched( + episodes: fromServer, + serverId: rule.serverId, + profileId: profileId, + clientScopeId: clientScopeId, + ); + if (unwatchedEpisodes.isEmpty) { appLogger.d('Sync rule ${rule.globalKey}: no unwatched episodes available'); await _database.updateSyncRuleLastExecuted(rule.globalKey); @@ -243,7 +280,7 @@ class SyncRuleExecutor { int alreadyHave = 0; for (final ep in unwatchedEpisodes) { - final gk = buildGlobalKey(rule.serverId, ep.ratingKey); + final gk = buildGlobalKey(rule.serverId, ep.id); if (_isActiveDownload(downloads[gk])) alreadyHave++; } @@ -260,14 +297,14 @@ class SyncRuleExecutor { for (final ep in unwatchedEpisodes) { if (queued >= deficit) break; - final gk = buildGlobalKey(rule.serverId, ep.ratingKey); + final gk = buildGlobalKey(rule.serverId, ep.id); if (_isActiveDownload(downloads[gk])) continue; final episodeWithServer = ep.serverId != null ? ep : ep.copyWith(serverId: rule.serverId); final ok = await queueSingleDownload(episodeWithServer, client, mediaIndex: rule.mediaIndex); if (ok) { queued++; - appLogger.d('Sync rule ${rule.globalKey}: queued ${ep.title}'); + appLogger.i('Sync rule ${rule.globalKey}: queued ${ep.title ?? ep.id}'); } } @@ -284,16 +321,23 @@ class SyncRuleExecutor { /// downloaded. No deficit cap. `mediaIndex` is always 0 for these rules. Future _executeListRule({ required SyncRuleItem rule, - required PlexClient client, + required MediaServerClient client, + required String? clientScopeId, + required String profileId, required Map downloads, - required Map metadata, - required Future Function(PlexMetadata episode, PlexClient client, {int mediaIndex}) queueSingleDownload, + required Map metadata, + required Future Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload, }) async { - final List rootItems; + final List rootItems; try { - rootItems = rule.targetType == ContentTypes.collection - ? await client.fetchAllCollectionItems(rule.ratingKey) - : await client.fetchAllPlaylistItems(rule.ratingKey); + // Page list calls so long collections/playlists don't truncate at the + // default limit. Plex collections use a distinct collections endpoint; + // Jellyfin's collection page implementation maps to its children API. + if (rule.targetType == ContentTypes.collection) { + rootItems = await _fetchAllCollectionItems(client, rule.ratingKey); + } else { + rootItems = await _fetchAllPlaylistItems(client, rule.ratingKey); + } } catch (e) { appLogger.w('Sync rule ${rule.globalKey}: failed to fetch list items: $e'); return null; @@ -306,8 +350,17 @@ class SyncRuleExecutor { } final unwatchedOnly = rule.downloadFilter == SyncRuleFilter.unwatched; - final candidates = []; - await _collectItemsForList(client, rootItems, unwatchedOnly: unwatchedOnly, out: candidates); + final collected = []; + await _collectItemsForList(client, rootItems, unwatchedOnly: unwatchedOnly, out: collected); + + final candidates = unwatchedOnly + ? await _excludeLocallyWatched( + episodes: collected, + serverId: rule.serverId, + profileId: profileId, + clientScopeId: clientScopeId, + ) + : collected; if (candidates.isEmpty) { appLogger.d('Sync rule ${rule.globalKey}: no candidates after filtering'); @@ -317,14 +370,14 @@ class SyncRuleExecutor { int queued = 0; for (final item in candidates) { - final gk = buildGlobalKey(rule.serverId, item.ratingKey); + final gk = buildGlobalKey(rule.serverId, item.id); if (_isActiveDownload(downloads[gk])) continue; final itemWithServer = item.serverId != null ? item : item.copyWith(serverId: rule.serverId); final ok = await queueSingleDownload(itemWithServer, client, mediaIndex: 0); if (ok) { queued++; - appLogger.d('Sync rule ${rule.globalKey}: queued ${item.title}'); + appLogger.i('Sync rule ${rule.globalKey}: queued ${item.title ?? item.id}'); } } @@ -336,26 +389,49 @@ class SyncRuleExecutor { return SyncRuleResult(globalKey: rule.globalKey, title: displayTitle, queuedCount: queued); } + /// Page through every item in a playlist; the neutral + /// [MediaServerClient.fetchPlaylistItems] caps each page (Plex at 100, + /// Jellyfin honours `limit`). + Future> _fetchAllPlaylistItems(MediaServerClient client, String playlistId) async { + return fetchAllPlaylistItems(client, playlistId); + } + + /// Page through every item in a collection. Plex requires + /// [MediaServerClient.fetchCollectionPage] because collection children live + /// under `/library/collections/{id}/children`, not metadata children. + Future> _fetchAllCollectionItems(MediaServerClient client, String collectionId) async { + final all = []; + const pageSize = 100; + var offset = 0; + while (true) { + final page = await client.fetchCollectionPage(collectionId, start: offset, size: pageSize); + if (page.items.isEmpty) break; + all.addAll(page.items); + if (all.length >= page.totalCount || page.items.length < pageSize) break; + offset += page.items.length; + } + return all; + } + /// Walks [items] and collects playable movie/episode entries into [out]. /// Shows and seasons are expanded into their episodes; music and nested /// collections/playlists are skipped. Future _collectItemsForList( - PlexClient client, - List items, { + MediaServerClient client, + List items, { required bool unwatchedOnly, - required List out, + required List out, }) async { for (final item in items) { - final type = item.type?.toLowerCase(); - switch (type) { - case ContentTypes.movie: - case ContentTypes.episode: + switch (item.kind) { + case MediaKind.movie: + case MediaKind.episode: if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) break; out.add(item); - case ContentTypes.show: - await collectEpisodesForShow(client, item.ratingKey, unwatchedOnly: unwatchedOnly, out: out); - case ContentTypes.season: - await collectEpisodesForSeason(client, item.ratingKey, unwatchedOnly: unwatchedOnly, out: out); + case MediaKind.show: + await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out); + case MediaKind.season: + await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: out); default: // Skip music, clips, nested collections/playlists, unknown types. break; @@ -363,6 +439,35 @@ class SyncRuleExecutor { } } + /// Drop items the user already marked watched locally — the server response + /// still shows them as unwatched until the next bidirectional-sync push + /// drains the OfflineWatchProgress queue, which can be many seconds away. + /// Without this filter, sync rules immediately re-queue an episode the + /// user just marked watched on a downloaded-detail screen. + Future> _excludeLocallyWatched({ + required List episodes, + required String serverId, + required String profileId, + String? clientScopeId, + }) async { + if (episodes.isEmpty) return episodes; + final keys = episodes.map((ep) => buildGlobalKey(serverId, ep.id)).toSet(); + final actions = await _database.getLatestWatchActionsForKeys( + keys, + profileId: profileId, + filterProfile: true, + clientScopeIdsByGlobalKey: {for (final key in keys) key: clientScopeId}, + ); + if (actions.isEmpty) return episodes; + return episodes.where((ep) { + final action = actions[buildGlobalKey(serverId, ep.id)]; + if (action == null) return true; + if (action.actionType == OfflineActionType.watched.id) return false; + if (action.actionType == OfflineActionType.progress.id && action.shouldMarkWatched) return false; + return true; + }).toList(); + } + static bool _isActiveDownload(DownloadProgress? p) => p != null && (p.status == DownloadStatus.completed || diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index 366901ba..968014e1 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -2,17 +2,29 @@ import 'dart:async'; import '../mpv/mpv.dart'; -import '../models/plex_media_info.dart'; -import '../models/plex_metadata.dart'; -import '../services/plex_client.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_server_user_profile.dart'; +import '../media/media_source_info.dart'; import '../services/settings_service.dart'; import '../services/track_selection_service.dart'; -import '../models/plex_user_profile.dart'; import '../utils/app_logger.dart'; -import '../utils/content_utils.dart'; import '../utils/language_codes.dart'; import '../utils/track_label_builder.dart'; +/// Persists a track choice through Plex's immediate preference endpoints. +/// Backends that persist through another path (Jellyfin uses playback progress +/// stream indexes) or lack server-side track preferences leave this null. +/// [trackType] is `'audio'` or `'subtitle'`. +typedef TrackPreferencePersister = + Future Function({ + required String id, + required int partId, + required String trackType, + String? languageCode, + int? streamID, + }); + /// Manages track (audio + subtitle) lifecycle: external subtitle loading, /// automatic track selection, server preference sync, and cycling. /// @@ -25,11 +37,13 @@ class TrackManager { /// Returns false once the owning widget is unmounted or disposed. final bool Function() isActive; - /// Resolves the Plex API client for the current server. - final PlexClient Function() getClient; + /// Optional hook for persisting a track choice to Plex immediately. `null` + /// for backends with a different persistence path (Jellyfin) or no + /// server-side track preferences. + final TrackPreferencePersister? persistTrackPreference; /// Resolves the user's profile settings (may be null during loading). - final PlexUserProfile? Function() getProfileSettings; + final MediaServerUserProfile? Function() getProfileSettings; /// Waits until profile settings are available (offline path). final Future Function() waitForProfileSettings; @@ -39,8 +53,8 @@ class TrackManager { // ── Mutable configuration (updated on episode navigation) ────────── - PlexMetadata metadata; - PlexMediaInfo? mediaInfo; + MediaItem metadata; + MediaSourceInfo? mediaInfo; AudioTrack? preferredAudioTrack; SubtitleTrack? preferredSubtitleTrack; SubtitleTrack? preferredSecondarySubtitleTrack; @@ -59,7 +73,7 @@ class TrackManager { TrackManager({ required this.player, required this.isActive, - required this.getClient, + this.persistTrackPreference, required this.getProfileSettings, required this.waitForProfileSettings, required this.metadata, @@ -346,16 +360,18 @@ class TrackManager { // ── Private helpers ──────────────────────────────────────────────── - /// Rating key used for series/movie level language preferences. - String get _preferenceRatingKey { - return metadata.isEpisode ? (metadata.grandparentRatingKey ?? metadata.ratingKey) : metadata.ratingKey; + /// Series/movie-level identifier used for language preferences. + String get _preferenceId { + return metadata.isEpisode ? (metadata.grandparentId ?? metadata.id) : metadata.id; } /// Common guard checks for track change handlers. - Future _guardTrackChange(PlexMediaInfo? info) async { + Future _guardTrackChange(MediaSourceInfo? info) async { final settings = await SettingsService.getInstance(); if (!settings.read(SettingsService.rememberTrackSelections)) return null; + if (persistTrackPreference == null) return null; + if (info == null) { appLogger.w('No media info available, cannot save stream selection'); return null; @@ -377,27 +393,17 @@ class TrackManager { }) async { try { if (!isActive()) return; - final client = getClient(); - final ratingKey = _preferenceRatingKey; - - final futures = []; - - if (languageCode != null && (trackType == 'subtitle' || languageCode.isNotEmpty)) { - futures.add( - trackType == 'audio' - ? client.setMetadataPreferences(ratingKey, audioLanguage: languageCode) - : client.setMetadataPreferences(ratingKey, subtitleLanguage: languageCode), - ); + final persist = persistTrackPreference; + if (persist == null) { + return; } - if (streamID != null) { - futures.add( - trackType == 'audio' - ? client.selectStreams(partId, audioStreamID: streamID, allParts: true) - : client.selectStreams(partId, subtitleStreamID: streamID, allParts: true), - ); - } - - await Future.wait(futures); + await persist( + id: _preferenceId, + partId: partId, + trackType: trackType, + languageCode: languageCode, + streamID: streamID, + ); appLogger.d('Successfully saved $trackType preferences (language + stream)'); } catch (e) { appLogger.e('Failed to save $trackType preferences', error: e); @@ -414,7 +420,7 @@ class TrackManager { required String? Function(T) getTitle, required int Function(T) getId, }) { - final normalizedLang = _iso6391ToPlex6392(mpvLanguage); + final normalizedLang = _iso6391To6392(mpvLanguage); for (final plexTrack in plexTracks) { final matchLang = getLanguageCode(plexTrack) == normalizedLang; @@ -429,8 +435,9 @@ class TrackManager { return null; } - /// Convert ISO 639-1 code (e.g. "fr") to Plex's 639-2 code (e.g. "fre"). - static String? _iso6391ToPlex6392(String? code) { + /// Convert ISO 639-1 code (e.g. "fr") to ISO 639-2/B (e.g. "fre"). Plex + /// streams use the 3-letter form. + static String? _iso6391To6392(String? code) { if (code == null || code.isEmpty) return null; final lang = code.split('-').first.toLowerCase(); diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index ddabaccc..01230e95 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -2,10 +2,11 @@ import 'dart:async'; import '../mpv/mpv.dart'; -import '../models/plex_media_info.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_server_user_profile.dart'; +import '../media/media_source_info.dart'; import '../utils/future_extensions.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_user_profile.dart'; import '../utils/app_logger.dart'; import '../utils/language_codes.dart'; @@ -20,7 +21,7 @@ import '../utils/language_codes.dart'; /// Language (+10 / +1 exact) and codec (+5) carry the most weight; title, /// forced flag, and identical ordinal position (only when [ordinalMatches] /// is true) add smaller nudges. -int _scoreSubtitleMatch(SubtitleTrack mpvTrack, PlexSubtitleTrack plexTrack, {required bool ordinalMatches}) { +int _scoreSubtitleMatch(SubtitleTrack mpvTrack, MediaSubtitleTrack plexTrack, {required bool ordinalMatches}) { int score = 0; if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) { @@ -51,7 +52,7 @@ int _scoreSubtitleMatch(SubtitleTrack mpvTrack, PlexSubtitleTrack plexTrack, {re /// Language (+10 / +1 exact) and codec (+5) dominate; channel count (+3), /// title match (+2), and identical ordinal position ([ordinalMatches], +1) /// act as tiebreakers. -int _scoreAudioMatch(AudioTrack mpvTrack, PlexAudioTrack plexTrack, {required bool ordinalMatches}) { +int _scoreAudioMatch(AudioTrack mpvTrack, MediaAudioTrack plexTrack, {required bool ordinalMatches}) { int score = 0; if (_languagesMatch(mpvTrack.language, plexTrack.languageCode)) { @@ -82,9 +83,9 @@ int _scoreAudioMatch(AudioTrack mpvTrack, PlexAudioTrack plexTrack, {required bo /// Find the MPV subtitle track that matches a Plex subtitle track SubtitleTrack? findMpvTrackForPlexSubtitle( - PlexSubtitleTrack plexTrack, + MediaSubtitleTrack plexTrack, List mpvTracks, { - List? allPlexTracks, + List? allPlexTracks, }) { if (mpvTracks.isEmpty) return null; @@ -130,9 +131,9 @@ SubtitleTrack? findMpvTrackForPlexSubtitle( } /// Find the Plex subtitle track that matches an MPV subtitle track -PlexSubtitleTrack? findPlexTrackForMpvSubtitle( +MediaSubtitleTrack? findPlexTrackForMpvSubtitle( SubtitleTrack mpvTrack, - List plexTracks, { + List plexTracks, { List? allMpvTracks, }) { if (plexTracks.isEmpty) return null; @@ -149,7 +150,7 @@ PlexSubtitleTrack? findPlexTrackForMpvSubtitle( } // For internal subtitles, use scoring based on properties - PlexSubtitleTrack? bestMatch; + MediaSubtitleTrack? bestMatch; int bestScore = 0; // Ordinal tiebreaker: precompute position of mpvTrack among internal tracks @@ -177,9 +178,9 @@ PlexSubtitleTrack? findPlexTrackForMpvSubtitle( /// Find the MPV audio track that matches a Plex audio track AudioTrack? findMpvTrackForPlexAudio( - PlexAudioTrack plexTrack, + MediaAudioTrack plexTrack, List mpvTracks, { - List? allPlexTracks, + List? allPlexTracks, }) { if (mpvTracks.isEmpty) return null; @@ -203,14 +204,14 @@ AudioTrack? findMpvTrackForPlexAudio( } /// Find the Plex audio track that matches an MPV audio track -PlexAudioTrack? findPlexTrackForMpvAudio( +MediaAudioTrack? findPlexTrackForMpvAudio( AudioTrack mpvTrack, - List plexTracks, { + List plexTracks, { List? allMpvTracks, }) { if (plexTracks.isEmpty) return null; - PlexAudioTrack? bestMatch; + MediaAudioTrack? bestMatch; int bestScore = 0; final mpvOrdinal = allMpvTracks?.indexOf(mpvTrack) ?? -1; @@ -335,7 +336,7 @@ bool _titlesMatch(String? mpvTitle, String? plexTitle, String? plexDisplayTitle) /// Priority levels for track selection enum TrackSelectionPriority { navigation, // Priority 1: User's manual selection from previous episode - plexSelected, // Priority 2: Plex's selected track + serverSelected, // Priority 2: server's pre-selected track perMedia, // Priority 3: Per-media language preference profile, // Priority 4: User profile preferences defaultTrack, // Priority 5: Default or first track @@ -354,14 +355,14 @@ class TrackSelectionResult { /// preferences, user profiles, and per-media settings. class TrackSelectionService { final Player player; - final PlexUserProfile? profileSettings; - final PlexMetadata metadata; - final PlexMediaInfo? plexMediaInfo; + final MediaServerUserProfile? profileSettings; + final MediaItem metadata; + final MediaSourceInfo? plexMediaInfo; TrackSelectionService({required this.player, this.profileSettings, required this.metadata, this.plexMediaInfo}); /// Build list of preferred languages from a user profile - List _buildPreferredLanguages(PlexUserProfile profile, {required bool isAudio}) { + List _buildPreferredLanguages(MediaServerUserProfile profile, {required bool isAudio}) { final primary = isAudio ? profile.defaultAudioLanguage : profile.defaultSubtitleLanguage; final list = isAudio ? profile.defaultAudioLanguages : profile.defaultSubtitleLanguages; @@ -445,7 +446,7 @@ class TrackSelectionService { return findBestTrackMatch(availableTracks, preferred, (t) => t.id, (t) => t.title, (t) => t.language); } - AudioTrack? findAudioTrackByProfile(List availableTracks, PlexUserProfile profile) { + AudioTrack? findAudioTrackByProfile(List availableTracks, MediaServerUserProfile profile) { if (availableTracks.isEmpty || !profile.autoSelectAudio) return null; final preferredLanguages = _buildPreferredLanguages(profile, isAudio: true); @@ -465,6 +466,103 @@ class TrackSelectionService { return null; } + SubtitleTrack? _findSubtitleTrackByProfile( + List availableTracks, + MediaServerUserProfile profile, { + bool forcedOnly = false, + }) { + final candidates = forcedOnly ? availableTracks.where((track) => track.isForced).toList() : availableTracks; + if (candidates.isEmpty) return null; + + final preferredLanguages = _buildPreferredLanguages(profile, isAudio: false); + if (preferredLanguages.isEmpty) return null; + + for (final preferredLanguage in preferredLanguages) { + final match = _findTrackByPreferredLanguage( + candidates, + preferredLanguage, + (track) => track.language, + (track) => track.title ?? 'Track ${track.id}', + 'subtitle track', + ); + if (match != null) return match; + } + + return null; + } + + SubtitleTrack? _findDefaultSubtitleTrack(List availableTracks) { + for (final track in availableTracks) { + if (track.isDefault) return track; + } + return null; + } + + SubtitleTrack? _findFirstSubtitleTrack(List availableTracks) { + return availableTracks.isEmpty ? null : availableTracks.first; + } + + SubtitleTrack? _findForcedSubtitleTrack(List availableTracks) { + for (final track in availableTracks) { + if (track.isForced) return track; + } + return null; + } + + bool _audioMatchesProfile(AudioTrack? selectedAudioTrack, MediaServerUserProfile profile) { + if (selectedAudioTrack == null) return false; + final preferredLanguages = _buildPreferredLanguages(profile, isAudio: true); + if (preferredLanguages.isEmpty) return false; + return preferredLanguages.any((language) => languageMatches(selectedAudioTrack.language, language)); + } + + TrackSelectionResult? _selectSubtitleTrackByProfile( + List availableTracks, + AudioTrack? selectedAudioTrack, + ) { + final profile = profileSettings; + final mode = profile?.subtitleMode; + if (profile == null || mode == null || mode == SubtitlePlaybackMode.defaultMode) return null; + + SubtitleTrack? selected; + switch (mode) { + case SubtitlePlaybackMode.none: + selected = SubtitleTrack.off; + break; + case SubtitlePlaybackMode.onlyForced: + selected = + _findSubtitleTrackByProfile(availableTracks, profile, forcedOnly: true) ?? + _findForcedSubtitleTrack(availableTracks) ?? + SubtitleTrack.off; + break; + case SubtitlePlaybackMode.always: + selected = + _findSubtitleTrackByProfile(availableTracks, profile) ?? + _findDefaultSubtitleTrack(availableTracks) ?? + _findFirstSubtitleTrack(availableTracks) ?? + SubtitleTrack.off; + break; + case SubtitlePlaybackMode.smart: + if (_audioMatchesProfile(selectedAudioTrack, profile)) { + selected = + _findSubtitleTrackByProfile(availableTracks, profile, forcedOnly: true) ?? + _findForcedSubtitleTrack(availableTracks) ?? + SubtitleTrack.off; + } else { + selected = + _findSubtitleTrackByProfile(availableTracks, profile) ?? + _findDefaultSubtitleTrack(availableTracks) ?? + _findFirstSubtitleTrack(availableTracks) ?? + SubtitleTrack.off; + } + break; + case SubtitlePlaybackMode.defaultMode: + return null; + } + + return TrackSelectionResult(selected, TrackSelectionPriority.profile); + } + SubtitleTrack? findBestSubtitleMatch(List availableTracks, SubtitleTrack preferred) { // Handle special "no subtitles" case if (preferred.id == 'no') { @@ -561,7 +659,7 @@ class TrackSelectionService { ); if (matchedMpvTrack != null) { - return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.plexSelected); + return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected); } } } @@ -592,10 +690,10 @@ class TrackSelectionService { /// Select the best subtitle track based on priority: /// Priority 1: Preferred track from navigation - /// Priority 2: Plex server's selected track (the server computes this from - /// account prefs, show/season prefs, and per-item stream selections) - /// Priority 3: Default track - /// Priority 4: Off + /// Priority 2: Server-selected track or explicit server off decision + /// Priority 3: User profile subtitle mode + /// Priority 4: Default track + /// Priority 5: Off TrackSelectionResult selectSubtitleTrack( List availableTracks, SubtitleTrack? preferredSubtitleTrack, @@ -613,37 +711,44 @@ class TrackSelectionService { } } - // Priority 2: Trust Plex server's selected track - // The server applies all preference levels (account, show/season, per-item) - // and exposes the result via the `selected` flag on streams. - if (plexMediaInfo != null && availableTracks.isNotEmpty) { - final plexSelectedTrack = plexMediaInfo!.subtitleTracks.where((t) => t.selected).firstOrNull; + // Priority 2: Trust the server's selected track. Plex computes this from + // account/show/per-item prefs; Jellyfin exposes DefaultSubtitleStreamIndex. + final info = plexMediaInfo; + if (info != null) { + final serverSelectedTrack = availableTracks.isNotEmpty + ? info.subtitleTracks.where((track) => track.selected).firstOrNull + : null; - if (plexSelectedTrack != null) { + if (serverSelectedTrack != null) { final matchedMpvTrack = findMpvTrackForPlexSubtitle( - plexSelectedTrack, + serverSelectedTrack, availableTracks, - allPlexTracks: plexMediaInfo!.subtitleTracks, + allPlexTracks: info.subtitleTracks, ); if (matchedMpvTrack != null) { - return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.plexSelected); + return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected); } - } else if (plexMediaInfo!.subtitleTracks.isNotEmpty) { + } else if (metadata.backend == MediaBackend.jellyfin && info.defaultSubtitleStreamIndex == -1) { + return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.serverSelected); + } else if (metadata.backend == MediaBackend.plex && info.subtitleTracks.isNotEmpty) { // Server has subtitle tracks but none selected — trust that decision - return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.plexSelected); + return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.serverSelected); } } - // Priority 3: Check for default subtitle - if (availableTracks.isNotEmpty) { - final defaultTrack = availableTracks.firstWhere((t) => t.isDefault, orElse: () => availableTracks.first); - if (defaultTrack.isDefault) { - return TrackSelectionResult(defaultTrack, TrackSelectionPriority.defaultTrack); - } + // Priority 3: Apply server profile subtitle mode when the backend exposes + // one (Jellyfin). Plex keeps using the selected-stream path above. + final profileSelectedTrack = _selectSubtitleTrackByProfile(availableTracks, selectedAudioTrack); + if (profileSelectedTrack != null) return profileSelectedTrack; + + // Priority 4: Check for default subtitle + final defaultTrack = _findDefaultSubtitleTrack(availableTracks); + if (defaultTrack != null) { + return TrackSelectionResult(defaultTrack, TrackSelectionPriority.defaultTrack); } - // Priority 4: Turn off subtitles + // Priority 5: Turn off subtitles return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.off); } diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index 3c85ad96..b3261c82 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -1,9 +1,10 @@ import 'dart:async'; -import '../../models/plex_metadata.dart'; +import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; +import '../../media/media_server_client.dart'; import '../../models/trackers/tracker_context.dart'; import '../../utils/app_logger.dart'; -import '../plex_client.dart'; import 'anilist/anilist_tracker.dart'; import 'mal/mal_tracker.dart'; import 'simkl/simkl_tracker.dart'; @@ -36,16 +37,16 @@ class TrackerCoordinator { await Future.wait(_trackers.map((t) => t.initialize())); } - Future startPlayback(PlexMetadata metadata, PlexClient plexClient, {bool isLive = false}) async { + Future startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async { if (isLive) return; - final mediaType = metadata.mediaType; - if (mediaType != PlexMediaType.movie && mediaType != PlexMediaType.episode) return; + final mediaType = metadata.kind; + if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) return; if (!_trackers.any((t) => t.canScrobble)) return; - _resolver ??= TrackerIdResolver(plexClient, needsFribb: _anyTrackerNeedsFribb); + _resolver ??= TrackerIdResolver(client, needsFribb: _anyTrackerNeedsFribb); final ctx = await _buildContext(metadata); if (ctx == null) { - appLogger.d('Trackers: no external IDs for ${metadata.ratingKey}'); + appLogger.d('Trackers: no external IDs for ${metadata.id}'); _reset(); return; } @@ -121,19 +122,19 @@ class TrackerCoordinator { ); } - Future _buildContext(PlexMetadata metadata) async { + Future _buildContext(MediaItem metadata) async { final resolver = _resolver; if (resolver == null) return null; - final libraryKey = metadata.librarySectionGlobalKey; + final libraryKey = metadata.libraryGlobalKey; - if (metadata.mediaType == PlexMediaType.movie) { - final ids = await resolver.resolveForMovie(metadata.ratingKey); + if (metadata.kind == MediaKind.movie) { + final ids = await resolver.resolveForMovie(metadata.id); if (ids == null) return null; return TrackerContext.movie( external: ids.external, anime: ids.anime, - ratingKey: metadata.ratingKey, + ratingKey: metadata.id, libraryGlobalKey: libraryKey, ); } @@ -147,7 +148,7 @@ class TrackerCoordinator { return TrackerContext.episode( external: ids.external, anime: ids.anime, - ratingKey: metadata.ratingKey, + ratingKey: metadata.id, libraryGlobalKey: libraryKey, season: season, episodeNumber: number, diff --git a/lib/services/trackers/tracker_id_resolver.dart b/lib/services/trackers/tracker_id_resolver.dart index b57a465b..80725773 100644 --- a/lib/services/trackers/tracker_id_resolver.dart +++ b/lib/services/trackers/tracker_id_resolver.dart @@ -1,37 +1,37 @@ -import '../../models/plex_metadata.dart'; +import '../../media/media_item.dart'; +import '../../media/media_server_client.dart'; import '../../models/trackers/anime_ids.dart'; import '../../models/trackers/fribb_mapping_row.dart'; -import '../../utils/plex_external_ids.dart'; -import '../plex_client.dart'; +import '../../utils/external_ids.dart'; import 'fribb_mapping_store.dart'; /// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus /// optional Fribb-sourced anime IDs (mal/anilist/simkl). Simkl uses [external] /// directly for non-anime titles; MAL/AniList no-op when [anime] is null. class TrackerIds { - final PlexExternalIds external; + final ExternalIds external; final AnimeIds? anime; const TrackerIds({required this.external, required this.anime}); } -/// Resolves Plex ratingKeys → tracker external IDs. Returns both Plex +/// Resolves item ids → tracker external IDs. Returns both backend-native /// external IDs (used by Trakt and by Simkl for non-anime matches) and Fribb /// anime IDs (used by MAL/AniList, and by Simkl for anime precision). /// Episodes resolve against the show's GUIDs because Fribb only maps -/// show-level external IDs; split-cour disambiguation uses the Plex season +/// show-level external IDs; split-cour disambiguation uses the season /// number. /// /// The Fribb lookup is skipped when [needsFribb] returns false — set this way /// for Trakt (which never uses anime IDs) and for a Simkl-only configuration, /// so those users don't pay the 5.6 MB mapping download they'll never need. class TrackerIdResolver { - final PlexClient _client; + final MediaServerClient _client; final FribbMappingStore _store; final bool Function() _needsFribb; - /// Null entries mean "Plex had no GUIDs" — cached so scrubbing on an - /// un-matched item doesn't re-hit Plex every position update. + /// Null entries mean "the server had no IDs" — cached so scrubbing on an + /// un-matched item doesn't re-hit the server every position update. final Map _cache = {}; TrackerIdResolver(this._client, {bool Function()? needsFribb, FribbMappingStore? store}) @@ -40,30 +40,36 @@ class TrackerIdResolver { static bool _returnTrue() => true; - /// Resolve IDs for a movie. - Future resolveForMovie(String ratingKey) async { - if (_cache.containsKey(ratingKey)) return _cache[ratingKey]; + /// Fetch external IDs for an item via the neutral + /// [MediaServerClient.fetchExternalIds] surface — Plex hits + /// `/library/metadata/{id}?includeGuids=1`, Jellyfin reads the inline + /// `ProviderIds` map. + Future _fetchExternalIds(String itemId) => _client.fetchExternalIds(itemId); - final external = PlexExternalIds.fromGuids(await _client.fetchExternalGuids(ratingKey)); + /// Resolve IDs for a movie. + Future resolveForMovie(String itemId) async { + if (_cache.containsKey(itemId)) return _cache[itemId]; + + final external = await _fetchExternalIds(itemId); final ids = await _build(external, isEpisodeSeason: null, isMovie: true); - _cache[ratingKey] = ids; + _cache[itemId] = ids; return ids; } /// Resolve IDs for an episode. Looks up the *show's* external IDs (via - /// `grandparentRatingKey`), then disambiguates among candidate Fribb rows - /// using the episode's season number. - Future resolveShowForEpisode(PlexMetadata episode) async { - final showRatingKey = episode.grandparentRatingKey; - if (showRatingKey == null || showRatingKey.isEmpty) return null; + /// `grandparentId`), then disambiguates among candidate Fribb rows using + /// the episode's season number. + Future resolveShowForEpisode(MediaItem episode) async { + final showId = episode.grandparentId; + if (showId == null || showId.isEmpty) return null; final season = episode.parentIndex; - // Cache under the (showRatingKey, season) pair so a show with multiple - // Fribb rows caches each season separately during a marathon. - final cacheKey = season != null ? '$showRatingKey#s$season' : showRatingKey; + // Cache under the (showId, season) pair so a show with multiple Fribb + // rows caches each season separately during a marathon. + final cacheKey = season != null ? '$showId#s$season' : showId; if (_cache.containsKey(cacheKey)) return _cache[cacheKey]; - final external = PlexExternalIds.fromGuids(await _client.fetchExternalGuids(showRatingKey)); + final external = await _fetchExternalIds(showId); final ids = await _build(external, isEpisodeSeason: season, isMovie: false); _cache[cacheKey] = ids; return ids; @@ -71,7 +77,7 @@ class TrackerIdResolver { void clearCache() => _cache.clear(); - Future _build(PlexExternalIds external, {int? isEpisodeSeason, required bool isMovie}) async { + Future _build(ExternalIds external, {int? isEpisodeSeason, required bool isMovie}) async { if (!external.hasAny) return null; if (!_needsFribb()) return TrackerIds(external: external, anime: null); final rows = await _store.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb); diff --git a/lib/services/trakt/trakt_constants.dart b/lib/services/trakt/trakt_constants.dart index bc8285d9..92e669fb 100644 --- a/lib/services/trakt/trakt_constants.dart +++ b/lib/services/trakt/trakt_constants.dart @@ -54,12 +54,13 @@ enum TraktSyncOp { values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktSyncOp: $name')); } -/// Trakt-relevant Plex media types. +/// Trakt-relevant media types. Accepts the neutral [MediaKind.id] string +/// (`'movie'`, `'episode'`) used across both Plex and Jellyfin watch events. enum TraktMediaKind { movie, episode; - static TraktMediaKind? tryFromPlexType(String type) => switch (type) { + static TraktMediaKind? tryFromMediaKindId(String type) => switch (type) { 'movie' => movie, 'episode' => episode, _ => null, diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart index abca3e80..86919efc 100644 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -1,10 +1,11 @@ import 'dart:async'; -import '../../models/plex_metadata.dart'; +import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; +import '../../media/media_server_client.dart'; import '../../models/trakt/trakt_ids.dart'; import '../../models/trakt/trakt_scrobble_request.dart'; import '../../utils/app_logger.dart'; -import '../plex_client.dart'; import '../settings_service.dart'; import '../trackers/tracker_constants.dart'; import '../trackers/tracker_id_resolver.dart'; @@ -85,30 +86,29 @@ class TraktScrobbleService { bool get _canScrobble => _isEnabled && _client != null; - Future startPlayback(PlexMetadata metadata, PlexClient plexClient, {bool isLive = false}) async { + Future startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async { if (!_canScrobble) return; if (isLive) return; - final type = metadata.mediaType; - if (type != PlexMediaType.movie && type != PlexMediaType.episode) return; + final type = metadata.kind; + if (type != MediaKind.movie && type != MediaKind.episode) return; final settings = SettingsService.instanceOrNull; - if (settings != null && - !settings.isLibraryAllowedForTracker(TrackerService.trakt, metadata.librarySectionGlobalKey)) { - appLogger.d('Trakt: library filtered out for ${metadata.ratingKey}'); + if (settings != null && !settings.isLibraryAllowedForTracker(TrackerService.trakt, metadata.libraryGlobalKey)) { + appLogger.d('Trakt: library filtered out for ${metadata.id}'); return; } // Seed with the resume offset so the first real position update doesn't // look like a seek when resuming mid-item. - _currentPosition = metadata.viewOffset != null ? Duration(milliseconds: metadata.viewOffset!) : Duration.zero; - _currentDuration = metadata.duration != null ? Duration(milliseconds: metadata.duration!) : Duration.zero; + _currentPosition = metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : Duration.zero; + _currentDuration = metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : Duration.zero; _lastSeekCheckpointAt = null; - _resolver = TrackerIdResolver(plexClient, needsFribb: () => false); + _resolver = TrackerIdResolver(client, needsFribb: () => false); final body = await _buildBody(metadata); if (body == null) { - appLogger.d('Trakt: skipping scrobble — no usable IDs for ${metadata.ratingKey}'); + appLogger.d('Trakt: skipping scrobble — no usable IDs for ${metadata.id}'); cancelInFlight(); return; } @@ -156,12 +156,12 @@ class TraktScrobbleService { cancelInFlight(); } - Future _buildBody(PlexMetadata metadata) async { + Future _buildBody(MediaItem metadata) async { final resolver = _resolver; if (resolver == null) return null; - if (metadata.mediaType == PlexMediaType.movie) { - final ids = await resolver.resolveForMovie(metadata.ratingKey); + if (metadata.kind == MediaKind.movie) { + final ids = await resolver.resolveForMovie(metadata.id); if (ids == null) return null; return TraktScrobbleRequest.movie(ids: TraktIds.fromExternal(ids.external)); } diff --git a/lib/services/trakt/trakt_sync_service.dart b/lib/services/trakt/trakt_sync_service.dart index 7015eccb..d30ba550 100644 --- a/lib/services/trakt/trakt_sync_service.dart +++ b/lib/services/trakt/trakt_sync_service.dart @@ -38,8 +38,9 @@ class TraktSyncService { StreamSubscription? _subscription; final TraktSyncQueue _queue = TraktSyncQueue(); - /// One resolver per Plex server, kept alive across events so the per-rating- - /// key GUID cache survives a binge-watch session. + /// One resolver per server, kept alive across events so the per-item + /// external-id cache survives a binge-watch session. Backend-neutral — + /// Plex resolves via `?includeGuids=1`, Jellyfin reads inline `ProviderIds`. final Map _resolvers = {}; /// Fallback buffer for items that failed to persist to the on-disk queue @@ -69,8 +70,8 @@ class TraktSyncService { _isEnabled = enabled; } - /// Switch to a different account. Drops cached resolvers (their PlexClients - /// are tied to the previous user's tokens) and rebinds the queue. + /// Switch to a different account. Drops cached resolvers (their backing + /// clients are tied to the previous user's tokens) and rebinds the queue. void rebindToProfile(String userUuid, TraktSession? session, {required void Function() onSessionInvalidated}) { _client?.dispose(); _client = session != null ? TraktClient(session, onSessionInvalidated: onSessionInvalidated) : null; @@ -95,10 +96,13 @@ class TraktSyncService { final cached = _resolvers[serverId]; if (cached != null) return cached; - final plexClient = _serverManager?.getClient(serverId); - if (plexClient == null) return null; + // Backend-neutral: TrackerIdResolver pulls external IDs through + // MediaServerClient.fetchExternalIds — Plex hits `?includeGuids=1`, + // Jellyfin reads the inline `ProviderIds` map. + final mediaClient = _serverManager?.getClient(serverId); + if (mediaClient == null) return null; - final resolver = TrackerIdResolver(plexClient, needsFribb: () => false); + final resolver = TrackerIdResolver(mediaClient, needsFribb: () => false); _resolvers[serverId] = resolver; return resolver; } @@ -107,19 +111,19 @@ class TraktSyncService { if (!_canPush) return; if (event.changeType == WatchStateChangeType.progressUpdate) return; - final kind = TraktMediaKind.tryFromPlexType(event.mediaType); + final kind = TraktMediaKind.tryFromMediaKindId(event.mediaType); if (kind == null) return; final settings = SettingsService.instanceOrNull; if (settings != null && !settings.isLibraryAllowedForTracker(TrackerService.trakt, event.librarySectionGlobalKey)) { - appLogger.d('Trakt sync: library filtered out for ${event.ratingKey}'); + appLogger.d('Trakt sync: library filtered out for ${event.itemId}'); return; } final op = event.changeType == WatchStateChangeType.watched ? TraktSyncOp.add : TraktSyncOp.remove; await _push( op: op, - ratingKey: event.ratingKey, + ratingKey: event.itemId, serverId: event.serverId, kind: kind, watchedAtIso: DateTime.now().toUtc().toIso8601String(), @@ -135,7 +139,7 @@ class TraktSyncService { }) async { final resolver = _resolverFor(serverId); if (resolver == null) { - appLogger.d('Trakt sync: no PlexClient for server $serverId, skipping'); + appLogger.d('Trakt sync: no client registered for server $serverId, skipping'); return; } @@ -147,10 +151,12 @@ class TraktSyncService { resolved = await resolver.resolveForMovie(ratingKey); } else { // Episode — need show IDs + season/episode index. The WatchStateEvent - // doesn't carry the index, so fetch episode metadata. - final plexClient = _serverManager?.getClient(serverId); - if (plexClient == null) return; - final episodeMeta = await plexClient.getMetadataWithImages(ratingKey); + // doesn't carry the index, so fetch episode metadata via the neutral + // MediaServerClient surface (Plex `/library/metadata`, Jellyfin + // `/Users/{id}/Items/{id}`). + final mediaClient = _serverManager?.getClient(serverId); + if (mediaClient == null) return; + final episodeMeta = await mediaClient.fetchItem(ratingKey); if (episodeMeta == null) return; season = episodeMeta.parentIndex; number = episodeMeta.index; diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 368197ca..5999aa58 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:auto_updater/auto_updater.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:logger/logger.dart'; -import 'package:plezy/utils/plex_http_client.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; import 'base_shared_preferences_service.dart'; /// Service to check for new versions on GitHub diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index 67b0b3f0..e3080731 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -4,7 +4,7 @@ import 'package:rate_limiter/rate_limiter.dart'; import '../mpv/mpv.dart'; import '../mpv/player/platform/player_android.dart'; -import '../models/plex_media_version.dart'; +import '../media/media_version.dart'; import '../utils/app_logger.dart'; import 'ambient_lighting_service.dart'; @@ -18,7 +18,7 @@ import 'ambient_lighting_service.dart'; /// - Ambient-lighting-friendly reset to contain mode class VideoFilterManager { final Player player; - final List availableVersions; + final List availableVersions; final int selectedMediaIndex; /// BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) diff --git a/lib/services/watch_next_service.dart b/lib/services/watch_next_service.dart index d0410d7d..c504e1bc 100644 --- a/lib/services/watch_next_service.dart +++ b/lib/services/watch_next_service.dart @@ -2,13 +2,14 @@ import 'dart:io' show Platform; import 'package:flutter/services.dart'; -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; import '../utils/app_logger.dart'; -import '../utils/content_utils.dart'; -import 'plex_client.dart'; import 'settings_service.dart' show EpisodePosterMode; -/// Service for syncing Plex "On Deck" content to Android TV's Watch Next row. +/// Service for syncing On Deck / Continue Watching content to Android TV's Watch Next row. class WatchNextService { static const MethodChannel _channel = MethodChannel('com.plezy/watch_next'); @@ -54,8 +55,8 @@ class WatchNextService { /// Sync On Deck items to Watch Next row. Future syncFromOnDeck( - List onDeckItems, - PlexClient Function(String serverId) getClientForServerId, { + List onDeckItems, + MediaServerClient Function(String serverId) getClientForServerId, { bool hideSpoilers = false, }) async { if (!Platform.isAndroid) return false; @@ -112,11 +113,11 @@ class WatchNextService { } Map _convertToWatchNextItem( - PlexMetadata item, - PlexClient Function(String serverId) getClientForServerId, { + MediaItem item, + MediaServerClient Function(String serverId) getClientForServerId, { bool hideSpoilers = false, }) { - final contentId = _buildContentId(item.serverId, item.ratingKey); + final contentId = _buildContentId(item.serverId, item.id); String? posterUri; try { @@ -128,7 +129,7 @@ class WatchNextService { } thumbPath ??= item.posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true); if (thumbPath != null) { - posterUri = client.getThumbnailUrl(thumbPath); + posterUri = client.thumbnailUrl(thumbPath); } } } catch (e) { @@ -137,11 +138,11 @@ class WatchNextService { final String title; final String? episodeTitle; - if (item.mediaType == PlexMediaType.episode && item.grandparentTitle != null) { + if (item.kind == MediaKind.episode && item.grandparentTitle != null) { title = item.grandparentTitle!; episodeTitle = item.title; } else { - title = item.title!; + title = item.title ?? ''; episodeTitle = null; } @@ -155,9 +156,9 @@ class WatchNextService { 'episodeTitle': episodeTitle, 'description': item.summary, 'posterUri': posterUri, - 'type': item.mediaType.name, - 'duration': item.duration ?? 0, - 'lastPlaybackPosition': item.viewOffset ?? 0, + 'type': item.kind.name, + 'duration': item.durationMs ?? 0, + 'lastPlaybackPosition': item.viewOffsetMs ?? 0, 'lastEngagementTime': lastEngagementTime, 'seriesTitle': item.grandparentTitle, 'seasonNumber': item.parentIndex, diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 3e6db52d..a48ad040 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -115,8 +115,10 @@ class MemoryAwareLogPrinter extends LogPrinter { MemoryLogOutput._currentSize -= removed.estimatedSize; } - // Delegate to wrapped printer for console output - return _wrappedPrinter.log(event); + // Delegate a redacted event to the wrapped printer for console output. + return _wrappedPrinter.log( + LogEvent(event.level, message, time: event.time, error: error, stackTrace: event.stackTrace), + ); } } diff --git a/lib/utils/connection_constants.dart b/lib/utils/connection_constants.dart deleted file mode 100644 index 4691aecd..00000000 --- a/lib/utils/connection_constants.dart +++ /dev/null @@ -1,29 +0,0 @@ -/// Centralized connection timeout constants used across the app. -class ConnectionTimeouts { - /// Timeout for probing a cached/preferred endpoint before falling back to - /// the full candidate race (used in [PlexServer.findBestWorkingConnection]). - static const preferredEndpointProbe = Duration(milliseconds: 1500); - - /// Timeout for the connection race where all candidates are tested in - /// parallel (used in [PlexServer.findBestWorkingConnection]). - static const connectionRace = Duration(seconds: 2); - - /// HTTP connect timeout for individual HTTP requests to a Plex server. - static const connect = Duration(seconds: 10); - - /// HTTP timeout for the live-TV tune POST. Matches Plex web's value — the - /// default 10s connect budget is too tight on Fire-TV cold starts. - static const tune = Duration(seconds: 30); - - /// Per-server connection budget: preferred probe + race + HTTPS upgrade attempt + 1s buffer. - static const perServerConnect = Duration(milliseconds: 1500 + 2000 + 2000 + 1000); - - /// HTTP receive timeout for streaming/large responses from a Plex server. - static const receive = Duration(seconds: 120); - - /// HTTP connect timeout for plex.tv / clients.plex.tv API requests. - static const plexTvConnect = Duration(seconds: 5); - - /// HTTP receive timeout for plex.tv / clients.plex.tv API responses. - static const plexTvReceive = Duration(seconds: 10); -} diff --git a/lib/utils/content_utils.dart b/lib/utils/content_utils.dart index ec8b76fd..ea9f8b7f 100644 --- a/lib/utils/content_utils.dart +++ b/lib/utils/content_utils.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../models/plex_metadata.dart'; - /// Content type constants used throughout the app class ContentTypes { ContentTypes._(); @@ -33,11 +31,12 @@ class ContentTypeHelper { /// Checks if the given type is video content (movie, show, episode, or season) static bool isVideoContent(String type) => ContentTypes.videoTypes.contains(type.toLowerCase()); - /// Checks if the given library is a music library + /// Checks if the given [MediaLibrary] is a music library. static bool isMusicLibrary(dynamic lib) { if (lib == null) return false; try { - final type = (lib as dynamic).type as String?; + // ignore: avoid_dynamic_calls — duck-typed across library shapes + final type = (lib as dynamic).kind?.id as String?; return type?.toLowerCase() == ContentTypes.artist; } catch (e) { return false; @@ -80,30 +79,3 @@ String formatContentRating(String? contentRating) { return contentRating; } - -/// Extension on PlexMetadata for type checking convenience methods -extension PlexMetadataType on PlexMetadata { - String get _lowerType => type?.toLowerCase() ?? ''; - - bool get isShow => _lowerType == ContentTypes.show; - bool get isMovie => _lowerType == ContentTypes.movie; - bool get isSeason => _lowerType == ContentTypes.season; - bool get isEpisode => _lowerType == ContentTypes.episode; - bool get isCollection => _lowerType == ContentTypes.collection; - bool get isMusicContent => ContentTypes.musicTypes.contains(_lowerType); - bool get isVideoContent => ContentTypes.videoTypes.contains(_lowerType); - - /// Whether this episode should have spoiler protection applied. - /// True when the item is an unwatched episode watched less than 50%. - bool get shouldHideSpoiler { - if (!isEpisode) return false; - if (isWatched) return false; - if (viewOffset != null && viewOffset! > 0 && duration != null && duration! > 0) { - return viewOffset! / duration! < 0.5; - } - return true; - } - - /// Non-spoiler art path for episodes (show/season background). - String? get spoilerSafeArt => grandparentArt ?? art; -} diff --git a/lib/utils/deletion_notifier.dart b/lib/utils/deletion_notifier.dart index e323a19d..6dfb3f93 100644 --- a/lib/utils/deletion_notifier.dart +++ b/lib/utils/deletion_notifier.dart @@ -1,4 +1,4 @@ -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; @@ -6,11 +6,11 @@ import 'hierarchical_event_mixin.dart'; /// Event representing a media item deletion with parent chain for hierarchical invalidation class DeletionEvent with HierarchicalEventMixin { - /// The ratingKey of the deleted item + /// The id of the deleted item (Plex ratingKey, Jellyfin GUID, …). @override - final String ratingKey; + final String itemId; - /// Composite key: serverId:ratingKey + /// Composite key: serverId:itemId @override final String globalKey; @@ -19,8 +19,8 @@ class DeletionEvent with HierarchicalEventMixin { final String serverId; /// Parent chain for hierarchical invalidation - /// For an episode: [seasonRatingKey, showRatingKey] - /// For a season: [showRatingKey] + /// For an episode: [seasonId, showId] + /// For a season: [showId] /// For a movie: [] @override final List parentChain; @@ -37,13 +37,13 @@ class DeletionEvent with HierarchicalEventMixin { final bool isDownloadOnly; DeletionEvent({ - required this.ratingKey, + required this.itemId, required this.serverId, required this.parentChain, required this.mediaType, this.leafCount = 1, this.isDownloadOnly = false, - }) : globalKey = buildGlobalKey(serverId, ratingKey); + }) : globalKey = buildGlobalKey(serverId, itemId); @override String toString() => 'DeletionEvent(deleted: $globalKey, type: $mediaType, parents: $parentChain)'; @@ -64,7 +64,7 @@ class DeletionNotifier extends BaseNotifier { Stream forServer(String serverId) => stream.where((e) => e.serverId == serverId); /// Filter for events affecting a specific item or its children - Stream forItem(String ratingKey) => stream.where((e) => e.affectsItem(ratingKey)); + Stream forItem(String itemId) => stream.where((e) => e.affectsItem(itemId)); /// Emit a deletion event with logging @override @@ -73,15 +73,15 @@ class DeletionNotifier extends BaseNotifier { super.notify(event); } - /// Helper to emit a deletion event from metadata - void notifyDeleted({required PlexMetadata metadata, bool isDownloadOnly = false}) { + /// Helper to emit a deletion event from a [MediaItem]. + void notifyDeletedItem({required MediaItem item, bool isDownloadOnly = false}) { notify( DeletionEvent( - ratingKey: metadata.ratingKey, - serverId: metadata.serverId ?? '', - parentChain: metadata.parentChain, - mediaType: metadata.type ?? '', - leafCount: metadata.leafCount ?? 1, + itemId: item.id, + serverId: item.serverId ?? '', + parentChain: item.parentChain, + mediaType: item.kind.id, + leafCount: item.leafCount ?? 1, isDownloadOnly: isDownloadOnly, ), ); diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index 78416bd0..5527b41a 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; import '../database/app_database.dart'; import '../providers/download_provider.dart'; -import '../services/plex_client.dart'; import '../services/sync_rule_executor.dart'; import 'content_utils.dart'; import 'dialogs.dart'; import 'download_version_utils.dart'; -import 'global_key_utils.dart'; import 'snackbar_helper.dart'; /// Dialog option for the download picker. Typed to avoid stringly-typed values. @@ -51,17 +51,17 @@ class DownloadResult { /// Returns a [DownloadResult], or null if cancelled. Future showDownloadOptionsAndQueue( BuildContext context, { - required PlexMetadata metadata, - required PlexClient client, + required MediaItem metadata, + required MediaServerClient client, required DownloadProvider downloadProvider, }) async { - final mt = metadata.mediaType; + final kind = metadata.kind; var filter = DownloadFilter.all; int? maxCount; bool keepSynced = false; - if (mt == PlexMediaType.show || mt == PlexMediaType.season) { + if (kind == MediaKind.show || kind == MediaKind.season) { int? customCount; final selected = await showOptionPickerDialog<_DownloadChoice>( context, @@ -103,7 +103,7 @@ Future showDownloadOptionsAndQueue( } // For unwatched-based options on shows, offer sync vs one-time download - if (filter == DownloadFilter.unwatched && mt == PlexMediaType.show && context.mounted) { + if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) { final syncChoice = await showOptionPickerDialog<_SyncChoice>( context, title: t.downloads.downloadNow, @@ -125,16 +125,17 @@ Future showDownloadOptionsAndQueue( // Create or update sync rule before queueing (so the rule exists even if queue fails) bool syncRuleUpdated = false; if (keepSynced) { - final globalKey = buildGlobalKey(metadata.serverId ?? client.serverId, metadata.ratingKey); - syncRuleUpdated = downloadProvider.hasSyncRule(globalKey); - final syncCount = maxCount ?? 0; // 0 means "all unwatched" for the rule + final ruleKey = downloadProvider.syncRuleKeyFor(metadata.serverId ?? client.serverId, metadata.id); + syncRuleUpdated = downloadProvider.hasSyncRule(ruleKey); + await downloadProvider.createSyncRule( serverId: metadata.serverId ?? client.serverId, - ratingKey: metadata.ratingKey, - targetType: metadata.type ?? ContentTypes.show, + ratingKey: metadata.id, + targetType: metadata.kind.id.isNotEmpty ? metadata.kind.id : ContentTypes.show, episodeCount: syncCount, mediaIndex: versionConfig.mediaIndex, + targetMetadata: metadata, ); } @@ -162,10 +163,10 @@ Future showDownloadOptionsAndQueue( /// [targetType] must be [ContentTypes.collection] or [ContentTypes.playlist]. Future showListDownloadOptionsAndQueue( BuildContext context, { - required PlexMetadata rootMetadata, + required MediaItem rootMetadata, required String targetType, - required List items, - required PlexClient client, + required List items, + required MediaServerClient client, required DownloadProvider downloadProvider, }) async { assert(targetType == ContentTypes.collection || targetType == ContentTypes.playlist); @@ -192,20 +193,20 @@ Future showListDownloadOptionsAndQueue( if (syncChoice == null || !context.mounted) return null; final serverId = rootMetadata.serverId ?? client.serverId; - final globalKey = buildGlobalKey(serverId, rootMetadata.ratingKey); final filterString = selectedFilter == DownloadFilter.unwatched ? SyncRuleFilter.unwatched : SyncRuleFilter.all; bool syncRuleCreated = false; bool syncRuleUpdated = false; if (syncChoice == _SyncChoice.keepSynced) { - if (downloadProvider.hasSyncRule(globalKey)) { - await downloadProvider.updateSyncRuleFilter(globalKey, filterString); + final ruleKey = downloadProvider.syncRuleKeyFor(serverId, rootMetadata.id); + if (downloadProvider.hasSyncRule(ruleKey)) { + await downloadProvider.updateSyncRuleFilter(ruleKey, filterString); syncRuleUpdated = true; } else { await downloadProvider.createSyncRule( serverId: serverId, - ratingKey: rootMetadata.ratingKey, + ratingKey: rootMetadata.id, targetType: targetType, episodeCount: 0, mediaIndex: 0, @@ -229,9 +230,9 @@ Future showListDownloadOptionsAndQueue( /// Shows the shared list-download dialog for a playlist. Future showPlaylistDownloadOptionsAndQueue( BuildContext context, { - required PlexMetadata playlistMetadata, - required List items, - required PlexClient client, + required MediaItem playlistMetadata, + required List items, + required MediaServerClient client, required DownloadProvider downloadProvider, }) => showListDownloadOptionsAndQueue( context, @@ -245,9 +246,9 @@ Future showPlaylistDownloadOptionsAndQueue( /// Shows the shared list-download dialog for a collection. Future showCollectionDownloadOptionsAndQueue( BuildContext context, { - required PlexMetadata collectionMetadata, - required List items, - required PlexClient client, + required MediaItem collectionMetadata, + required List items, + required MediaServerClient client, required DownloadProvider downloadProvider, }) => showListDownloadOptionsAndQueue( context, diff --git a/lib/utils/download_version_utils.dart b/lib/utils/download_version_utils.dart index 9ecb42a1..8c0f29eb 100644 --- a/lib/utils/download_version_utils.dart +++ b/lib/utils/download_version_utils.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../models/plex_media_version.dart'; -import '../models/plex_metadata.dart'; -import '../services/plex_client.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; +import '../media/media_version.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../i18n/strings.g.dart'; @@ -11,7 +12,7 @@ import '../i18n/strings.g.dart'; class DownloadVersionConfig { final int mediaIndex; final Set acceptedSignatures; - final Future Function(PlexMetadata episode, List versions)? onVersionMismatch; + final Future Function(MediaItem episode, List versions)? onVersionMismatch; DownloadVersionConfig({this.mediaIndex = 0, Set? acceptedSignatures, this.onVersionMismatch}) : acceptedSignatures = acceptedSignatures ?? {}; @@ -20,7 +21,7 @@ class DownloadVersionConfig { factory DownloadVersionConfig.fromSignature( String signature, { int mediaIndex = 0, - Future Function(PlexMetadata, List)? onVersionMismatch, + Future Function(MediaItem, List)? onVersionMismatch, }) { return DownloadVersionConfig( mediaIndex: mediaIndex, @@ -34,13 +35,13 @@ class DownloadVersionConfig { /// Returns null if the user cancels, or a config with the selection. Future resolveDownloadVersion( BuildContext context, - PlexMetadata metadata, - PlexClient client, { - List? fallbackVersions, + MediaItem metadata, + MediaServerClient client, { + List? fallbackVersions, }) async { - final mediaType = metadata.mediaType; + final kind = metadata.kind; - if (mediaType == PlexMediaType.movie || mediaType == PlexMediaType.episode) { + if (kind == MediaKind.movie || kind == MediaKind.episode) { final versions = metadata.mediaVersions ?? fallbackVersions; if (versions != null && versions.length > 1) { final selectedIndex = await showVersionPickerDialog(context, versions, t.downloads.selectVersion); @@ -50,7 +51,7 @@ Future resolveDownloadVersion( return DownloadVersionConfig(); } - if (mediaType == PlexMediaType.show || mediaType == PlexMediaType.season) { + if (kind == MediaKind.show || kind == MediaKind.season) { final versions = await fetchRepresentativeVersions(client, metadata); if (versions != null && versions.length > 1) { if (!context.mounted) return null; @@ -77,7 +78,7 @@ Future resolveDownloadVersion( /// Show a dialog for selecting a media version. /// Returns the selected index, or null if cancelled. -Future showVersionPickerDialog(BuildContext context, List versions, String title) { +Future showVersionPickerDialog(BuildContext context, List versions, String title) { return showOptionPickerDialog( context, title: title, @@ -89,31 +90,30 @@ Future showVersionPickerDialog(BuildContext context, List?> fetchRepresentativeVersions(PlexClient client, PlexMetadata metadata) async { +Future?> fetchRepresentativeVersions(MediaServerClient client, MediaItem metadata) async { try { String? episodeRatingKey; - if (metadata.mediaType == PlexMediaType.season) { - final episodes = await client.getChildren(metadata.ratingKey); - final firstEpisode = episodes.cast().firstWhere((e) => e?.type == 'episode', orElse: () => null); - episodeRatingKey = firstEpisode?.ratingKey; - } else if (metadata.mediaType == PlexMediaType.show) { - final seasons = await client.getChildren(metadata.ratingKey); + if (metadata.kind == MediaKind.season) { + final episodes = await client.fetchChildren(metadata.id); + final firstEpisode = episodes.where((e) => e.kind == MediaKind.episode).firstOrNull; + episodeRatingKey = firstEpisode?.id; + } else if (metadata.kind == MediaKind.show) { + final seasons = await client.fetchChildren(metadata.id); // Skip Season 0 (Specials) as it may have different encoding - final firstSeason = seasons.cast().firstWhere( - (s) => s?.type == 'season' && (s?.index ?? 0) > 0, - orElse: () => seasons.cast().firstWhere((s) => s?.type == 'season', orElse: () => null), - ); + final firstSeason = + seasons.where((s) => s.kind == MediaKind.season && (s.index ?? 0) > 0).firstOrNull ?? + seasons.where((s) => s.kind == MediaKind.season).firstOrNull; if (firstSeason != null) { - final episodes = await client.getChildren(firstSeason.ratingKey); - final firstEpisode = episodes.cast().firstWhere((e) => e?.type == 'episode', orElse: () => null); - episodeRatingKey = firstEpisode?.ratingKey; + final episodes = await client.fetchChildren(firstSeason.id); + final firstEpisode = episodes.where((e) => e.kind == MediaKind.episode).firstOrNull; + episodeRatingKey = firstEpisode?.id; } } if (episodeRatingKey == null) return null; - final fullMetadata = await client.getMetadataWithImages(episodeRatingKey); + final fullMetadata = await client.fetchItem(episodeRatingKey); return fullMetadata?.mediaVersions; } catch (e) { appLogger.w('Failed to fetch representative versions', error: e); diff --git a/lib/utils/episode_collection.dart b/lib/utils/episode_collection.dart index c361bfc6..0bfce6b2 100644 --- a/lib/utils/episode_collection.dart +++ b/lib/utils/episode_collection.dart @@ -1,51 +1,48 @@ -import '../models/plex_metadata.dart'; -import '../services/plex_client.dart'; -import '../utils/app_logger.dart'; -import '../utils/content_utils.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; -/// Walk the children of a show and collect every episode into [out]. +/// Collect every episode of a show into [out] using the backend's one-shot +/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants] — +/// Plex's `/library/metadata/{id}/allLeaves`, Jellyfin's +/// `/Items?Recursive=true&IncludeItemTypes=Movie,Episode`). Avoids walking +/// show → seasons → episodes client-side, so large series come back in one +/// trip and aren't capped by any per-page Limit. /// -/// - Per-season fetch failures are logged and skipped (one bad season doesn't -/// discard progress from the others). -/// - A failure to fetch the show's own children is logged and leaves [out] -/// empty. -/// - [unwatchedOnly] skips episodes that are watched and have no active -/// progress. +/// A failure of the underlying call propagates to the caller — both +/// [DownloadProvider.queueDownload] and the sync rule executor wrap their +/// invocations so the user-facing error surfaces / the rule run is rolled +/// back. Future collectEpisodesForShow( - PlexClient client, + MediaServerClient client, String showRatingKey, { required bool unwatchedOnly, - required List out, -}) async { - final List seasons; - try { - seasons = await client.getChildren(showRatingKey); - } catch (e) { - appLogger.w('Episode collection: show $showRatingKey getChildren failed, skipping', error: e); - return; - } - for (final season in seasons) { - if (season.type != ContentTypes.season) continue; - try { - await collectEpisodesForSeason(client, season.ratingKey, unwatchedOnly: unwatchedOnly, out: out); - } catch (e) { - appLogger.w('Episode collection: season ${season.ratingKey} fetch failed, skipping', error: e); - } - } + required List out, +}) { + return _collectPlayable(client, showRatingKey, unwatchedOnly: unwatchedOnly, out: out); } -/// Fetch the episodes of a season and append the ones passing [unwatchedOnly] -/// to [out]. Throws if the underlying `getChildren` fails — callers that want -/// per-season resilience should wrap in try/catch (see [collectEpisodesForShow]). +/// Collect every episode of a single season into [out] via the same +/// one-shot endpoint. On a season the leaves *are* the episodes, so the +/// shape matches the show case. Future collectEpisodesForSeason( - PlexClient client, + MediaServerClient client, String seasonRatingKey, { required bool unwatchedOnly, - required List out, + required List out, +}) { + return _collectPlayable(client, seasonRatingKey, unwatchedOnly: unwatchedOnly, out: out); +} + +Future _collectPlayable( + MediaServerClient client, + String parentId, { + required bool unwatchedOnly, + required List out, }) async { - final episodes = await client.getChildren(seasonRatingKey); - for (final ep in episodes) { - if (ep.type != ContentTypes.episode) continue; + final leaves = await client.fetchPlayableDescendants(parentId); + for (final ep in leaves) { + if (ep.kind != MediaKind.episode) continue; if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue; out.add(ep); } diff --git a/lib/utils/error_message_utils.dart b/lib/utils/error_message_utils.dart index 8f9a2ef0..b5ca0d77 100644 --- a/lib/utils/error_message_utils.dart +++ b/lib/utils/error_message_utils.dart @@ -1,18 +1,19 @@ import '../i18n/strings.g.dart'; import 'app_logger.dart'; -import 'plex_http_exception.dart'; +import '../exceptions/media_server_exceptions.dart'; /// Shared helpers for translating network errors into user-friendly messages. -String mapHttpErrorToMessage(PlexHttpException error, {required String context}) { +String mapHttpErrorToMessage(MediaServerHttpException error, {required String context}) { switch (error.type) { - case PlexHttpErrorType.connectionTimeout: - case PlexHttpErrorType.receiveTimeout: + case MediaServerHttpErrorType.connectionTimeout: + case MediaServerHttpErrorType.receiveTimeout: return t.errors.connectionTimeout(context: context); - case PlexHttpErrorType.connectionError: + case MediaServerHttpErrorType.connectionError: return t.errors.connectionFailed; default: appLogger.e('Error loading $context', error: error); - return t.errors.failedToLoad(context: context, error: error.message ?? t.common.unknown); + final msg = error.message.isNotEmpty ? error.message : t.common.unknown; + return t.errors.failedToLoad(context: context, error: msg); } } diff --git a/lib/utils/external_ids.dart b/lib/utils/external_ids.dart new file mode 100644 index 00000000..6b4359c5 --- /dev/null +++ b/lib/utils/external_ids.dart @@ -0,0 +1,60 @@ +/// External IDs (IMDb / TMDB / TVDB) extracted from a media server's +/// metadata. Shared by the Trakt and tracker resolvers. +/// +/// - **Plex** stores them in a `Guid` array (`imdb://tt123`, +/// `tmdb://456`, `tvdb://789`) — fetched via +/// [PlexClient.fetchExternalGuids]. Use [ExternalIds.fromGuids]. +/// - **Jellyfin** stores them inline as a `ProviderIds` map on every +/// `BaseItemDto`. Use [ExternalIds.fromJellyfinProviderIds]. +class ExternalIds { + final String? imdb; + final int? tmdb; + final int? tvdb; + + const ExternalIds({this.imdb, this.tmdb, this.tvdb}); + + bool get hasAny => imdb != null || tmdb != null || tvdb != null; + + factory ExternalIds.fromGuids(List guids) { + String? imdb; + int? tmdb; + int? tvdb; + for (final g in guids) { + if (g is! Map) continue; + final id = g['id']; + if (id is! String) continue; + if (id.startsWith('imdb://')) { + imdb = id.substring(7); + } else if (id.startsWith('tmdb://')) { + tmdb = int.tryParse(id.substring(7)); + } else if (id.startsWith('tvdb://')) { + tvdb = int.tryParse(id.substring(7)); + } + } + return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb); + } + + /// Build from a Jellyfin `ProviderIds` map. Jellyfin stores external IDs + /// directly on every `BaseItemDto` so no extra fetch is needed. + /// Keys are case-insensitive in practice (`Tmdb`, `Imdb`, `Tvdb`). + factory ExternalIds.fromJellyfinProviderIds(Map providerIds) { + String? imdb; + int? tmdb; + int? tvdb; + providerIds.forEach((key, value) { + if (value is! String || value.isEmpty) return; + switch (key.toLowerCase()) { + case 'imdb': + imdb = value; + break; + case 'tmdb': + tmdb = int.tryParse(value); + break; + case 'tvdb': + tvdb = int.tryParse(value); + break; + } + }); + return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb); + } +} diff --git a/lib/utils/global_key_utils.dart b/lib/utils/global_key_utils.dart index cf696eaf..a8901d36 100644 --- a/lib/utils/global_key_utils.dart +++ b/lib/utils/global_key_utils.dart @@ -1,6 +1,16 @@ /// Builds a globalKey string from [serverId] and [ratingKey]. String buildGlobalKey(String serverId, String ratingKey) => '$serverId:$ratingKey'; +/// Separator used by profile-owned rows whose public media identity is still +/// [buildGlobalKey]. Profile ids are generated by the app and do not contain +/// this character. +const String profileScopedGlobalKeySeparator = '|'; + +/// Builds a profile-owned sync-rule key from [profileId] and public media id. +String buildProfileScopedGlobalKey(String profileId, String serverId, String ratingKey) { + return '$profileId$profileScopedGlobalKeySeparator${buildGlobalKey(serverId, ratingKey)}'; +} + /// Parses a globalKey string (format: "serverId:ratingKey") into its components. /// /// Returns `null` if the key does not contain a colon separator. @@ -10,3 +20,12 @@ String buildGlobalKey(String serverId, String ratingKey) => '$serverId:$ratingKe if (idx < 0) return null; return (serverId: globalKey.substring(0, idx), ratingKey: globalKey.substring(idx + 1)); } + +/// Parses a profile-owned sync-rule key, returning `null` for legacy public keys. +({String profileId, String serverId, String ratingKey})? parseProfileScopedGlobalKey(String globalKey) { + final idx = globalKey.indexOf(profileScopedGlobalKeySeparator); + if (idx < 0) return null; + final publicKey = parseGlobalKey(globalKey.substring(idx + 1)); + if (publicKey == null) return null; + return (profileId: globalKey.substring(0, idx), serverId: publicKey.serverId, ratingKey: publicKey.ratingKey); +} diff --git a/lib/utils/hierarchical_event_mixin.dart b/lib/utils/hierarchical_event_mixin.dart index 80f1f357..b367ae3a 100644 --- a/lib/utils/hierarchical_event_mixin.dart +++ b/lib/utils/hierarchical_event_mixin.dart @@ -6,30 +6,30 @@ import 'global_key_utils.dart'; /// affect a specific item or any of its parents in the hierarchy. This mixin /// provides common matching logic for such events. mixin HierarchicalEventMixin { - /// The ratingKey of the affected item. - String get ratingKey; + /// The id of the affected item (Plex ratingKey, Jellyfin GUID, …). + String get itemId; - /// Composite key: serverId:ratingKey. + /// Composite key: serverId:itemId. String get globalKey; /// Server this item belongs to. String get serverId; /// Parent chain for hierarchical matching. - /// For an episode: [seasonRatingKey, showRatingKey] - /// For a season: [showRatingKey] + /// For an episode: [seasonId, showId] + /// For a season: [showId] /// For a movie: [] List get parentChain; - /// Check if this event affects a specific item by ratingKey. - bool affectsItem(String ratingKey) => this.ratingKey == ratingKey || parentChain.contains(ratingKey); + /// Check if this event affects a specific item by id. + bool affectsItem(String itemId) => this.itemId == itemId || parentChain.contains(itemId); /// Check if this event affects a specific globalKey. bool affectsGlobalKey(String globalKey) => this.globalKey == globalKey || parentChain.any((pk) => buildGlobalKey(serverId, pk) == globalKey); /// Check if this event affects any item in a collection. - bool affectsAnyOf(Iterable ratingKeys) => ratingKeys.any(affectsItem); + bool affectsAnyOf(Iterable itemIds) => itemIds.any(affectsItem); /// Check if this event affects any item in a global-key collection. bool affectsAnyGlobalKey(Iterable globalKeys) => globalKeys.any(affectsGlobalKey); diff --git a/lib/utils/initials_palette.dart b/lib/utils/initials_palette.dart new file mode 100644 index 00000000..6f526eb4 --- /dev/null +++ b/lib/utils/initials_palette.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +/// First grapheme of [name] uppercased, or `?` when [name] is empty. +String initialOf(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return '?'; + final first = trimmed.runes.first; + return String.fromCharCode(first).toUpperCase(); +} + +/// Deterministic colour for [name] from a curated palette. The palette is +/// dark enough that white-on-colour text always meets contrast — callers +/// can use plain `Colors.white` for the text without per-colour checks. +Color colorForName(String name, ThemeData theme) { + if (name.isEmpty) return theme.colorScheme.primary; + var hash = 0; + for (final code in name.codeUnits) { + hash = (hash * 31 + code) & 0x7fffffff; + } + return _palette[hash % _palette.length]; +} + +const _palette = [ + Color(0xFF1565C0), // blue + Color(0xFF2E7D32), // green + Color(0xFFAD1457), // pink + Color(0xFF6A1B9A), // purple + Color(0xFF00838F), // teal + Color(0xFFE65100), // orange + Color(0xFF4527A0), // deep purple + Color(0xFFC62828), // red +]; diff --git a/lib/utils/jellyfin_time.dart b/lib/utils/jellyfin_time.dart new file mode 100644 index 00000000..799c04ec --- /dev/null +++ b/lib/utils/jellyfin_time.dart @@ -0,0 +1,37 @@ +/// Time-unit conversions for Jellyfin's wire format. +/// +/// Jellyfin reports durations and offsets in "ticks" (100-nanosecond units, a +/// .NET `DateTime.Ticks` legacy) and timestamps as ISO-8601 strings. The app +/// otherwise speaks milliseconds + Unix epoch seconds, so every Jellyfin +/// boundary needs a conversion. Centralised here so the shape is consistent +/// across mappers, the client, and the playback bundle. +library; + +const int _ticksPerMs = 10000; + +/// Jellyfin ticks → milliseconds. Returns `null` for non-numeric input. +int? jellyfinTicksToMs(Object? ticks) { + if (ticks is num) return ticks ~/ _ticksPerMs; + return null; +} + +/// Milliseconds → Jellyfin ticks. Used when reporting playback position back +/// to the server (`PositionTicks`). +int msToJellyfinTicks(int ms) => ms * _ticksPerMs; + +/// ISO-8601 date string → Unix epoch seconds. Returns `null` for empty, +/// missing, or unparseable input. +int? jellyfinIsoToEpochSeconds(String? iso) { + if (iso == null || iso.isEmpty) return null; + final dt = DateTime.tryParse(iso); + if (dt == null) return null; + return dt.millisecondsSinceEpoch ~/ 1000; +} + +/// Truncate a Jellyfin ISO-8601 datetime to `YYYY-MM-DD` so it lines up with +/// Plex's `originallyAvailableAt` shape. Returns `null` for empty input. +String? jellyfinIsoToYmd(String? iso) { + if (iso == null || iso.isEmpty) return null; + final i = iso.indexOf('T'); + return i > 0 ? iso.substring(0, i) : iso; +} diff --git a/lib/utils/json_utils.dart b/lib/utils/json_utils.dart index ae9f9c27..316494c4 100644 --- a/lib/utils/json_utils.dart +++ b/lib/utils/json_utils.dart @@ -39,3 +39,21 @@ List? flexibleList(Object? v) => switch (v) { final List l => l, _ => [v], }; + +List? stringListFromRaw(Object? raw, {String? mapKey, bool stringify = false, bool nullIfEmpty = false}) { + if (raw is! List) return null; + final result = []; + for (final value in raw) { + final source = mapKey != null && value is Map ? value[mapKey] : value; + final string = stringify + ? source?.toString() + : source is String + ? source + : null; + if (string != null) result.add(string); + } + if (result.isEmpty && nullIfEmpty) return null; + return result; +} + +List? nullIfEmptyList(List values) => values.isEmpty ? null : values; diff --git a/lib/utils/live_tv_player_navigation.dart b/lib/utils/live_tv_player_navigation.dart index edade64e..65620b91 100644 --- a/lib/utils/live_tv_player_navigation.dart +++ b/lib/utils/live_tv_player_navigation.dart @@ -2,32 +2,61 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; import '../models/livetv_channel.dart'; -import '../models/plex_metadata.dart'; +import '../providers/multi_server_provider.dart'; import '../screens/video_player_screen.dart'; import '../services/plex_client.dart'; import '../utils/app_logger.dart'; +import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; /// Navigate to the video player for a live TV channel. /// -/// Pushes the player screen immediately with a placeholder metadata. -/// The actual tuning (tune POST + decision GET) happens inside the player, -/// which shows a loading spinner while it works. +/// Plex flow: pass [liveClient] + [dvrKey] and the player will run the +/// `/livetv/.../tune` POST + transcode decision inside its loading spinner. +/// +/// Jellyfin flow: pass a pre-resolved [liveStreamUrl] (e.g. from +/// [JellyfinClient.buildDirectStreamUrl]) plus [liveClient], and leave +/// [dvrKey] null. +/// The player skips Plex's tune step and points the engine at the URL +/// directly. +/// +/// [backend] is the actual backend serving the channel — the placeholder +/// `MediaItem` carries this through so any in-player `metadata.backend` +/// branch (transcoder hints, watch-state surfaces) sees the right kind. /// /// [channels] is the full channel list for channel up/down navigation. Future navigateToLiveTv( BuildContext context, { - required PlexClient client, - required String dvrKey, + MediaServerClient? liveClient, + String? dvrKey, + String? liveStreamUrl, + String? liveSessionIdentifier, + required MediaBackend backend, required LiveTvChannel channel, List? channels, }) async { + assert( + liveStreamUrl != null || (liveClient is PlexClient && dvrKey != null), + 'navigateToLiveTv needs either a pre-resolved stream URL or a Plex client + dvrKey to tune', + ); final navigator = Navigator.of(context); appLogger.d('Navigating to live channel: ${channel.displayName} (${channel.key})'); - final placeholder = PlexMetadata(ratingKey: channel.key, key: channel.key, type: 'clip', title: channel.displayName); + final placeholder = MediaItem( + id: channel.key, + backend: backend, + kind: MediaKind.clip, + title: channel.displayName, + serverId: channel.serverId, + serverName: channel.serverName, + raw: {'key': channel.key}, + ); final route = PageRouteBuilder( settings: const RouteSettings(name: kVideoPlayerRouteName), @@ -35,11 +64,14 @@ Future navigateToLiveTv( metadata: placeholder, isLive: true, liveChannelName: channel.displayName, - liveStreamUrl: null, + liveStreamUrl: liveStreamUrl, liveChannels: channels, - liveCurrentChannelIndex: channels?.indexWhere((ch) => ch.key == channel.key), + liveCurrentChannelIndex: channels?.indexWhere( + (ch) => liveTvChannelScopeKey(ch) == liveTvChannelScopeKey(channel), + ), liveDvrKey: dvrKey, - liveClient: client, + liveClient: liveClient, + liveSessionIdentifier: liveSessionIdentifier, ), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, @@ -47,3 +79,65 @@ Future navigateToLiveTv( unawaited(navigator.push(route)); } + +Future tuneAndNavigateToLiveTv( + BuildContext context, { + required MultiServerProvider multiServer, + required LiveTvChannel channel, + required List channels, +}) async { + final serverInfo = liveTvServerInfoForChannel(multiServer, channel); + if (serverInfo == null) { + showErrorSnackBar(context, 'Live TV server is not available.'); + return; + } + + final genericClient = multiServer.getClientForServer(serverInfo.serverId); + if (genericClient == null) { + showErrorSnackBar(context, 'Live TV server is not connected.'); + return; + } + final resolution = await genericClient.liveTv.resolveStreamUrl(channel.key, dvrKey: serverInfo.dvrKey); + if (!context.mounted) return; + if (resolution != null) { + await navigateToLiveTv( + context, + liveClient: genericClient, + liveStreamUrl: resolution.url, + liveSessionIdentifier: resolution.playSessionId, + backend: genericClient.backend, + channel: channel, + channels: channels, + ); + return; + } + + final plexClient = multiServer.getPlexClientForServer(serverInfo.serverId); + if (plexClient == null) { + appLogger.w('Failed to resolve live stream URL for ${channel.displayName} on ${genericClient.backend.id}'); + showErrorSnackBar(context, 'Unable to start this live TV channel.'); + return; + } + await navigateToLiveTv( + context, + liveClient: plexClient, + dvrKey: serverInfo.dvrKey, + backend: plexClient.backend, + channel: channel, + channels: channels, + ); +} + +LiveTvServerInfo? liveTvServerInfoForChannel(MultiServerProvider multiServer, LiveTvChannel channel) { + final serverId = channel.serverId; + final dvrKey = channel.liveDvrKey; + if (serverId != null && dvrKey != null) { + final exact = multiServer.liveTvServers.where((s) => s.serverId == serverId && s.dvrKey == dvrKey).firstOrNull; + if (exact != null) return exact; + } + if (serverId != null) { + final serverMatch = multiServer.liveTvServers.where((s) => s.serverId == serverId).firstOrNull; + if (serverMatch != null) return serverMatch; + } + return multiServer.liveTvServers.firstOrNull; +} diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart index 91c997b8..791ac564 100644 --- a/lib/utils/log_redaction_manager.dart +++ b/lib/utils/log_redaction_manager.dart @@ -1,3 +1,5 @@ +import 'url_utils.dart'; + class LogRedactionManager { // Size limits for bounded sets (FIFO eviction when exceeded) static const int _maxTokens = 50; @@ -15,6 +17,20 @@ class LogRedactionManager { /// Pattern-based catch-all for Plex tokens in query strings/headers. static final RegExp _plexTokenQueryParam = RegExp(r'X-Plex-Token=[^&#\s]+', caseSensitive: false); + /// Pattern-based catch-all for Jellyfin tokens carried as `api_key=` query + /// params (URL-embedded auth path used for thumbnails and direct streams). + static final RegExp _jellyfinApiKeyQueryParam = RegExp(r'api_key=[^&#\s]+', caseSensitive: false); + + /// Pattern-based catch-all for Jellyfin Quick Connect auth handles. + static final RegExp _jellyfinQuickConnectSecretQueryParam = RegExp(r'secret=[^&#\s]+', caseSensitive: false); + + /// Pattern-based catch-all for the legacy Emby/Jellyfin header form. + static final RegExp _embyTokenHeader = RegExp(r'X-Emby-Token[:=]\s*[^,;&#\s"]+', caseSensitive: false); + + /// Pattern-based catch-all for the `Authorization: MediaBrowser ... Token="..."` + /// header that Jellyfin's SDK and Findroid both send. + static final RegExp _mediaBrowserTokenHeader = RegExp(r'Token="[^"]+"', caseSensitive: false); + // Combined regex for single-pass redaction (rebuilt on set changes) static RegExp? _combinedPattern; @@ -50,7 +66,7 @@ class LogRedactionManager { return; } - final strippedSlash = normalized.endsWith('/') ? normalized.substring(0, normalized.length - 1) : normalized; + final strippedSlash = stripTrailingSlash(normalized); if (strippedSlash.isNotEmpty) { _addWithLimit(_urls, strippedSlash, _maxUrls); @@ -96,12 +112,22 @@ class LogRedactionManager { // Pass 2: Strip X-Plex-Token query parameters (pattern-based, no pre-registration needed) redacted = redacted.replaceAll(_plexTokenQueryParam, 'X-Plex-Token=[REDACTED]'); + // Pass 2b: Strip Jellyfin api_key/Quick Connect query parameters and Emby/MediaBrowser headers. + redacted = redacted.replaceAll(_jellyfinApiKeyQueryParam, 'api_key=[REDACTED]'); + redacted = redacted.replaceAll(_jellyfinQuickConnectSecretQueryParam, 'secret=[REDACTED]'); + redacted = redacted.replaceAllMapped(_embyTokenHeader, (m) { + final value = m.group(0)!; + final separator = value.contains(':') ? ':' : '='; + return 'X-Emby-Token$separator [REDACTED]'; + }); + redacted = redacted.replaceAll(_mediaBrowserTokenHeader, 'Token="[REDACTED]"'); + // Pass 3: All tracked values in single pass if (_combinedPattern != null) { redacted = redacted.replaceAllMapped(_combinedPattern!, (match) { final value = match.group(0)!; if (_tokens.contains(value)) return '[REDACTED_TOKEN]'; - if (_urls.contains(value)) return _maskUrlPreview(value); + if (_urls.contains(value)) return '[REDACTED_URL]'; return '[REDACTED]'; }); } @@ -155,29 +181,4 @@ class LogRedactionManager { 'x$separator' '$last'; } - - static String _maskUrlPreview(String url) { - const startPreviewLength = 12; - const endPreviewLength = 8; - - if (url.isEmpty) { - return '[REDACTED_URL]'; - } - - if (url.length <= 4) { - return '[REDACTED_URL]'; - } - - final startLength = url.length <= startPreviewLength ? (url.length / 2).ceil() : startPreviewLength; - final remainingForEnd = url.length - startLength; - final endLength = remainingForEnd <= endPreviewLength ? remainingForEnd : endPreviewLength; - - final start = url.substring(0, startLength); - if (endLength <= 0) { - return '$start...[REDACTED_URL]'; - } - - final end = url.substring(url.length - endLength); - return '$start...[REDACTED_URL]...$end'; - } } diff --git a/lib/utils/plex_image_helper.dart b/lib/utils/media_image_helper.dart similarity index 69% rename from lib/utils/plex_image_helper.dart rename to lib/utils/media_image_helper.dart index 465e2a3e..37937cbf 100644 --- a/lib/utils/plex_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -1,8 +1,7 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; -import '../services/plex_client.dart'; +import '../media/media_server_client.dart'; import 'platform_detector.dart'; -import 'plex_url_helper.dart'; /// Image types for different transcoding strategies enum ImageType { @@ -13,7 +12,23 @@ enum ImageType { avatar, // Square-ish user avatars } -class PlexImageHelper { +/// Backend-neutral image URL helper. +/// +/// Builds optimally-sized image URLs that go through the right server-side +/// transcode path: +/// - **Plex**: `/photo/:/transcode?width=W&height=H&url=...&X-Plex-Token=...` +/// constructed by [MediaServerClient.thumbnailUrl] (PlexClient impl). +/// - **Jellyfin**: `/Items/{id}/Images/{type}?MaxWidth=W&MaxHeight=H&api_key=...` +/// constructed by [MediaServerClient.thumbnailUrl] (JellyfinClient impl). +/// +/// Self-contained absolute URLs (Jellyfin items pre-absolutized at the +/// model layer) get sized via query-param append so they pick up the same +/// DPR scaling and cache-bucket rounding as Plex. +/// +/// External URLs (EPG provider images, etc.) that the local server doesn't +/// host get proxied through Plex's photo transcoder when a Plex client is +/// available; otherwise they pass through unchanged. +class MediaImageHelper { static const int _widthRoundingFactor = 40; static const int _heightRoundingFactor = 60; @@ -106,39 +121,13 @@ class PlexImageHelper { } } - /// Builds a Plex photo transcode URL with optimized parameters - static String buildTranscodeUrl({ - required PlexClient client, - required String originalPath, - required int width, - int? height, - }) { - final baseUrl = client.config.baseUrl; - final token = client.config.token; - - // URL encode the original path with token - final encodedPath = Uri.encodeComponent(originalPath.withPlexToken(token)); - - // Build the transcode URL - final transcodeParams = { - 'width': width.toString(), - if (height != null) 'height': height.toString(), - 'minSize': '1', // Ensure minimum size is maintained - 'upscale': '1', // Allow upscaling for better quality - 'url': encodedPath, - 'X-Plex-Token': token, - }; - - final queryString = transcodeParams.entries.map((e) => '${e.key}=${e.value}').join('&'); - - return '$baseUrl/photo/:/transcode?$queryString'; - } - - /// Creates an optimized image URL for Plex content - /// Falls back to original URL if transcoding is not appropriate - /// If client is null (offline mode), returns empty string for relative paths + /// Creates an optimized image URL. + /// + /// Falls back to the raw [thumbPath] when the path is empty, when no + /// client is available (offline mode), or when transcoding is suppressed + /// for this path. static String getOptimizedImageUrl({ - PlexClient? client, + MediaServerClient? client, required String? thumbPath, required double maxWidth, required double maxHeight, @@ -146,47 +135,59 @@ class PlexImageHelper { bool enableTranscoding = true, ImageType imageType = ImageType.poster, }) { - if (thumbPath == null || thumbPath.isEmpty) { - return ''; - } - + if (thumbPath == null || thumbPath.isEmpty) return ''; final basePath = thumbPath; - // External URLs (e.g. EPG provider images) — proxy through the server's - // photo transcoder so the Plex server fetches them on our behalf. + // External absolute URLs (EPG provider images, Jellyfin self-absolutized + // image URLs). if (basePath.startsWith('http://') || basePath.startsWith('https://')) { - if (client == null) return basePath; + // Self-contained Jellyfin URLs already carry their own auth + // (`api_key=...`). Append `MaxWidth/MaxHeight` so we still get DPR + // scaling and cache-bucket rounding — Jellyfin's image endpoint + // honours those query params. + if (basePath.contains('api_key=')) { + if (!enableTranscoding) return basePath; + // If size params are already attached, leave them. + if (basePath.contains('MaxWidth=') || basePath.contains('maxWidth=') || basePath.contains('Width=')) { + return basePath; + } + final (width, height) = calculateOptimalDimensions( + maxWidth: maxWidth, + maxHeight: maxHeight, + devicePixelRatio: devicePixelRatio, + imageType: imageType, + ); + final separator = basePath.contains('?') ? '&' : '?'; + return '$basePath${separator}MaxWidth=$width&MaxHeight=$height'; + } + + // EPG / external URL — proxy through the server's transcoder. Plex + // implements [externalImageUrl] via `/photo/:/transcode?url=...`; + // backends without a comparable endpoint return the URL unchanged. + if (client == null || !enableTranscoding) return basePath; final (width, height) = calculateOptimalDimensions( maxWidth: maxWidth, maxHeight: maxHeight, devicePixelRatio: devicePixelRatio, imageType: imageType, ); - // Don't append Plex token to the inner URL — only on the outer request - final encodedUrl = Uri.encodeComponent(basePath); - final token = client.config.token; - return '${client.config.baseUrl}/photo/:/transcode?width=$width&height=$height&minSize=1&upscale=1&url=$encodedUrl&X-Plex-Token=$token'; + return client.externalImageUrl(basePath, width: width, height: height); } - // If no client (offline mode), we can't build URLs for relative paths - // Images should already be cached from when they were originally loaded + // Relative path — let the client build the sized URL using its native + // size-hint params (`/photo/:/transcode` for Plex, `MaxWidth/MaxHeight` + // for Jellyfin). The interface guarantees both honour width/height. if (client == null) { + // Offline mode and a relative path — the cached entry should already + // exist under whatever URL was originally fetched. Returning empty + // matches the pre-refactor behaviour. return ''; } - final canTranscode = enableTranscoding && shouldTranscode(basePath); - - // If marked non-transcodable or transcoding disabled, use the direct thumbnail URL. - if (!canTranscode) { - return client.getThumbnailUrl(basePath); + if (!enableTranscoding || !shouldTranscode(basePath)) { + return client.thumbnailUrl(basePath); } - // For very small images use original URL - if (maxWidth < 80 || maxHeight < 120) { - return client.getThumbnailUrl(basePath); - } - - // Calculate optimal dimensions final (width, height) = calculateOptimalDimensions( maxWidth: maxWidth, maxHeight: maxHeight, @@ -194,17 +195,16 @@ class PlexImageHelper { imageType: imageType, ); - // For dimensions close to minimum, use original to avoid unnecessary processing + // For very small targets, skip server-side resizing — the cost of the + // transcode round-trip outweighs the savings. + if (maxWidth < 80 || maxHeight < 120) { + return client.thumbnailUrl(basePath); + } if (width <= _minTranscodedWidth * 1.2 && height <= _minTranscodedHeight * 1.2) { - return client.getThumbnailUrl(basePath); + return client.thumbnailUrl(basePath); } - try { - return buildTranscodeUrl(client: client, originalPath: basePath, width: width, height: height); - } catch (e) { - // Fallback to original URL on any error - return client.getThumbnailUrl(basePath); - } + return client.thumbnailUrl(basePath, width: width, height: height); } /// Generates cache-friendly dimensions for memory caching. @@ -252,7 +252,7 @@ class PlexImageHelper { /// Optimized URL for hero/background art ([ImageType.art]). static String heroArtUrl({ - required PlexClient? client, + required MediaServerClient? client, required String? thumbPath, required BuildContext context, required double containerWidth, @@ -261,7 +261,7 @@ class PlexImageHelper { /// Optimized URL for clear-logo overlays ([ImageType.logo]). static String logoUrl({ - required PlexClient? client, + required MediaServerClient? client, required String? thumbPath, required BuildContext context, required double containerWidth, @@ -269,7 +269,7 @@ class PlexImageHelper { }) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.logo); static String _typedUrl( - PlexClient? client, + MediaServerClient? client, String? thumbPath, BuildContext context, double containerWidth, diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index fa4e236e..398e8342 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_playlist.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_playlist.dart'; import '../screens/collection_detail_screen.dart'; import '../screens/main_screen.dart'; import '../screens/media_detail_screen.dart'; import '../screens/playlist/playlist_detail_screen.dart'; import '../utils/global_key_utils.dart'; +import 'plex_library_section_helpers.dart'; import 'video_player_navigation.dart'; /// Result of media navigation indicating what action was taken @@ -25,6 +27,9 @@ enum MediaNavigationResult { /// Navigates to the appropriate screen based on the item type. /// +/// Accepts a [MediaItem] or a [MediaPlaylist] (typed as [Object] because Dart +/// has no nominal union type). +/// /// For episodes, starts playback directly via video player. /// For movies, starts playback directly if [playDirectly] is true, otherwise /// navigates to media detail screen. @@ -34,8 +39,8 @@ enum MediaNavigationResult { /// For other types (shows), navigates to media detail screen. /// For music types (artist, album, track), returns [MediaNavigationResult.unsupported]. /// -/// The [onRefresh] callback is invoked with the item's ratingKey after -/// returning from the detail screen, allowing the caller to refresh state. +/// The [onRefresh] callback is invoked with the item's id after returning from +/// the detail screen, allowing the caller to refresh state. /// /// Set [isOffline] to true for downloaded content without server access. /// @@ -47,35 +52,38 @@ enum MediaNavigationResult { /// - [MediaNavigationResult.unsupported]: Item type not supported, caller should handle Future navigateToMediaItem( BuildContext context, - dynamic item, { + Object item, { void Function(String)? onRefresh, bool isOffline = false, bool playDirectly = false, }) async { - // Handle playlists - if (item is PlexPlaylist) { + if (item is MediaPlaylist) { await Navigator.push(context, MaterialPageRoute(builder: (context) => PlaylistDetailScreen(playlist: item))); return MediaNavigationResult.navigated; } - final metadata = item as PlexMetadata; + if (item is! MediaItem) { + return MediaNavigationResult.unsupported; + } + final mi = item; - // Handle library section items (shared whole-library entries) - if (metadata.isLibrarySection) { - final sectionKey = metadata.librarySectionKey; - if (sectionKey != null && metadata.serverId != null) { - final libraryGlobalKey = buildGlobalKey(metadata.serverId!, sectionKey); + // Handle library section items (shared whole-library entries) — Plex-only; + // [PlexLibrarySection.isLibrarySection] reads the stashed `key` from `raw`. + if (mi.isLibrarySection) { + final sectionKey = mi.librarySectionKey; + if (sectionKey != null && mi.serverId != null) { + final libraryGlobalKey = buildGlobalKey(mi.serverId!, sectionKey); MainScreenFocusScope.of(context)?.selectLibrary?.call(libraryGlobalKey); return MediaNavigationResult.librarySelected; } return MediaNavigationResult.unsupported; } - switch (metadata.mediaType) { - case PlexMediaType.collection: + switch (mi.kind) { + case MediaKind.collection: final result = await Navigator.push( context, - MaterialPageRoute(builder: (context) => CollectionDetailScreen(collection: metadata)), + MaterialPageRoute(builder: (context) => CollectionDetailScreen(collection: mi)), ); // If collection was deleted, signal that list refresh is needed if (result == true) { @@ -83,72 +91,78 @@ Future navigateToMediaItem( } return MediaNavigationResult.navigated; - case PlexMediaType.artist: - case PlexMediaType.album: - case PlexMediaType.track: + case MediaKind.artist: + case MediaKind.album: + case MediaKind.track: // Music types not supported return MediaNavigationResult.unsupported; - case PlexMediaType.clip: - case PlexMediaType.episode: + case MediaKind.clip: + case MediaKind.episode: // For episodes and clips (trailers/extras), start playback directly - final result = await navigateToVideoPlayer(context, metadata: metadata, isOffline: isOffline); + final result = await navigateToVideoPlayer(context, metadata: mi, isOffline: isOffline); if (result == true) { - onRefresh?.call(metadata.ratingKey); + onRefresh?.call(mi.id); } return MediaNavigationResult.navigated; - case PlexMediaType.movie: + case MediaKind.movie: if (playDirectly) { // For movies in continue watching, start playback directly - final result = await navigateToVideoPlayer(context, metadata: metadata, isOffline: isOffline); + final result = await navigateToVideoPlayer(context, metadata: mi, isOffline: isOffline); if (result == true) { - onRefresh?.call(metadata.ratingKey); + onRefresh?.call(mi.id); } return MediaNavigationResult.navigated; } - // Fall through to default case for detail screen - continue defaultCase; + return _showDetail(context, mi, isOffline, onRefresh); - case PlexMediaType.season: + case MediaKind.season: // Navigate to the parent show with the season tab pre-selected - if (metadata.parentRatingKey != null) { - final showStub = PlexMetadata( - ratingKey: metadata.parentRatingKey!, - key: '/library/metadata/${metadata.parentRatingKey}', - type: 'show', - title: metadata.grandparentTitle ?? metadata.parentTitle ?? metadata.displayTitle, - thumb: metadata.grandparentThumb ?? metadata.parentThumb, - art: metadata.grandparentArt, - serverId: metadata.serverId, - serverName: metadata.serverName, + if (mi.parentId != null) { + final showStub = MediaItem( + id: mi.parentId!, + backend: mi.backend, + kind: MediaKind.show, + title: mi.grandparentTitle ?? mi.parentTitle ?? mi.displayTitle, + thumbPath: mi.grandparentThumbPath ?? mi.parentThumbPath, + artPath: mi.grandparentArtPath, + serverId: mi.serverId, + serverName: mi.serverName, ); final result = await Navigator.push( context, MaterialPageRoute( builder: (context) => - MediaDetailScreen(metadata: showStub, isOffline: isOffline, initialSeasonIndex: metadata.index), + MediaDetailScreen(metadata: showStub, isOffline: isOffline, initialSeasonIndex: mi.index), ), ); if (result == true) { - onRefresh?.call(metadata.ratingKey); + onRefresh?.call(mi.id); } return MediaNavigationResult.navigated; } - continue defaultCase; + return _showDetail(context, mi, isOffline, onRefresh); - defaultCase: default: - // For all other types (shows, movies), show detail screen - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MediaDetailScreen(metadata: metadata, isOffline: isOffline), - ), - ); - if (result == true) { - onRefresh?.call(metadata.ratingKey); - } - return MediaNavigationResult.navigated; + return _showDetail(context, mi, isOffline, onRefresh); } } + +Future _showDetail( + BuildContext context, + MediaItem mi, + bool isOffline, + void Function(String)? onRefresh, +) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen(metadata: mi, isOffline: isOffline), + ), + ); + if (result == true) { + onRefresh?.call(mi.id); + } + return MediaNavigationResult.navigated; +} diff --git a/lib/utils/plex_http_client.dart b/lib/utils/media_server_http_client.dart similarity index 80% rename from lib/utils/plex_http_client.dart rename to lib/utils/media_server_http_client.dart index 8db27144..8ffa264f 100644 --- a/lib/utils/plex_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -9,13 +9,13 @@ import 'app_logger.dart'; import 'future_extensions.dart'; import 'isolate_helper.dart'; import 'log_redaction_manager.dart'; -import 'plex_http_exception.dart'; +import '../exceptions/media_server_exceptions.dart'; // Platform-specific imports are conditional import 'platform_http_client_stub.dart' if (dart.library.io) 'platform_http_client_io.dart' as platform; -/// Response from [PlexHttpClient] requests. -class PlexResponse { +/// Response from [MediaServerHttpClient] requests. +class MediaServerResponse { final int statusCode; /// Parsed JSON body (`Map` or `List`), or raw `String` @@ -23,18 +23,20 @@ class PlexResponse { final dynamic data; final Map headers; + final Uri? requestUri; - PlexResponse({required this.statusCode, this.data, required this.headers}); + MediaServerResponse({required this.statusCode, this.data, required this.headers, this.requestUri}); } -/// Throw [PlexHttpException] for non-2xx responses so callers don't blindly +/// Throw [MediaServerHttpException] for non-2xx responses so callers don't blindly /// cast HTML/text error bodies to `Map`. -void throwIfHttpError(PlexResponse r) { +void throwIfHttpError(MediaServerResponse r) { if (r.statusCode >= 400) { - throw PlexHttpException( - type: PlexHttpErrorType.unknown, + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, statusCode: r.statusCode, responseData: r.data, + requestUri: r.requestUri, message: 'HTTP ${r.statusCode}', ); } @@ -60,10 +62,10 @@ class AbortController { /// HTTP client wrapper providing base URL, default headers, JSON parsing, /// timeouts, logging, and optional endpoint failover. -class PlexHttpClient { +class MediaServerHttpClient { final http.Client _client; - PlexHttpClient({ + MediaServerHttpClient({ http.Client? client, this.baseUrl = '', Map defaultHeaders = const {}, @@ -84,7 +86,7 @@ class PlexHttpClient { // Public request methods // --------------------------------------------------------------------------- - Future get( + Future get( String path, { Map? queryParameters, Map? headers, @@ -92,7 +94,7 @@ class PlexHttpClient { AbortController? abort, }) => _send('GET', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort); - Future post( + Future post( String path, { Map? queryParameters, Map? headers, @@ -109,7 +111,7 @@ class PlexHttpClient { abort: abort, ); - Future put( + Future put( String path, { Map? queryParameters, Map? headers, @@ -126,7 +128,7 @@ class PlexHttpClient { abort: abort, ); - Future delete( + Future delete( String path, { Map? queryParameters, Map? headers, @@ -156,7 +158,7 @@ class PlexHttpClient { return bytes; } catch (e) { sw.stop(); - throw PlexHttpException.from(e, uri: uri); + throw MediaServerHttpException.from(e, uri: uri); } } @@ -179,7 +181,7 @@ class PlexHttpClient { await sink.close(); } } catch (e) { - throw PlexHttpException.from(e, uri: uri); + throw MediaServerHttpException.from(e, uri: uri); } } @@ -192,7 +194,7 @@ class PlexHttpClient { // Core send implementation // --------------------------------------------------------------------------- - Future _send( + Future _send( String method, String path, { Map? queryParameters, @@ -233,11 +235,28 @@ class PlexHttpClient { sw.stop(); _logResponse(method, uri, streamed.statusCode, sw.elapsedMilliseconds); - final data = await _decodeBody(bytes, streamed.headers); - return PlexResponse(statusCode: streamed.statusCode, data: data, headers: streamed.headers); + dynamic data; + try { + data = await _decodeBody(bytes, streamed.headers); + } catch (e) { + final body = await _decodeTextBody(bytes); + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: streamed.statusCode, + responseData: body, + requestUri: uri, + message: 'Failed to decode response body: $e', + ); + } + return MediaServerResponse( + statusCode: streamed.statusCode, + data: data, + headers: streamed.headers, + requestUri: uri, + ); } catch (e) { sw.stop(); - throw PlexHttpException.from(e, uri: uri); + throw MediaServerHttpException.from(e, uri: uri); } } @@ -308,9 +327,14 @@ class PlexHttpClient { // Map or List → JSON encode request.body = jsonEncode(body); - // Only set content-type if the caller hasn't already - if (!request.headers.containsKey('content-type')) { - request.headers['content-type'] = 'application/json; charset=utf-8'; + // Only set content-type if the caller hasn't already. http.BaseRequest's + // headers map is case-sensitive, so we must check both common casings — + // Jellyfin returns 415 if a `Content-Type: application/json` from the + // default headers ends up coexisting with a lowercase `content-type: + // application/json; charset=utf-8` we'd append below. + final hasContentType = request.headers.keys.any((k) => k.toLowerCase() == 'content-type'); + if (!hasContentType) { + request.headers['content-type'] = 'application/json'; } } @@ -323,7 +347,7 @@ class PlexHttpClient { Future _decodeBody(List bytes, Map headers) async { if (bytes.isEmpty) return null; - final contentType = headers['content-type'] ?? ''; + final contentType = (_headerValue(headers, 'content-type') ?? '').toLowerCase(); final isJson = contentType.contains('json'); // For large JSON payloads, do both UTF-8 decode and JSON parse in a @@ -332,13 +356,25 @@ class PlexHttpClient { return await tryIsolateRun(() => jsonDecode(utf8.decode(bytes, allowMalformed: true))); } - final body = bytes.length > 50 * 1024 - ? await tryIsolateRun(() => utf8.decode(bytes, allowMalformed: true)) - : utf8.decode(bytes, allowMalformed: true); + final body = await _decodeTextBody(bytes); return isJson ? jsonDecode(body) : body; } + Future _decodeTextBody(List bytes) async { + return bytes.length > 50 * 1024 + ? await tryIsolateRun(() => utf8.decode(bytes, allowMalformed: true)) + : utf8.decode(bytes, allowMalformed: true); + } + + static String? _headerValue(Map headers, String name) { + final lowerName = name.toLowerCase(); + for (final entry in headers.entries) { + if (entry.key.toLowerCase() == lowerName) return entry.value; + } + return null; + } + // --------------------------------------------------------------------------- // Logging // --------------------------------------------------------------------------- @@ -348,6 +384,6 @@ class PlexHttpClient { } } -/// Shared [PlexHttpClient] instance for ad-hoc requests (update checks, +/// Shared [MediaServerHttpClient] instance for ad-hoc requests (update checks, /// log uploads, image fetches, etc). No base URL or default Plex headers. -final httpClient = PlexHttpClient(); +final httpClient = MediaServerHttpClient(); diff --git a/lib/utils/media_server_timeouts.dart b/lib/utils/media_server_timeouts.dart new file mode 100644 index 00000000..a8b2eaee --- /dev/null +++ b/lib/utils/media_server_timeouts.dart @@ -0,0 +1,45 @@ +/// Centralized HTTP timeout constants for both backends. The same +/// [MediaServerHttpClient] wrapper is used by Plex and Jellyfin clients — +/// timeouts are kept here so the budgets per phase are visible at a +/// glance. +class MediaServerTimeouts { + // ── Per-server HTTP request budgets (apply to both backends) ─── + /// HTTP connect timeout for individual HTTP requests to a media server. + static const connect = Duration(seconds: 10); + + /// HTTP receive timeout for streaming/large responses from a media server. + static const receive = Duration(seconds: 120); + + // ── Plex server discovery / endpoint racing ──────────────────── + /// Timeout for probing a cached/preferred endpoint before falling back to + /// the full candidate race (used in [PlexServer.findBestWorkingConnection]). + static const preferredEndpointProbe = Duration(milliseconds: 1500); + + /// Timeout for the connection race where all candidates are tested in + /// parallel (used in [PlexServer.findBestWorkingConnection]). + static const connectionRace = Duration(seconds: 2); + + /// Per-server connection budget: preferred probe + race + HTTPS upgrade + /// attempt + 1s buffer. + static const perServerConnect = Duration(milliseconds: 1500 + 2000 + 2000 + 1000); + + /// HTTP timeout for the live-TV tune POST. Matches Plex web's value — the + /// default 10s connect budget is too tight on Fire-TV cold starts. + static const tune = Duration(seconds: 30); + + // ── plex.tv (auth provider) ───────────────────────────────────── + /// HTTP connect timeout for plex.tv / clients.plex.tv API requests. + static const plexTvConnect = Duration(seconds: 5); + + /// HTTP receive timeout for plex.tv / clients.plex.tv API responses. + static const plexTvReceive = Duration(seconds: 10); + + // ── Jellyfin auth flow ────────────────────────────────────────── + /// Probe + token-validate timeout — Jellyfin servers respond fast on + /// `/System/Info/Public` and `/Users/Me`. + static const jellyfinProbe = Duration(seconds: 8); + + /// Best-effort `/Sessions/Logout` timeout — short because the call is + /// fire-and-forget; the token is removed locally regardless. + static const jellyfinSignOut = Duration(seconds: 5); +} diff --git a/lib/utils/obfuscation_utils.dart b/lib/utils/obfuscation_utils.dart new file mode 100644 index 00000000..33c77477 --- /dev/null +++ b/lib/utils/obfuscation_utils.dart @@ -0,0 +1,15 @@ +/// Set to `true` to blur all artwork (for store screenshots). +const kBlurArtwork = false; + +/// Rotates vowels (a→e, e→i, i→o, o→u, u→a) when [kBlurArtwork] is `true`. +String obfuscateText(String text) { + if (!kBlurArtwork) return text; + const from = 'aeiouAEIOU'; + const to = 'eiouaEIOUA'; + final buf = StringBuffer(); + for (var i = 0; i < text.length; i++) { + final idx = from.indexOf(text[i]); + buf.write(idx >= 0 ? to[idx] : text[i]); + } + return buf.toString(); +} diff --git a/lib/utils/platform_http_client_stub.dart b/lib/utils/platform_http_client_stub.dart index cbe3b139..8a10446f 100644 --- a/lib/utils/platform_http_client_stub.dart +++ b/lib/utils/platform_http_client_stub.dart @@ -1,5 +1,5 @@ import 'package:http/http.dart' as http; /// Fallback stub — should never be called; actual implementation is selected -/// via conditional imports in `plex_http_client.dart`. +/// via conditional imports in `media_server_http_client.dart`. http.Client createPlatformClient() => throw UnsupportedError('No platform HTTP client available'); diff --git a/lib/utils/plex_external_ids.dart b/lib/utils/plex_external_ids.dart deleted file mode 100644 index 3fa855ce..00000000 --- a/lib/utils/plex_external_ids.dart +++ /dev/null @@ -1,33 +0,0 @@ -/// External IDs parsed from a Plex `Guid` array (as returned by -/// `?includeGuids=1`). Shared by the Trakt and tracker resolvers. -/// -/// Plex GUIDs look like `imdb://tt123`, `tmdb://456`, `tvdb://789`. Callers -/// fetch the raw array via [PlexClient.fetchExternalGuids] and pass it here. -class PlexExternalIds { - final String? imdb; - final int? tmdb; - final int? tvdb; - - const PlexExternalIds({this.imdb, this.tmdb, this.tvdb}); - - bool get hasAny => imdb != null || tmdb != null || tvdb != null; - - factory PlexExternalIds.fromGuids(List guids) { - String? imdb; - int? tmdb; - int? tvdb; - for (final g in guids) { - if (g is! Map) continue; - final id = g['id']; - if (id is! String) continue; - if (id.startsWith('imdb://')) { - imdb = id.substring(7); - } else if (id.startsWith('tmdb://')) { - tmdb = int.tryParse(id.substring(7)); - } else if (id.startsWith('tvdb://')) { - tvdb = int.tryParse(id.substring(7)); - } - } - return PlexExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb); - } -} diff --git a/lib/utils/plex_http_exception.dart b/lib/utils/plex_http_exception.dart deleted file mode 100644 index 7252f943..00000000 --- a/lib/utils/plex_http_exception.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:http/http.dart'; - -enum PlexHttpErrorType { connectionTimeout, receiveTimeout, connectionError, cancelled, unknown } - -class PlexHttpException implements Exception { - final PlexHttpErrorType type; - final String? message; - final int? statusCode; - final dynamic responseData; - final Uri? requestUri; - - PlexHttpException({required this.type, this.message, this.statusCode, this.responseData, this.requestUri}); - - /// Map a caught exception to a [PlexHttpException]. - factory PlexHttpException.from(Object error, {Uri? uri}) { - if (error is PlexHttpException) return error; - - if (error is RequestAbortedException) { - return PlexHttpException(type: PlexHttpErrorType.cancelled, message: error.message, requestUri: error.uri ?? uri); - } - - if (error is TimeoutException) { - return PlexHttpException(type: PlexHttpErrorType.connectionTimeout, message: error.message, requestUri: uri); - } - - if (error is SocketException) { - return PlexHttpException(type: PlexHttpErrorType.connectionError, message: error.message, requestUri: uri); - } - - if (error is HttpException) { - return PlexHttpException(type: PlexHttpErrorType.connectionError, message: error.message, requestUri: uri); - } - - if (error is ClientException) { - return PlexHttpException( - type: PlexHttpErrorType.connectionError, - message: error.message, - requestUri: error.uri ?? uri, - ); - } - - return PlexHttpException(type: PlexHttpErrorType.unknown, message: error.toString(), requestUri: uri); - } - - /// Whether the error looks transient (network/timeout) and worth retrying. - bool get isTransient => - type == PlexHttpErrorType.connectionTimeout || - type == PlexHttpErrorType.connectionError || - type == PlexHttpErrorType.receiveTimeout; - - @override - String toString() => 'PlexHttpException(${type.name}: $message)'; -} diff --git a/lib/utils/plex_library_section_helpers.dart b/lib/utils/plex_library_section_helpers.dart new file mode 100644 index 00000000..fec97cce --- /dev/null +++ b/lib/utils/plex_library_section_helpers.dart @@ -0,0 +1,31 @@ +import '../media/media_item.dart'; + +/// Plex-only helpers for navigating to a "library section" hub entry. +/// +/// Plex's home/discover hubs occasionally surface library-section rows +/// (`/library/sections/{id}/all`) alongside individual items; the +/// `PlexMappers` adapter stashes the section key in [MediaItem.raw] under +/// `'key'` so navigation code can detect and route to the library screen +/// instead of the media-detail screen. +/// +/// Jellyfin's analogue is the dedicated `MediaLibrary` shape — Jellyfin +/// "views" never appear inside a [MediaItem], so these helpers correctly +/// return `false`/`null` for any Jellyfin item. +extension PlexLibrarySection on MediaItem { + /// Whether this item represents a Plex library section (shared + /// whole-library entry, not a media item). + bool get isLibrarySection { + final key = raw?['key']; + return key is String && key.startsWith('/library/sections/'); + } + + /// Extract the library section id from the stashed Plex `raw['key']`. + /// Returns `null` for non-section items or items without a parsable id. + String? get librarySectionKey { + if (!isLibrarySection) return null; + final key = raw?['key'] as String?; + if (key == null) return null; + final match = RegExp(r'/library/sections/(\d+)').firstMatch(key); + return match?.group(1); + } +} diff --git a/lib/utils/poll_with_backoff.dart b/lib/utils/poll_with_backoff.dart new file mode 100644 index 00000000..8bce0d50 --- /dev/null +++ b/lib/utils/poll_with_backoff.dart @@ -0,0 +1,39 @@ +/// Throw from a [pollWithBackoff] probe to stop polling without a value. +/// Used for terminal server responses (e.g. 404 secret expired) where the +/// outer caller wants `null` rather than the next iteration. +class PollTerminatedSignal implements Exception { + const PollTerminatedSignal(); +} + +/// Poll [probe] until it returns a non-null value, [shouldCancel] returns +/// true, [endTime] is reached, or [PollTerminatedSignal] is thrown. +/// +/// Uses exponential backoff between probes: starts at [initial], doubles +/// each iteration, capped at [maxBackoff]. Returns the first non-null +/// probe result, or null on cancel / timeout / terminated signal. +/// +/// Other exceptions thrown from [probe] propagate to the caller — wrap +/// the probe with your own try/catch if you want to swallow transient +/// network errors. +Future pollWithBackoff({ + required Future Function() probe, + required DateTime endTime, + bool Function()? shouldCancel, + Duration initial = const Duration(seconds: 1), + Duration maxBackoff = const Duration(seconds: 5), +}) async { + var backoff = initial; + while (DateTime.now().isBefore(endTime)) { + if (shouldCancel?.call() ?? false) return null; + try { + final result = await probe(); + if (result != null) return result; + } on PollTerminatedSignal { + return null; + } + await Future.delayed(backoff); + final next = backoff * 2; + backoff = next > maxBackoff ? maxBackoff : next; + } + return null; +} diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 8e6d203d..a8efc940 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../media/media_item.dart'; +import '../media/media_library.dart'; +import '../media/media_server_client.dart'; +import '../media/media_server_user_profile.dart'; import '../services/plex_client.dart'; import '../i18n/strings.g.dart'; -import '../models/plex_library.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_user_profile.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/user_profile_provider.dart'; @@ -16,19 +17,17 @@ extension ProviderExtensions on BuildContext { HiddenLibrariesProvider get hiddenLibraries => Provider.of(this, listen: false); // Direct profile settings access (nullable) - PlexUserProfile? get profileSettings => userProfile.profileSettings; + MediaServerUserProfile? get profileSettings => userProfile.profileSettings; /// Internal: resolve a [PlexClient] from a serverId or fall back to the - /// first online server. Returns null if neither yields a client. + /// first online server. Returns null if neither yields a Plex client. + /// Non-Plex servers (Jellyfin) are skipped — these helpers exist for + /// Plex-only flows that have no neutral equivalent (DVR tuning, metadata + /// edit, match). Backend-agnostic flows use the [_resolveMediaClient] + /// helpers below. PlexClient? _resolveClient(String? serverId) { final provider = Provider.of(this, listen: false); - if (serverId != null) { - final client = provider.getClientForServer(serverId); - if (client != null) return client; - } - final fallbackId = provider.onlineServerIds.firstOrNull; - if (fallbackId == null) return null; - return provider.getClientForServer(fallbackId); + return _resolvePrioritized(serverId, provider.onlineServerIds, provider.getPlexClientForServer); } /// Internal: like [_resolveClient] but throws a localized exception when @@ -37,15 +36,14 @@ extension ProviderExtensions on BuildContext { PlexClient _requireClient(String? serverId, {bool fallback = true}) { final provider = Provider.of(this, listen: false); if (serverId != null) { - final client = provider.getClientForServer(serverId); + final client = provider.getPlexClientForServer(serverId); if (client != null) return client; if (!fallback) { - appLogger.e('No client found for server $serverId'); + appLogger.e('No Plex client found for server $serverId'); throw Exception(t.errors.noClientAvailable); } } - final fallbackId = provider.onlineServerIds.firstOrNull; - final client = fallbackId == null ? null : provider.getClientForServer(fallbackId); + final client = _resolveClient(null); if (client == null) { throw Exception(t.errors.noClientAvailable); } @@ -53,36 +51,92 @@ extension ProviderExtensions on BuildContext { } /// Get PlexClient for a specific server ID. Throws if unavailable. - PlexClient getClientForServer(String serverId) => _requireClient(serverId, fallback: false); + PlexClient getPlexClientForServer(String serverId) => _requireClient(serverId, fallback: false); /// Get PlexClient for a specific server ID, or null if unavailable. - PlexClient? tryGetClientForServer(String? serverId) { + PlexClient? tryGetPlexClientForServer(String? serverId) { + if (serverId == null) return null; + final provider = Provider.of(this, listen: false); + return provider.getPlexClientForServer(serverId); + } + + /// Get PlexClient for a library, falling back to the first online server + /// when the library has no serverId. Throws if no client is available. + PlexClient getPlexClientForLibrary(MediaLibrary library) => _requireClient(library.serverId); + + /// Get client for a serverId, falling back to the first online server. + /// Throws if no client is available. + PlexClient getPlexClientWithFallback(String? serverId) => _requireClient(serverId); + + // ── Backend-neutral helpers ────────────────────────────────────── + // These return [MediaServerClient] regardless of backend kind so callers + // that consume only the [MediaServerClient] surface don't need to type- + // check the result. Use [getPlexClientForServer] / [getPlexClientForLibrary] + // when you specifically need a [PlexClient] (Plex-only flows like Live TV, + // metadata editing, etc.). + + MediaServerClient? _resolveMediaClient(String? serverId) { + final provider = Provider.of(this, listen: false); + return _resolvePrioritized(serverId, provider.onlineServerIds, provider.getClientForServer); + } + + /// Get a [MediaServerClient] for the given serverId, or null. + MediaServerClient? tryGetMediaClientForServer(String? serverId) { if (serverId == null) return null; final provider = Provider.of(this, listen: false); return provider.getClientForServer(serverId); } - /// Get PlexClient for a library, falling back to the first online server - /// when the library has no serverId. Throws if no client is available. - PlexClient getClientForLibrary(PlexLibrary library) => _requireClient(library.serverId); - - /// Get PlexClient for metadata, falling back to the first online server. - /// Throws if no client is available. - PlexClient getClientForMetadata(PlexMetadata metadata) => _requireClient(metadata.serverId); - - /// Get PlexClient for metadata, or null in offline mode / when no serverId. - PlexClient? getClientForMetadataOrNull(PlexMetadata metadata, {bool isOffline = false}) { - if (isOffline) return null; - return tryGetClientForServer(metadata.serverId); + /// Get a [MediaServerClient] for the given serverId. Throws when the + /// server isn't registered or is offline. Mirrors the throwing variant of + /// the Plex-typed [getPlexClientForServer] helpers. + MediaServerClient getMediaClientForServer(String serverId) { + final c = tryGetMediaClientForServer(serverId); + if (c == null) throw Exception(t.errors.noClientAvailable); + return c; } - /// Get the first online server's client. Throws if none available. - PlexClient getFirstAvailableClient() => _requireClient(null); + /// Get a [MediaServerClient] for [library], falling back to the first + /// online server when the library has no serverId. Throws if none. + MediaServerClient getMediaClientForLibrary(MediaLibrary library) { + final c = _resolveMediaClient(library.serverId); + if (c == null) throw Exception(t.errors.noClientAvailable); + return c; + } - /// Get the first online server's client, or null. - PlexClient? tryGetFirstAvailableClient() => _resolveClient(null); + /// Get a [MediaServerClient] for a [MediaItem], or null in offline mode / + /// when the server isn't online. + MediaServerClient? getMediaClientForItemOrNull(MediaItem item, {bool isOffline = false}) { + if (isOffline) return null; + return tryGetMediaClientForServer(item.serverId); + } - /// Get client for a serverId, falling back to the first online server. - /// Throws if no client is available. - PlexClient getClientWithFallback(String? serverId) => _requireClient(serverId); + /// Get a [MediaServerClient] for [serverId], falling back to the first + /// online server when not found. Throws if no client is available. + MediaServerClient getMediaClientWithFallback(String? serverId) { + final c = _resolveMediaClient(serverId); + if (c == null) throw Exception(t.errors.noClientAvailable); + return c; + } + + /// Like [getMediaClientWithFallback] but returns null instead of throwing + /// when no client is registered. Use this for non-critical surfaces (image + /// loaders, list cards) that can render a fallback when the client isn't + /// available — throwing during `build` would crash the widget instead. + MediaServerClient? tryGetMediaClientWithFallback(String? serverId) => _resolveMediaClient(serverId); +} + +/// Try [preferred] first, then fall back through [fallbacks] in order. Returns +/// the first non-null result from [resolve], or `null` if every candidate +/// resolves to null. +T? _resolvePrioritized(String? preferred, Iterable fallbacks, T? Function(String) resolve) { + if (preferred != null) { + final c = resolve(preferred); + if (c != null) return c; + } + for (final id in fallbacks) { + final c = resolve(id); + if (c != null) return c; + } + return null; } diff --git a/lib/utils/quality_preset_labels.dart b/lib/utils/quality_preset_labels.dart index 61280f8b..2d19e994 100644 --- a/lib/utils/quality_preset_labels.dart +++ b/lib/utils/quality_preset_labels.dart @@ -29,26 +29,44 @@ String _formatBitrate(int kbps) { /// File-size hint for a quality row, e.g. `3.6 GB (45%)`. Transcode presets /// append the ratio vs. source so the user can compare at a glance; Original -/// returns just the raw source size. Returns `null` when inputs are missing. +/// returns the raw source size. Prefers [sourceSizeBytes] when known (the +/// actual file size, matches what File Info shows) and falls back to +/// `bitrate × duration` when only bitrate is available. Returns `null` +/// when inputs are missing. String? qualityPresetSizeEstimate({ required TranscodeQualityPreset preset, required int? sourceBitrateKbps, required int? sourceDurationMs, + int? sourceSizeBytes, }) { - if (sourceDurationMs == null || sourceDurationMs <= 0) return null; - if (preset.isOriginal) { + if (sourceSizeBytes != null && sourceSizeBytes > 0) { + return ByteFormatter.formatBytes(sourceSizeBytes); + } + if (sourceDurationMs == null || sourceDurationMs <= 0) return null; if (sourceBitrateKbps == null || sourceBitrateKbps <= 0) return null; return ByteFormatter.formatBytes(sourceBitrateKbps * sourceDurationMs ~/ 8); } + if (sourceDurationMs == null || sourceDurationMs <= 0) return null; final videoKbps = preset.videoBitrateKbps; if (videoKbps == null) return null; final totalKbps = videoKbps + _audioBitrateEstimateKbps; - final size = ByteFormatter.formatBytes(totalKbps * sourceDurationMs ~/ 8); + final estimatedBytes = totalKbps * sourceDurationMs ~/ 8; + final size = ByteFormatter.formatBytes(estimatedBytes); - if (sourceBitrateKbps != null && sourceBitrateKbps > 0) { - final pct = (totalKbps * 100 / sourceBitrateKbps).round(); + // Percentage compares estimated transcode size to the same source figure + // the "Original" row displays — the real file size when known, otherwise + // the bitrate × duration estimate. Mixing the two bases (real file size + // vs. bitrate-based estimate) was causing visible mismatches. + int? sourceBytes; + if (sourceSizeBytes != null && sourceSizeBytes > 0) { + sourceBytes = sourceSizeBytes; + } else if (sourceBitrateKbps != null && sourceBitrateKbps > 0) { + sourceBytes = sourceBitrateKbps * sourceDurationMs ~/ 8; + } + if (sourceBytes != null && sourceBytes > 0) { + final pct = (estimatedBytes * 100 / sourceBytes).round(); return '$size ($pct%)'; } return size; @@ -61,6 +79,7 @@ Future showQualityPickerDialog( String? title, int? sourceBitrateKbps, int? sourceDurationMs, + int? sourceSizeBytes, }) { String labelFor(TranscodeQualityPreset p) { final base = qualityPresetLabel(p); @@ -68,6 +87,7 @@ Future showQualityPickerDialog( preset: p, sourceBitrateKbps: sourceBitrateKbps, sourceDurationMs: sourceDurationMs, + sourceSizeBytes: sourceSizeBytes, ); return size == null ? base : toBulletedString([base, size]); } diff --git a/lib/utils/resolution_label.dart b/lib/utils/resolution_label.dart new file mode 100644 index 00000000..c285a9d4 --- /dev/null +++ b/lib/utils/resolution_label.dart @@ -0,0 +1,20 @@ +/// Map a video stream height (pixels) onto the canonical resolution label +/// the rest of the app uses (`'4k'`, `'1080'`, `'720'`, `'480'`, or the raw +/// height for non-standard sizes). Returns `null` when [height] is null. +/// +/// Plex hands the label back already in its `Media.videoResolution` field; +/// Jellyfin only gives raw pixel dimensions, so the Jellyfin mapper and +/// playback path both call this to produce the same shape. +String? resolutionLabelFromHeight(int? height) { + if (height == null) return null; + if (height >= 2160) return '4k'; + if (height >= 1080) return '1080'; + if (height >= 720) return '720'; + if (height >= 480) return '480'; + return height.toString(); +} + +/// Convenience overload that takes width + height. Width is ignored — the +/// label is height-driven — but the signature matches earlier per-backend +/// helpers so callers don't have to drop a parameter on the floor. +String? resolutionLabelFromDimensions(int? width, int? height) => resolutionLabelFromHeight(height); diff --git a/lib/utils/session_identifier.dart b/lib/utils/session_identifier.dart new file mode 100644 index 00000000..88c668ad --- /dev/null +++ b/lib/utils/session_identifier.dart @@ -0,0 +1,15 @@ +import 'dart:math'; + +/// 24-character random alphanumeric identifier used for transient session +/// handles (Plex `X-Plex-Session-Identifier`, Jellyfin `PlaySessionId`). +/// +/// Backend-neutral: the format matches Plex's official client because the +/// Plex transcoder accepts that shape, and Jellyfin treats `PlaySessionId` +/// as opaque so any unique string works. Lifted out of `PlexClient` so +/// Jellyfin code paths don't have to import the Plex client just to mint a +/// session id. +String generateSessionIdentifier() { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final rand = Random(); + return List.generate(24, (_) => chars[rand.nextInt(chars.length)]).join(); +} diff --git a/lib/utils/url_utils.dart b/lib/utils/url_utils.dart new file mode 100644 index 00000000..87fcb5ba --- /dev/null +++ b/lib/utils/url_utils.dart @@ -0,0 +1,15 @@ +/// Backend-neutral URL helpers. +library; + +/// Removes a single trailing `/` from [input] so subsequent path joins +/// don't produce double slashes (`http://host//Items` → `http://host/Items`). +/// Trims whitespace first; returns the input unchanged if it has no trailing +/// slash. Empty input returns empty. +String stripTrailingSlash(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return trimmed; + if (trimmed.endsWith('/')) { + return trimmed.substring(0, trimmed.length - 1); + } + return trimmed; +} diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 4c06e3fb..a8e7930f 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -4,16 +4,14 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../media/media_item.dart'; import '../mpv/mpv.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_video_playback_data.dart'; import '../models/transcode_quality_preset.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../screens/video_player_screen.dart'; import '../services/external_player_service.dart'; import '../services/settings_service.dart'; -import '../utils/provider_extensions.dart'; import 'app_logger.dart'; const String kVideoPlayerRouteName = '/video_player'; @@ -35,7 +33,7 @@ class WatchTogetherPlaybackNavigationException implements Exception { /// /// Parameters: /// - [context]: The build context for navigation -/// - [metadata]: The Plex metadata for the content to play +/// - [metadata]: The neutral [MediaItem] for the content to play /// - [preferredAudioTrack]: Optional audio track to select on playback start /// - [preferredSubtitleTrack]: Optional subtitle track to select on playback start /// - [selectedMediaIndex]: Optional media version index to use; if not provided, @@ -48,7 +46,7 @@ class WatchTogetherPlaybackNavigationException implements Exception { /// was watched, or null if navigation was cancelled. Future navigateToVideoPlayer( BuildContext context, { - required PlexMetadata metadata, + required MediaItem metadata, AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, SubtitleTrack? preferredSecondarySubtitleTrack, @@ -56,28 +54,24 @@ Future navigateToVideoPlayer( TranscodeQualityPreset? selectedQualityPreset, bool usePushReplacement = false, bool isOffline = false, - PlexVideoPlaybackData? playbackData, }) async { // Extract context-dependent values before any async operations final navigator = Navigator.of(context); final downloadProvider = context.read(); - final client = isOffline ? null : context.getClientForMetadata(metadata); + // Use the manager-routed lookup so Jellyfin items don't trip the + // Plex-only client. The player branches on the returned type internally. + final manager = context.read().serverManager; + final mediaClient = isOffline ? null : manager.getClient(metadata.serverId ?? ''); // Load saved media version preference if not explicitly provided int mediaIndex = selectedMediaIndex ?? 0; - var effectivePlaybackData = playbackData; if (selectedMediaIndex == null) { try { final settingsService = await SettingsService.getInstance(); - final seriesKey = metadata.grandparentRatingKey ?? metadata.ratingKey; + final seriesKey = metadata.grandparentId ?? metadata.id; final savedPreference = settingsService.read(SettingsService.mediaVersionPreferences)[seriesKey]; if (savedPreference != null) { mediaIndex = savedPreference; - // Pre-parsed playbackData was built with mediaIndex=0; invalidate if - // the resolved index differs so the player re-fetches with the correct one - if (savedPreference != 0) { - effectivePlaybackData = null; - } } } catch (e) { // Ignore errors loading preference, use default @@ -102,7 +96,7 @@ Future navigateToVideoPlayer( launched = await ExternalPlayerService.launch( context: context, metadata: metadata, - client: client!, + client: mediaClient, mediaIndex: mediaIndex, ); } @@ -116,10 +110,10 @@ Future navigateToVideoPlayer( // Prevent stacking an identical video player when already active if (!usePushReplacement && - VideoPlayerScreenState.activeRatingKey == metadata.ratingKey && + VideoPlayerScreenState.activeId == metadata.id && VideoPlayerScreenState.activeMediaIndex == mediaIndex) { appLogger.d( - 'Video player already active for ${metadata.ratingKey} (mediaIndex=$mediaIndex), skipping duplicate navigation', + 'Video player already active for ${metadata.id} (mediaIndex=$mediaIndex), skipping duplicate navigation', ); return null; } @@ -134,7 +128,6 @@ Future navigateToVideoPlayer( selectedMediaIndex: mediaIndex, selectedQualityPreset: selectedQualityPreset, isOffline: isOffline, - playbackData: effectivePlaybackData, ), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, @@ -152,14 +145,14 @@ Future navigateToVideoPlayer( /// /// Parameters: /// - [context]: The build context for navigation -/// - [metadata]: The Plex metadata for the content to play +/// - [metadata]: The neutral [MediaItem] for the content to play /// - [isOffline]: If true, plays from downloaded content /// - [onRefresh]: Optional callback to refresh data when returning from playback /// (only called when not offline) /// - All other parameters are passed through to [navigateToVideoPlayer] Future navigateToVideoPlayerWithRefresh( BuildContext context, { - required PlexMetadata metadata, + required MediaItem metadata, bool isOffline = false, VoidCallback? onRefresh, AudioTrack? preferredAudioTrack, @@ -167,7 +160,6 @@ Future navigateToVideoPlayerWithRefresh( SubtitleTrack? preferredSecondarySubtitleTrack, int? selectedMediaIndex, bool usePushReplacement = false, - PlexVideoPlaybackData? playbackData, }) async { final result = await navigateToVideoPlayer( context, @@ -178,7 +170,6 @@ Future navigateToVideoPlayerWithRefresh( preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack, selectedMediaIndex: selectedMediaIndex, usePushReplacement: usePushReplacement, - playbackData: playbackData, ); appLogger.d('Returned from playback, refreshing metadata'); @@ -205,7 +196,7 @@ Future navigateToWatchTogetherPlayback( throw const WatchTogetherPlaybackNavigationException('Watch Together server is unavailable'); } - final metadata = await client.getMetadataWithImages(ratingKey); + final metadata = await client.fetchItem(ratingKey); if (metadata == null) { throw const WatchTogetherPlaybackNavigationException('Current Watch Together media is unavailable'); } diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index a10d94ef..2e3e284a 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -1,4 +1,4 @@ -import '../models/plex_metadata.dart'; +import '../media/media_item.dart'; import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; @@ -9,11 +9,11 @@ enum WatchStateChangeType { watched, unwatched, progressUpdate } /// Event representing a watch state change with parent chain for hierarchical invalidation class WatchStateEvent with HierarchicalEventMixin { - /// The item that changed + /// The id of the item that changed (Plex ratingKey, Jellyfin GUID, …). @override - final String ratingKey; + final String itemId; - /// Composite key: serverId:ratingKey + /// Composite key: serverId:itemId @override final String globalKey; @@ -21,12 +21,18 @@ class WatchStateEvent with HierarchicalEventMixin { @override final String serverId; + /// Optional backend-private cache namespace for user-scoped servers. + /// + /// UI invalidation still uses [serverId], but cache writers should prefer + /// this when present so Jellyfin user data stays isolated per user. + final String? cacheServerId; + /// Type of change final WatchStateChangeType changeType; /// Parent chain for hierarchical invalidation - /// For an episode: [seasonRatingKey, showRatingKey] - /// For a season: [showRatingKey] + /// For an episode: [seasonId, showId] + /// For a season: [showId] /// For a movie: [] @override final List parentChain; @@ -41,24 +47,25 @@ class WatchStateEvent with HierarchicalEventMixin { final bool? isNowWatched; /// Library section this item belongs to — used for per-tracker library - /// filtering. Null when emitted without full metadata. - final int? librarySectionID; + /// filtering. Null when emitted without full metadata. Plex sends a + /// numeric id, Jellyfin sends a UUID; both round-trip as strings. + final String? librarySectionID; WatchStateEvent({ - required this.ratingKey, + required this.itemId, required this.serverId, required this.changeType, required this.parentChain, required this.mediaType, + this.cacheServerId, this.viewOffset, this.isNowWatched, this.librarySectionID, - }) : globalKey = buildGlobalKey(serverId, ratingKey); + }) : globalKey = buildGlobalKey(serverId, itemId); - /// `serverId:librarySectionID`, matching [PlexLibrary.globalKey]. Null when + /// `serverId:librarySectionID`, matching [MediaLibrary.globalKey]. Null when /// the library section is unknown. - String? get librarySectionGlobalKey => - librarySectionID != null ? buildGlobalKey(serverId, librarySectionID!.toString()) : null; + String? get librarySectionGlobalKey => librarySectionID != null ? buildGlobalKey(serverId, librarySectionID!) : null; @override String toString() => 'WatchStateEvent($changeType, $globalKey, parents: $parentChain)'; @@ -79,7 +86,7 @@ class WatchStateNotifier extends BaseNotifier { Stream forServer(String serverId) => stream.where((e) => e.serverId == serverId); /// Filter for events affecting a specific item or its children - Stream forItem(String ratingKey) => stream.where((e) => e.affectsItem(ratingKey)); + Stream forItem(String itemId) => stream.where((e) => e.affectsItem(itemId)); /// Emit a watch state event with logging @override @@ -88,26 +95,27 @@ class WatchStateNotifier extends BaseNotifier { super.notify(event); } - /// Helper to emit a watched/unwatched event from metadata - void notifyWatched({required PlexMetadata metadata, bool isNowWatched = true}) { + /// Helper to emit a watched/unwatched event from a [MediaItem]. + void notifyWatched({required MediaItem item, bool isNowWatched = true, String? cacheServerId}) { notify( WatchStateEvent( - ratingKey: metadata.ratingKey, - serverId: metadata.serverId ?? '', + itemId: item.id, + serverId: item.serverId ?? '', + cacheServerId: cacheServerId, changeType: isNowWatched ? WatchStateChangeType.watched : WatchStateChangeType.unwatched, - parentChain: metadata.parentChain, - mediaType: metadata.type ?? '', + parentChain: item.parentChain, + mediaType: item.kind.id, isNowWatched: isNowWatched, - librarySectionID: metadata.librarySectionID, + librarySectionID: item.libraryId, ), ); } /// Helper to emit a progress update event. /// [watchedThreshold] defaults to 0.9 — pass the server's configured value - /// (`client.watchedThresholdPercent / 100.0`) when available. + /// (`client.watchedThreshold`) when available. void notifyProgress({ - required PlexMetadata metadata, + required MediaItem item, required int viewOffset, required int duration, double watchedThreshold = 0.9, @@ -116,14 +124,14 @@ class WatchStateNotifier extends BaseNotifier { notify( WatchStateEvent( - ratingKey: metadata.ratingKey, - serverId: metadata.serverId ?? '', + itemId: item.id, + serverId: item.serverId ?? '', changeType: WatchStateChangeType.progressUpdate, - parentChain: metadata.parentChain, - mediaType: metadata.type ?? '', + parentChain: item.parentChain, + mediaType: item.kind.id, viewOffset: viewOffset, isNowWatched: isNowWatched, - librarySectionID: metadata.librarySectionID, + librarySectionID: item.libraryId, ), ); } diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 484f45e4..33a2119f 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -9,9 +9,9 @@ import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_wrapper.dart'; +import '../../profiles/active_profile_provider.dart'; import '../../services/settings_service.dart'; import '../../utils/app_logger.dart'; -import '../../utils/provider_extensions.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/dialog_action_button.dart'; @@ -22,6 +22,7 @@ import '../providers/watch_together_provider.dart'; import '../services/recent_rooms_service.dart'; import '../services/watch_together_peer_service.dart'; import '../widgets/join_session_dialog.dart'; +import '../../widgets/loading_indicator_box.dart'; /// Main screen for Watch Together functionality /// @@ -98,7 +99,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { bool get _isBusy => _isCreating || _isJoining || _enteringRoomCode != null; - String? get _plexDisplayName => context.userProfile.currentUser?.displayName; + String? get _plexDisplayName => context.read().active?.displayName; Future _checkHealth() async { try { @@ -170,9 +171,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { onPressed: _isBusy ? null : _createSession, child: FilledButton.icon( onPressed: _isBusy ? null : _createSession, - icon: _isCreating - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Symbols.add_rounded), + icon: _isCreating ? const LoadingIndicatorBox(size: 20) : const Icon(Symbols.add_rounded), label: Text(_isCreating ? t.watchTogether.creating : t.watchTogether.createSession), ), ), @@ -184,9 +183,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { onPressed: _isBusy ? null : _joinSession, child: OutlinedButton.icon( onPressed: _isBusy ? null : _joinSession, - icon: _isJoining - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Symbols.group_add_rounded), + icon: _isJoining ? const LoadingIndicatorBox(size: 20) : const Icon(Symbols.group_add_rounded), label: Text(_isJoining ? t.watchTogether.joining : t.watchTogether.joinSession), ), ), @@ -370,9 +367,7 @@ class _RecentRoomTile extends StatelessWidget { onLongPress: () => _showActions(context), child: ListTile( shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), - leading: isEntering - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Symbols.meeting_room_rounded), + leading: isEntering ? const LoadingIndicatorBox(size: 24) : const Icon(Symbols.meeting_room_rounded), title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: room.name != null ? Text( @@ -674,9 +669,7 @@ class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> { onPressed: _isJoining ? null : _joinCurrentPlayback, child: FilledButton.icon( onPressed: _isJoining ? null : _joinCurrentPlayback, - icon: _isJoining - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Symbols.play_arrow_rounded), + icon: _isJoining ? const LoadingIndicatorBox() : const Icon(Symbols.play_arrow_rounded), label: Text(_isJoining ? t.watchTogether.joining : t.watchTogether.joinCurrentPlayback), ), ), diff --git a/lib/widgets/artwork_picker_dialog.dart b/lib/widgets/artwork_picker_dialog.dart index cf7f51fb..bbda0cc7 100644 --- a/lib/widgets/artwork_picker_dialog.dart +++ b/lib/widgets/artwork_picker_dialog.dart @@ -6,10 +6,12 @@ import '../focus/focusable_wrapper.dart'; import '../i18n/strings.g.dart'; import '../services/file_picker_service.dart'; import '../services/plex_client.dart'; +import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/app_icon.dart'; -import '../widgets/plex_optimized_image.dart'; +import '../widgets/optimized_media_image.dart'; +import 'loading_indicator_box.dart'; class ArtworkPickerDialog extends StatefulWidget { final PlexClient client; @@ -59,10 +61,7 @@ class _ArtworkPickerDialogState extends State { // silently ignore the selection despite returning 200. final url = artwork['ratingKey'] as String? ?? artwork['key'] as String?; if (url == null || _isApplying) return; - - setState(() => _isApplying = true); - final success = await widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url); - _handleArtworkUpdate(success); + await _runArtworkUpdate(() => widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url)); } Future _addFromUrl() async { @@ -74,10 +73,7 @@ class _ArtworkPickerDialogState extends State { ); if (url == null || url.isEmpty || !mounted) return; - - setState(() => _isApplying = true); - final success = await widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url); - _handleArtworkUpdate(success); + await _runArtworkUpdate(() => widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url)); } Future _uploadFile() async { @@ -87,13 +83,22 @@ class _ArtworkPickerDialogState extends State { final bytes = result.files.first.bytes; if (bytes == null) return; - - setState(() => _isApplying = true); - final success = await widget.client.uploadArtwork(widget.ratingKey, widget.element, bytes); - _handleArtworkUpdate(success); + await _runArtworkUpdate(() => widget.client.uploadArtwork(widget.ratingKey, widget.element, bytes)); } - void _handleArtworkUpdate(bool success) { + /// Runs an artwork update API call with shared loading-state and + /// error-handling. The underlying client throws on HTTP errors (see + /// [PlexClient] `_wrapBoolApiCall`), so we must catch here or `_isApplying` + /// gets stuck `true` and the user sees an infinite spinner. + Future _runArtworkUpdate(Future Function() action) async { + if (_isApplying) return; + setState(() => _isApplying = true); + bool success = false; + try { + success = await action(); + } catch (e, st) { + appLogger.e('Artwork update failed', error: e, stackTrace: st); + } if (!mounted) return; setState(() => _isApplying = false); if (success) { @@ -114,11 +119,7 @@ class _ArtworkPickerDialogState extends State { child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(), ), actions: [ - if (_isApplying) - const Padding( - padding: EdgeInsets.all(8), - child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)), - ), + if (_isApplying) const Padding(padding: EdgeInsets.all(8), child: LoadingIndicatorBox(size: 24)), FocusableButton( onPressed: _addFromUrl, child: TextButton.icon( @@ -182,7 +183,7 @@ class _ArtworkPickerDialogState extends State { ), child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), - child: PlexOptimizedImage(client: widget.client, imagePath: thumbUrl, fit: BoxFit.contain), + child: OptimizedMediaImage(client: widget.client, imagePath: thumbUrl, fit: BoxFit.contain), ), ), if (isSelected) diff --git a/lib/widgets/auth_error_banner.dart b/lib/widgets/auth_error_banner.dart new file mode 100644 index 00000000..f6f54510 --- /dev/null +++ b/lib/widgets/auth_error_banner.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../i18n/strings.g.dart'; +import '../providers/multi_server_provider.dart'; +import '../screens/settings/add_connection_screen.dart'; +import 'app_icon.dart'; + +/// Top-of-app banner shown when one or more servers' tokens have been +/// rejected (HTTP 401/403 on the health probe). Distinct from "server +/// offline" — taps the user toward re-auth instead of leaving them +/// puzzled by empty hubs. +/// +/// Tracks [MultiServerProvider.hasAuthErrorServers] and collapses to +/// `SizedBox.shrink()` when no servers are in the auth-error state. The +/// CTA opens [AddConnectionScreen]; the user picks the right backend and +/// the resulting token replaces the stale row in the registry, which +/// clears the auth-error state on the next health sweep. +class AuthErrorBanner extends StatelessWidget { + const AuthErrorBanner({super.key}); + + @override + Widget build(BuildContext context) { + final entries = context.select>( + (p) => p.authErrorServers, + ); + if (entries.isEmpty) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final label = entries.length == 1 + ? t.connections.sessionExpiredOne(name: entries.first.displayName) + : t.connections.sessionExpiredMany(count: entries.length); + + return Material( + color: scheme.errorContainer, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + AppIcon(Symbols.lock_rounded, fill: 1, color: scheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onErrorContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + FilledButton.tonal( + style: FilledButton.styleFrom( + backgroundColor: scheme.onErrorContainer, + foregroundColor: scheme.errorContainer, + ), + onPressed: () => _openReauth(context), + child: Text(t.connections.signInAgain), + ), + ], + ), + ), + ), + ); + } + + Future _openReauth(BuildContext context) async { + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AddConnectionScreen())); + } +} diff --git a/lib/widgets/backend_badge.dart b/lib/widgets/backend_badge.dart new file mode 100644 index 00000000..3f6d6f66 --- /dev/null +++ b/lib/widgets/backend_badge.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +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 +/// the parent provides — pass [color] to override, otherwise inherits from +/// [DefaultTextStyle] / `IconTheme`. +class BackendBadge extends StatelessWidget { + final MediaBackend backend; + final double size; + final Color? color; + + const BackendBadge({super.key, required this.backend, this.size = 16, this.color}); + + @override + Widget build(BuildContext context) { + final tint = + color ?? + DefaultTextStyle.of(context).style.color ?? + IconTheme.of(context).color ?? + Theme.of(context).colorScheme.onSurface; + final asset = switch (backend) { + MediaBackend.plex => 'assets/plex_chevron.svg', + MediaBackend.jellyfin => 'assets/jellyfin_icon.svg', + }; + return SvgPicture.asset( + asset, + width: size, + height: size, + theme: SvgTheme(currentColor: tint), + ); + } +} diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index a129f68d..0ce98e3a 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -3,10 +3,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../connection/connection_registry.dart'; import '../../i18n/strings.g.dart'; +import '../../models/plex/plex_home.dart'; +import '../../profiles/active_plex_identity.dart'; +import '../../profiles/active_profile_provider.dart'; +import '../../profiles/plex_home_service.dart'; +import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; -import '../../providers/user_profile_provider.dart'; import '../../utils/app_logger.dart'; +import '../loading_indicator_box.dart'; /// Discovers LAN hosts and provides UI to connect to them. class DiscoveryView extends StatefulWidget { @@ -38,8 +44,26 @@ class _DiscoveryViewState extends State { } Future _initCryptoAndDiscover() async { - final home = context.read().home; - await _provider.ensureCryptoReady(home); + final connections = context.read(); + final activeProfile = context.read(); + final profileConnections = context.read(); + final plexHome = context.read(); + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); + if (!mounted) return; + final home = await _resolveHome(identity?.account.id); + if (!mounted) return; + await _provider.ensureCryptoReady( + home, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + identity: identity, + plexHomeForConnection: plexHome.materializePlexHomeForConnection, + ); if (!mounted) return; if (_provider.isCryptoReady) { @@ -54,6 +78,11 @@ class _DiscoveryViewState extends State { } } + Future _resolveHome(String? connectionId) { + if (connectionId == null) return Future.value(); + return context.read().materializePlexHomeForConnection(connectionId); + } + void _startDiscovery() { final stream = _provider.discoverHosts(); if (stream == null) return; @@ -240,9 +269,7 @@ class _DiscoveryViewState extends State { leading: Icon(_platformIcon(host.platform), size: 32), title: Text(host.name), subtitle: Text(host.platform), - trailing: _isConnecting - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.arrow_forward), + trailing: _isConnecting ? const LoadingIndicatorBox(size: 24) : const Icon(Icons.arrow_forward), onTap: _isConnecting ? null : () => _connect(() => _provider.connectToDiscoveredHost(host)), ), ), @@ -305,9 +332,7 @@ class _DiscoveryViewState extends State { if (!_formKey.currentState!.validate()) return; _connect(() => _provider.connectToManualHost(_hostAddressController.text.trim())); }, - icon: _isConnecting - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.link), + icon: _isConnecting ? const LoadingIndicatorBox(size: 16) : const Icon(Icons.link), label: Text(_isConnecting ? t.companionRemote.pairing.connecting : t.common.connect), ), ], diff --git a/lib/widgets/companion_remote/remote_session_dialog.dart b/lib/widgets/companion_remote/remote_session_dialog.dart index bf938346..7242b095 100644 --- a/lib/widgets/companion_remote/remote_session_dialog.dart +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../connection/connection_registry.dart'; import '../../i18n/strings.g.dart'; +import '../../profiles/active_plex_identity.dart'; +import '../../profiles/active_profile_provider.dart'; +import '../../profiles/plex_home_service.dart'; +import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; -import '../../providers/user_profile_provider.dart'; import '../../focus/focusable_button.dart'; import '../../utils/app_logger.dart'; @@ -34,7 +38,6 @@ class _RemoteSessionDialogState extends State { Future _ensureServerRunning() async { final provider = context.read(); - if (provider.isHostServerRunning) return; setState(() { _isStarting = true; @@ -42,10 +45,37 @@ class _RemoteSessionDialogState extends State { }); try { - final home = context.read().home; - await provider.ensureCryptoReady(home); + final connections = context.read(); + final activeProfile = context.read(); + final profileConnections = context.read(); + final plexHome = context.read(); + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); if (!mounted) return; - await provider.startHostServer(); + final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); + if (!mounted) return; + final ok = await provider.ensureCryptoReady( + home, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + identity: identity, + plexHomeForConnection: plexHome.materializePlexHomeForConnection, + ); + if (!mounted) return; + if (!ok) { + setState(() { + _isStarting = false; + _errorMessage = t.companionRemote.pairing.cryptoInitFailed; + }); + return; + } + if (!provider.isHostServerRunning) { + await provider.startHostServer(); + } if (mounted) setState(() => _isStarting = false); } catch (e) { diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart index 83530d8a..fa80a813 100644 --- a/lib/widgets/device_code_dialog.dart +++ b/lib/widgets/device_code_dialog.dart @@ -6,6 +6,7 @@ import '../i18n/strings.g.dart'; import '../models/trackers/device_code.dart'; import '../utils/snackbar_helper.dart'; import 'dialog_action_button.dart'; +import 'loading_indicator_box.dart'; /// Shared device-code activation dialog for Trakt and Simkl (RFC 8628). /// @@ -71,7 +72,7 @@ class DeviceCodeDialog extends StatelessWidget { const SizedBox(height: 16), Row( children: [ - const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), + const LoadingIndicatorBox(size: 16), const SizedBox(width: 12), Expanded(child: Text(t.trackers.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)), ], diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 47f71334..2183c87e 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -3,10 +3,11 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../focus/focusable_wrapper.dart'; import '../i18n/strings.g.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; import '../models/download_models.dart'; -import '../models/plex_metadata.dart'; -import '../utils/content_utils.dart'; import '../utils/dialogs.dart'; +import '../utils/global_key_utils.dart'; import 'download_status_icon.dart'; /// Represents a node in the download tree @@ -17,7 +18,7 @@ class DownloadTreeNode { final double progress; // 0.0-1.0 final DownloadStatus status; final List children; - final PlexMetadata? metadata; + final MediaItem? metadata; final DownloadProgress? downloadProgress; const DownloadTreeNode({ @@ -48,7 +49,7 @@ enum DownloadNodeType { show, season, episode, movie } /// Movies appear at top level class DownloadTreeView extends StatefulWidget { final Map downloads; - final Map metadata; + final Map metadata; final void Function(String globalKey)? onPause; final void Function(String globalKey)? onResume; final void Function(String globalKey)? onRetry; @@ -133,7 +134,7 @@ class _DownloadTreeViewState extends State { if (meta.isEpisode) { // Group episodes by show - final showKey = meta.grandparentRatingKey ?? 'unknown'; + final showKey = meta.grandparentId ?? 'unknown'; showGroups.putIfAbsent(showKey, () => []); showGroups[showKey]!.add(entry); } else if (meta.isMovie) { @@ -170,7 +171,7 @@ class _DownloadTreeViewState extends State { final meta = widget.metadata[episode.key]; if (meta == null) continue; - final seasonKey = meta.parentRatingKey ?? 'unknown'; + final seasonKey = meta.parentId ?? 'unknown'; seasonGroups.putIfAbsent(seasonKey, () => []); seasonGroups[seasonKey]!.add(episode); } @@ -408,10 +409,17 @@ class _DownloadTreeViewState extends State { return keys; } - /// Delete all children of a container node + /// Delete all children of a container node via the container's globalKey + /// so deleteDownload's transitive show/season path cleans up all maps. void _deleteAllChildren(DownloadTreeNode node) { - final allKeys = _getAllChildKeys(node); - for (final key in allKeys) { + final containerKey = resolveDownloadContainerGlobalKey(node, widget.metadata); + if (containerKey != null) { + widget.onDelete?.call(containerKey); + return; + } + + // Container globalKey unresolvable; fall back to per-leaf delete. + for (final key in _getAllChildKeys(node)) { widget.onDelete?.call(key); } } @@ -432,6 +440,42 @@ class _DownloadTreeViewState extends State { } } +/// Tree-node keys for shows/seasons aren't provider globalKeys; reconstruct +/// from any leaf episode's serverId + grandparentId/parentId. +@visibleForTesting +String? resolveDownloadContainerGlobalKey(DownloadTreeNode node, Map metadata) { + final firstLeafKey = _firstLeafKey(node); + if (firstLeafKey == null) return null; + final firstLeafMeta = metadata[firstLeafKey]; + final serverId = firstLeafMeta?.serverId; + if (serverId == null) return null; + switch (node.type) { + case DownloadNodeType.show: + final showRatingKey = firstLeafMeta!.grandparentId; + if (showRatingKey == null) return null; + return buildGlobalKey(serverId, showRatingKey); + case DownloadNodeType.season: + final seasonRatingKey = firstLeafMeta!.parentId; + if (seasonRatingKey == null) return null; + return buildGlobalKey(serverId, seasonRatingKey); + case DownloadNodeType.episode: + case DownloadNodeType.movie: + return null; + } +} + +String? _firstLeafKey(DownloadTreeNode node) { + for (final child in node.children) { + if (child.hasChildren) { + final result = _firstLeafKey(child); + if (result != null) return result; + } else { + return child.key; + } + } + return null; +} + /// Helper class to store a node with its depth in the flattened tree class _FlatNode { final DownloadTreeNode node; diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 994956f5..31d578ef 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -10,22 +10,22 @@ import '../mixins/context_menu_tap_mixin.dart'; import '../models/download_models.dart'; import '../providers/download_provider.dart'; import '../providers/settings_provider.dart'; -import '../utils/content_utils.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; import '../widgets/collapsible_text.dart'; import '../widgets/download_status_icon.dart'; -import '../widgets/plex_optimized_image.dart'; -import '../models/plex_metadata.dart'; +import '../widgets/optimized_media_image.dart'; import '../utils/platform_detector.dart'; import '../utils/formatters.dart'; import '../widgets/media_context_menu.dart'; import '../widgets/placeholder_container.dart'; import '../theme/mono_tokens.dart'; -import '../../services/plex_client.dart'; +import '../media/media_server_client.dart'; /// Episode card widget with D-pad long-press support class EpisodeCard extends StatefulWidget { - final PlexMetadata episode; - final PlexClient? client; + final MediaItem episode; + final MediaServerClient? client; final VoidCallback onTap; final Future Function(String)? onRefresh; final Future Function()? onListRefresh; @@ -62,8 +62,8 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin 0; - final progress = hasProgress ? widget.episode.viewOffset! / widget.episode.duration! : 0.0; + widget.episode.viewOffsetMs != null && + widget.episode.durationMs != null && + widget.episode.viewOffsetMs! > 0; + final progress = hasProgress ? widget.episode.viewOffsetMs! / widget.episode.durationMs! : 0.0; - final hasActiveProgress = hasProgress && widget.episode.viewOffset! < widget.episode.duration!; + final hasActiveProgress = hasProgress && widget.episode.viewOffsetMs! < widget.episode.durationMs!; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), @@ -118,7 +118,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin const PlaceholderContainer(), diff --git a/lib/widgets/file_info_bottom_sheet.dart b/lib/widgets/file_info_bottom_sheet.dart index 48ccc571..4ec9fc05 100644 --- a/lib/widgets/file_info_bottom_sheet.dart +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../models/plex_file_info.dart'; +import '../media/media_file_info.dart'; import '../i18n/strings.g.dart'; import '../utils/scroll_utils.dart'; import 'bottom_sheet_header.dart'; class FileInfoBottomSheet extends StatefulWidget { - final PlexFileInfo fileInfo; + final MediaFileInfo fileInfo; final String title; const FileInfoBottomSheet({super.key, required this.fileInfo, required this.title}); @@ -32,6 +32,8 @@ class _FileInfoBottomSheetState extends State { @override Widget build(BuildContext context) { + final info = widget.fileInfo; + final hasAdvanced = info.optimizedForStreaming != null || info.has64bitOffsets != null; return Column( children: [ // Header @@ -50,69 +52,63 @@ class _FileInfoBottomSheetState extends State { // Video Section _buildSectionHeader(t.fileInfo.video), const SizedBox(height: 8), - _buildInfoRow(t.fileInfo.codec, widget.fileInfo.videoCodec ?? t.common.unknown), - _buildInfoRow(t.fileInfo.resolution, widget.fileInfo.resolutionFormatted), - if (widget.fileInfo.videoBitrate != null) - _buildInfoRow(t.fileInfo.bitrate, widget.fileInfo.videoBitrateFormatted), - _buildInfoRow(t.fileInfo.frameRate, widget.fileInfo.frameRateFormatted), - _buildInfoRow(t.fileInfo.aspectRatio, widget.fileInfo.aspectRatioFormatted), - if (widget.fileInfo.videoProfile != null) - _buildInfoRow(t.fileInfo.profile, widget.fileInfo.videoProfile!), - if (widget.fileInfo.bitDepth != null) - _buildInfoRow(t.fileInfo.bitDepth, '${widget.fileInfo.bitDepth} bit'), - if (widget.fileInfo.colorSpace != null) _buildInfoRow(t.fileInfo.colorSpace, widget.fileInfo.colorSpace!), - if (widget.fileInfo.colorRange != null) _buildInfoRow(t.fileInfo.colorRange, widget.fileInfo.colorRange!), - if (widget.fileInfo.colorPrimaries != null) - _buildInfoRow(t.fileInfo.colorPrimaries, widget.fileInfo.colorPrimaries!), - if (widget.fileInfo.chromaSubsampling != null) - _buildInfoRow(t.fileInfo.chromaSubsampling, widget.fileInfo.chromaSubsampling!), + if (info.videoCodec != null) _buildInfoRow(t.fileInfo.codec, info.videoCodec!), + if (info.resolutionFormatted != null) _buildInfoRow(t.fileInfo.resolution, info.resolutionFormatted!), + if (info.videoBitrateFormatted != null) _buildInfoRow(t.fileInfo.bitrate, info.videoBitrateFormatted!), + if (info.frameRateFormatted != null) _buildInfoRow(t.fileInfo.frameRate, info.frameRateFormatted!), + if (info.aspectRatioFormatted != null) _buildInfoRow(t.fileInfo.aspectRatio, info.aspectRatioFormatted!), + if (info.videoProfile != null) _buildInfoRow(t.fileInfo.profile, info.videoProfile!), + if (info.bitDepth != null) _buildInfoRow(t.fileInfo.bitDepth, '${info.bitDepth} bit'), + if (info.colorSpace != null) _buildInfoRow(t.fileInfo.colorSpace, info.colorSpace!), + if (info.colorRange != null) _buildInfoRow(t.fileInfo.colorRange, info.colorRange!), + if (info.colorPrimaries != null) _buildInfoRow(t.fileInfo.colorPrimaries, info.colorPrimaries!), + if (info.chromaSubsampling != null) _buildInfoRow(t.fileInfo.chromaSubsampling, info.chromaSubsampling!), const SizedBox(height: 20), // Audio Section _buildSectionHeader(t.fileInfo.audio), const SizedBox(height: 8), - if (widget.fileInfo.audioTracks.isNotEmpty) - for (int i = 0; i < widget.fileInfo.audioTracks.length; i++) - _buildInfoRow('${i + 1}', widget.fileInfo.audioTracks[i].label), - if (widget.fileInfo.audioTracks.isEmpty) ...[ - _buildInfoRow(t.fileInfo.codec, widget.fileInfo.audioCodec ?? t.common.unknown), - _buildInfoRow(t.fileInfo.channels, widget.fileInfo.audioChannelsFormatted), - if (widget.fileInfo.audioProfile != null) - _buildInfoRow(t.fileInfo.profile, widget.fileInfo.audioProfile!), + if (info.audioTracks.isNotEmpty) + for (int i = 0; i < info.audioTracks.length; i++) _buildInfoRow('${i + 1}', info.audioTracks[i].label), + if (info.audioTracks.isEmpty) ...[ + if (info.audioCodec != null) _buildInfoRow(t.fileInfo.codec, info.audioCodec!), + if (info.audioChannelsFormatted != null) + _buildInfoRow(t.fileInfo.channels, info.audioChannelsFormatted!), + if (info.audioProfile != null) _buildInfoRow(t.fileInfo.profile, info.audioProfile!), ], const SizedBox(height: 20), // Subtitles Section - if (widget.fileInfo.subtitleTracks.isNotEmpty) ...[ + if (info.subtitleTracks.isNotEmpty) ...[ _buildSectionHeader(t.fileInfo.subtitles), const SizedBox(height: 8), - for (int i = 0; i < widget.fileInfo.subtitleTracks.length; i++) - _buildInfoRow('${i + 1}', widget.fileInfo.subtitleTracks[i].label), + for (int i = 0; i < info.subtitleTracks.length; i++) + _buildInfoRow('${i + 1}', info.subtitleTracks[i].label), const SizedBox(height: 20), ], // File Section _buildSectionHeader(t.fileInfo.file), const SizedBox(height: 8), - if (widget.fileInfo.filePath != null) - _buildInfoRow(t.fileInfo.path, widget.fileInfo.filePath!, isMonospace: true), - _buildInfoRow(t.fileInfo.size, widget.fileInfo.fileSizeFormatted), - _buildInfoRow(t.fileInfo.container, widget.fileInfo.container ?? t.common.unknown), - _buildInfoRow(t.fileInfo.duration, widget.fileInfo.durationFormatted), - _buildInfoRow(t.fileInfo.overallBitrate, widget.fileInfo.bitrateFormatted), - const SizedBox(height: 20), + if (info.filePath != null) _buildInfoRow(t.fileInfo.path, info.filePath!, isMonospace: true), + if (info.fileSizeFormatted != null) _buildInfoRow(t.fileInfo.size, info.fileSizeFormatted!), + if (info.container != null) _buildInfoRow(t.fileInfo.container, info.container!), + if (info.durationFormatted != null) _buildInfoRow(t.fileInfo.duration, info.durationFormatted!), + if (info.bitrateFormatted != null) _buildInfoRow(t.fileInfo.overallBitrate, info.bitrateFormatted!), + if (hasAdvanced) ...[ + const SizedBox(height: 20), - // Advanced Section - _buildSectionHeader(t.fileInfo.advanced), - const SizedBox(height: 8), - _buildInfoRow( - t.fileInfo.optimizedForStreaming, - widget.fileInfo.optimizedForStreaming == true ? t.common.yes : t.common.no, - ), - _buildInfoRow( - t.fileInfo.has64bitOffsets, - widget.fileInfo.has64bitOffsets == true ? t.common.yes : t.common.no, - ), + // Advanced Section + _buildSectionHeader(t.fileInfo.advanced), + const SizedBox(height: 8), + if (info.optimizedForStreaming != null) + _buildInfoRow( + t.fileInfo.optimizedForStreaming, + info.optimizedForStreaming! ? t.common.yes : t.common.no, + ), + if (info.has64bitOffsets != null) + _buildInfoRow(t.fileInfo.has64bitOffsets, info.has64bitOffsets! ? t.common.yes : t.common.no), + ], ], ), ), diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index 91c14219..01f6bc4b 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -11,10 +11,12 @@ import 'media_card.dart'; /// - Handles SELECT key for activation with long-press detection /// - Accepts optional external focusNode for programmatic focus control class FocusableMediaCard extends StatefulWidget { - final dynamic item; // PlexMetadata or PlexPlaylist + /// Either a [MediaItem] or a [MediaPlaylist]. Typed as [Object] because + /// Dart has no nominal union type. Forwarded as-is to the inner [MediaCard]. + final Object item; final double? width; final double? height; - final void Function(String ratingKey)? onRefresh; + final void Function(String itemId)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onListRefresh; final bool forceGridMode; diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index db0e7462..0538e147 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -14,7 +14,7 @@ import '../services/settings_service.dart' show EpisodePosterMode; import '../utils/grid_size_calculator.dart'; import '../theme/mono_tokens.dart'; import '../focus/locked_hub_controller.dart'; -import '../models/plex_hub.dart'; +import '../media/media_hub.dart'; import '../screens/hub_detail_screen.dart'; import '../utils/media_navigation_helper.dart'; import 'focus_builders.dart'; @@ -32,7 +32,7 @@ import '../i18n/strings.g.dart'; /// - Children render focus visuals based on the passed index /// - Focus never "escapes" to random elements class HubSection extends StatefulWidget { - final PlexHub hub; + final MediaHub hub; final IconData icon; final void Function(String)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; @@ -97,7 +97,7 @@ class HubSectionState extends State { @override void initState() { super.initState(); - _hubFocusNode = FocusNode(debugLabel: 'hub_${widget.hub.hubKey}'); + _hubFocusNode = FocusNode(debugLabel: 'hub_${widget.hub.id}'); _hubFocusNode.addListener(_onFocusChange); } @@ -143,7 +143,7 @@ class HubSectionState extends State { final clamped = index.clamp(0, _totalItemCount - 1); _focusedIndex = clamped; // Remember this position for this specific hub - HubFocusMemory.setForHub(widget.hub.hubKey, clamped); + HubFocusMemory.setForHub(widget.hub.id, clamped); _scrollToIndex(clamped); _hubFocusNode.requestFocus(); // ignore: no-empty-block - setState triggers rebuild to update focus styling @@ -155,7 +155,7 @@ class HubSectionState extends State { /// Request focus using the stored memory for this hub void requestFocusFromMemory() { - final index = HubFocusMemory.getForHub(widget.hub.hubKey, _totalItemCount); + final index = HubFocusMemory.getForHub(widget.hub.id, _totalItemCount); requestFocusAt(index); } @@ -244,7 +244,7 @@ class HubSectionState extends State { setState(() { _focusedIndex--; }); - HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); + HubFocusMemory.setForHub(widget.hub.id, _focusedIndex); _scrollToIndex(_focusedIndex); } else if (widget.onNavigateToSidebar != null) { // At leftmost item: navigate to sidebar @@ -260,7 +260,7 @@ class HubSectionState extends State { setState(() { _focusedIndex++; }); - HubFocusMemory.setForHub(widget.hub.hubKey, _focusedIndex); + HubFocusMemory.setForHub(widget.hub.id, _focusedIndex); _scrollToIndex(_focusedIndex); } return KeyEventResult.handled; @@ -530,7 +530,7 @@ class HubSectionState extends State { setState(() { _focusedIndex = index; }); - HubFocusMemory.setForHub(widget.hub.hubKey, index); + HubFocusMemory.setForHub(widget.hub.id, index); _hubFocusNode.requestFocus(); } } diff --git a/lib/widgets/loading_indicator_box.dart b/lib/widgets/loading_indicator_box.dart new file mode 100644 index 00000000..94eb63a0 --- /dev/null +++ b/lib/widgets/loading_indicator_box.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// Square `CircularProgressIndicator(strokeWidth: 2)` sized to fit inside a +/// button or status row. The default 18×18 matches the +/// [FilledButton.icon] / [OutlinedButton.icon] icon slot; bump [size] for +/// list-row or app-bar contexts. +/// +/// Centralises the `SizedBox(width:.., height:.., child: +/// CircularProgressIndicator(strokeWidth: 2))` pattern that previously +/// appeared inline in every async button. +class LoadingIndicatorBox extends StatelessWidget { + final double size; + const LoadingIndicatorBox({super.key, this.size = 18}); + + @override + Widget build(BuildContext context) => + SizedBox(width: size, height: size, child: const CircularProgressIndicator(strokeWidth: 2)); +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index d1610e74..48deb628 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,19 +1,21 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:plezy/utils/content_utils.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/input_mode_tracker.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_kind.dart'; +import '../media/media_playlist.dart'; import '../mixins/context_menu_tap_mixin.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_playlist.dart'; import '../providers/download_provider.dart'; import '../services/download_storage_service.dart'; import '../providers/settings_provider.dart'; import '../screens/media_detail_screen.dart'; import '../services/settings_service.dart'; +import '../utils/content_utils.dart'; import '../utils/provider_extensions.dart'; import '../utils/formatters.dart'; import '../utils/media_navigation_helper.dart'; @@ -22,13 +24,15 @@ import '../theme/mono_tokens.dart'; import '../i18n/strings.g.dart'; import 'media_context_menu.dart'; import 'media_progress_bar.dart'; -import 'plex_optimized_image.dart'; +import 'optimized_media_image.dart'; class MediaCard extends StatefulWidget { - final dynamic item; // Can be PlexMetadata or PlexPlaylist + /// Either a [MediaItem] or a [MediaPlaylist]. Typed as [Object] because Dart + /// has no nominal union type — runtime `is` checks select the variant. + final Object item; final double? width; final double? height; - final void Function(String ratingKey)? onRefresh; + final void Function(String itemId)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onListRefresh; // Callback to refresh the entire parent list final bool forceGridMode; @@ -69,27 +73,26 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin 0 && item.viewOffset! < item.duration!; + item.viewOffsetMs != null && + item.durationMs != null && + item.viewOffsetMs! > 0 && + item.viewOffsetMs! < item.durationMs!; if (hasActiveProgress) { - final percent = ((item.viewOffset! / item.duration!) * 100).round(); + final percent = ((item.viewOffsetMs! / item.durationMs!) * 100).round(); baseLabel = '$baseLabel, ${t.accessibility.mediaCardPartiallyWatched(percent: percent)}'; } else if (item.isWatched) { baseLabel = '$baseLabel, ${t.accessibility.mediaCardWatched}'; @@ -142,17 +148,17 @@ class MediaCardState extends State with ContextMenuTapMixin(); - final globalKey = metadata.globalKey; + final globalKey = item.globalKey; // Get artwork reference and resolve to local path using hash (includes serverId) final artwork = downloadProvider.getArtworkPaths(globalKey); - return artwork?.getLocalPath(DownloadStorageService.instance, metadata.serverId!); + return artwork?.getLocalPath(DownloadStorageService.instance, item.serverId!); } @override @@ -244,7 +250,7 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin with ContextMenuTapMixin((s) => s.episodePosterMode); - if ((item as PlexMetadata).usesWideAspectRatio(mode)) { + if ((item as MediaItem).usesWideAspectRatio(mode)) { return base * 1.6; // Wider for 16:9 thumbnails } } @@ -341,9 +348,9 @@ class _MediaCardList extends StatelessWidget { double _posterHeight(BuildContext context) { final base = _basePosterWidth(); // For episodes with thumbnail mode, use 16:9 aspect ratio - if (item is PlexMetadata) { + if (item is MediaItem) { final mode = context.select((s) => s.episodePosterMode); - if ((item as PlexMetadata).usesWideAspectRatio(mode)) { + if ((item as MediaItem).usesWideAspectRatio(mode)) { // 16:9: height = width * 9/16 = base * 1.6 * 9/16 = base * 0.9 return base * 0.9; } @@ -367,64 +374,64 @@ class _MediaCardList extends StatelessWidget { String _buildMetadataLine() { final parts = []; - if (item is PlexPlaylist) { - final playlist = item as PlexPlaylist; + if (item is MediaPlaylist) { + final playlist = item as MediaPlaylist; // Add item count if (playlist.leafCount != null && playlist.leafCount! > 0) { parts.add(t.playlists.itemCount(count: playlist.leafCount!)); } // Add duration - if (playlist.duration != null) { - parts.add(formatDurationTextual(playlist.duration!)); + if (playlist.durationMs != null) { + parts.add(formatDurationTextual(playlist.durationMs!)); } // Add smart playlist badge if (playlist.smart) { parts.add(t.playlists.smartPlaylist); } - } else if (item is PlexMetadata) { - final metadata = item as PlexMetadata; + } else if (item is MediaItem) { + final mi = item as MediaItem; // For collections, show item count - if (metadata.mediaType == PlexMediaType.collection) { - final count = metadata.childCount ?? metadata.leafCount; + if (mi.kind == MediaKind.collection) { + final count = mi.childCount ?? mi.leafCount; if (count != null && count > 0) { parts.add(t.playlists.itemCount(count: count)); } } else { // For other media types, show standard metadata // Add content rating - if (metadata.contentRating != null && metadata.contentRating!.isNotEmpty) { - final rating = formatContentRating(metadata.contentRating); + if (mi.contentRating != null && mi.contentRating!.isNotEmpty) { + final rating = formatContentRating(mi.contentRating); if (rating.isNotEmpty) { parts.add(rating); } } // Add year - if (metadata.year != null) { - parts.add('${metadata.year}'); + if (mi.year != null) { + parts.add('${mi.year}'); } - // Add edition title - if (metadata.editionTitle != null) { - parts.add(metadata.editionTitle!); + // Add edition title (Plex-only field; null on other backends) + if (mi.editionTitle case final editionTitle?) { + parts.add(editionTitle); } // Add duration - if (metadata.duration != null) { - parts.add(formatDurationTextual(metadata.duration!)); + if (mi.durationMs != null) { + parts.add(formatDurationTextual(mi.durationMs!)); } // Add user rating - if (metadata.rating != null) { - parts.add('${metadata.rating!.toStringAsFixed(1)}★'); + if (mi.rating != null) { + parts.add('${mi.rating!.toStringAsFixed(1)}★'); } // Add studio - if (metadata.studio != null && metadata.studio!.isNotEmpty) { - parts.add(metadata.studio!); + if (mi.studio != null && mi.studio!.isNotEmpty) { + parts.add(mi.studio!); } } } @@ -433,23 +440,23 @@ class _MediaCardList extends StatelessWidget { } String? _buildSubtitleText(BuildContext context) { - if (item is PlexPlaylist) { + if (item is MediaPlaylist) { // Playlists don't have subtitles return null; - } else if (item is PlexMetadata) { - final metadata = item as PlexMetadata; + } else if (item is MediaItem) { + final mi = item as MediaItem; // For TV episodes, show S# (optionally with E#) - if (metadata.parentIndex != null && metadata.index != null) { + if (mi.parentIndex != null && mi.index != null) { final showEp = context.select((p) => p.showEpisodeNumberOnCards); - return showEp ? 'S${metadata.parentIndex} E${metadata.index}' : 'S${metadata.parentIndex}'; + return showEp ? 'S${mi.parentIndex} E${mi.index}' : 'S${mi.parentIndex}'; } // Otherwise use existing subtitle logic - if (metadata.displaySubtitle != null) { - return metadata.displaySubtitle; - } else if (metadata.parentTitle != null) { - return metadata.parentTitle; + if (mi.displaySubtitle != null) { + return mi.displaySubtitle; + } else if (mi.parentTitle != null) { + return mi.parentTitle; } } @@ -457,20 +464,34 @@ class _MediaCardList extends StatelessWidget { return null; } - Widget _buildEpisodeSubtitle(BuildContext context, PlexMetadata metadata) { + String? _summary() { + final it = item; + if (it is MediaItem) return it.summary; + if (it is MediaPlaylist) return it.summary; + return null; + } + + String _displayTitle() { + final it = item; + if (it is MediaItem) return it.displayTitle; + if (it is MediaPlaylist) return it.displayTitle; + return ''; + } + + Widget _buildEpisodeSubtitle(BuildContext context, MediaItem mi) { final style = Theme.of(context).textTheme.bodySmall?.copyWith( color: tokens(context).textMuted.withValues(alpha: 0.85), fontSize: _subtitleFontSize, ); - final episodeTitle = metadata.displaySubtitle ?? metadata.displayTitle; + final episodeTitle = mi.displaySubtitle ?? mi.displayTitle; final showEp = context.select((p) => p.showEpisodeNumberOnCards); - final episodeNum = (showEp && metadata.index != null) ? ' E${metadata.index}' : ''; + final episodeNum = (showEp && mi.index != null) ? ' E${mi.index}' : ''; return Row( children: [ _ClickableText( - text: 'S${metadata.parentIndex}', + text: 'S${mi.parentIndex}', style: style, - onTap: () => _navigateToSeason(context, metadata, isOffline: isOffline), + onTap: () => _navigateToSeason(context, mi, isOffline: isOffline), ), Text('$episodeNum · ', style: style), Expanded( @@ -508,7 +529,7 @@ class _MediaCardList extends StatelessWidget { borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: _buildPosterImage(context, item, isOffline: isOffline, localPosterPath: localPosterPath), ), - if (item is PlexMetadata) _MediaCardHelpers.buildWatchProgress(context, item as PlexMetadata), + if (item is MediaItem) _MediaCardHelpers.buildWatchProgress(context, item as MediaItem), ], ), ), @@ -520,15 +541,15 @@ class _MediaCardList extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ // Title - if (item is PlexMetadata && _hasClickableTitle(item as PlexMetadata)) + if (item is MediaItem && _hasClickableTitle(item as MediaItem)) _ClickableText( - text: item.displayTitle, + text: (item as MediaItem).displayTitle, style: TextStyle(fontWeight: FontWeight.w600, fontSize: _titleFontSize, height: 1.2), - onTap: () => _navigateToDetail(context, item as PlexMetadata, isOffline: isOffline), + onTap: () => _navigateToDetail(context, item as MediaItem, isOffline: isOffline), ) else Text( - item.displayTitle, + _displayTitle(), maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.w600, fontSize: _titleFontSize, height: 1.2), @@ -549,11 +570,11 @@ class _MediaCardList extends StatelessWidget { const SizedBox(height: 2), ], // Subtitle (S# · Episode Title, or year/parent title) - if (item is PlexMetadata && - (item as PlexMetadata).isEpisode && - (item as PlexMetadata).parentIndex != null && - (item as PlexMetadata).parentRatingKey != null) ...[ - _buildEpisodeSubtitle(context, item as PlexMetadata), + if (item is MediaItem && + (item as MediaItem).isEpisode && + (item as MediaItem).parentIndex != null && + (item as MediaItem).parentId != null) ...[ + _buildEpisodeSubtitle(context, item as MediaItem), const SizedBox(height: 4), ] else if (subtitle != null) ...[ Text( @@ -568,12 +589,12 @@ class _MediaCardList extends StatelessWidget { const SizedBox(height: 4), ], // Summary (hidden when spoiler protection is active) - if (!(item is PlexMetadata && + if (!(item is MediaItem && context.select((s) => s.hideSpoilers) && - (item as PlexMetadata).shouldHideSpoiler) && - item.summary != null) ...[ + (item as MediaItem).shouldHideSpoiler) && + _summary() != null) ...[ Text( - item.summary!, + _summary()!, maxLines: _summaryMaxLines, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -584,7 +605,7 @@ class _MediaCardList extends StatelessWidget { ), ], // Server name (multi-server mode) - if (showServerName && item is PlexMetadata && (item as PlexMetadata).serverName != null) ...[ + if (showServerName && item is MediaItem && (item as MediaItem).serverName != null) ...[ const SizedBox(height: 4), Row( children: [ @@ -597,7 +618,7 @@ class _MediaCardList extends StatelessWidget { const SizedBox(width: 4), Flexible( child: Text( - (item as PlexMetadata).serverName!, + (item as MediaItem).serverName!, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -621,7 +642,7 @@ class _MediaCardList extends StatelessWidget { Widget _buildPosterImage( BuildContext context, - dynamic item, { + Object item, { bool isOffline = false, String? localPosterPath, bool mixedHubContext = false, @@ -631,19 +652,19 @@ Widget _buildPosterImage( String? posterUrl; IconData fallbackIcon = Symbols.movie_rounded; - if (item is PlexPlaylist) { - posterUrl = item.displayImage; + if (item is MediaPlaylist) { + posterUrl = item.displayImagePath; fallbackIcon = Symbols.playlist_play_rounded; - return PlexOptimizedImage.playlist( - client: isOffline ? null : context.getClientWithFallback(item.serverId), + return OptimizedMediaImage.playlist( + client: isOffline ? null : context.tryGetMediaClientWithFallback(item.serverId), imagePath: posterUrl, width: knownWidth ?? double.infinity, height: knownHeight ?? double.infinity, fit: BoxFit.cover, localFilePath: localPosterPath, ); - } else if (item is PlexMetadata) { + } else if (item is MediaItem) { final episodePosterMode = context.select((s) => s.episodePosterMode); final hideSpoilers = context.select((s) => s.hideSpoilers); final shouldBlur = @@ -654,8 +675,8 @@ Widget _buildPosterImage( // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) { - image = PlexOptimizedImage.thumb( - client: isOffline ? null : context.getClientWithFallback(item.serverId), + image = OptimizedMediaImage.thumb( + client: isOffline ? null : context.tryGetMediaClientWithFallback(item.serverId), imagePath: posterUrl, width: knownWidth ?? double.infinity, height: knownHeight ?? double.infinity, @@ -663,8 +684,8 @@ Widget _buildPosterImage( localFilePath: localPosterPath, ); } else { - image = PlexOptimizedImage.poster( - client: isOffline ? null : context.getClientWithFallback(item.serverId), + image = OptimizedMediaImage.poster( + client: isOffline ? null : context.tryGetMediaClientWithFallback(item.serverId), imagePath: posterUrl, width: knownWidth ?? double.infinity, height: knownHeight ?? double.infinity, @@ -689,7 +710,7 @@ Widget _buildPosterImage( /// Helper methods for building media card metadata and subtitles class _MediaCardHelpers { /// Builds playlist metadata (item count) - static Widget buildPlaylistMeta(BuildContext context, PlexPlaylist playlist) { + static Widget buildPlaylistMeta(BuildContext context, MediaPlaylist playlist) { if (playlist.leafCount != null && playlist.leafCount! > 0) { return Text( t.playlists.itemCount(count: playlist.leafCount!), @@ -704,14 +725,14 @@ class _MediaCardHelpers { } /// Builds metadata subtitle (for collections, episodes, movies, shows) - static Widget buildMetadataSubtitle(BuildContext context, PlexMetadata metadata, {bool isOffline = false}) { + static Widget buildMetadataSubtitle(BuildContext context, MediaItem mi, {bool isOffline = false}) { final subtitleStyle = Theme.of( context, ).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 11, height: 1.1); // For collections, show item count - if (metadata.mediaType == PlexMediaType.collection) { - final count = metadata.childCount ?? metadata.leafCount; + if (mi.kind == MediaKind.collection) { + final count = mi.childCount ?? mi.leafCount; if (count != null && count > 0) { return Text( t.playlists.itemCount(count: count), @@ -723,17 +744,17 @@ class _MediaCardHelpers { } // For episodes, show "S# · Episode Title" with clickable season link - if (metadata.isEpisode && metadata.parentIndex != null) { - final episodeTitle = metadata.displaySubtitle ?? metadata.displayTitle; + if (mi.isEpisode && mi.parentIndex != null) { + final episodeTitle = mi.displaySubtitle ?? mi.displayTitle; final showEp = context.select((p) => p.showEpisodeNumberOnCards); - final episodeSuffix = (showEp && metadata.index != null) ? ' E${metadata.index}' : ''; - if (metadata.parentRatingKey != null) { + final episodeSuffix = (showEp && mi.index != null) ? ' E${mi.index}' : ''; + if (mi.parentId != null) { return Row( children: [ _ClickableText( - text: 'S${metadata.parentIndex}', + text: 'S${mi.parentIndex}', style: subtitleStyle, - onTap: () => _navigateToSeason(context, metadata, isOffline: isOffline), + onTap: () => _navigateToSeason(context, mi, isOffline: isOffline), ), Text('$episodeSuffix · ', style: subtitleStyle), Expanded( @@ -743,7 +764,7 @@ class _MediaCardHelpers { ); } return Text( - 'S${metadata.parentIndex}$episodeSuffix · $episodeTitle', + 'S${mi.parentIndex}$episodeSuffix · $episodeTitle', maxLines: 1, overflow: TextOverflow.ellipsis, style: subtitleStyle, @@ -751,13 +772,14 @@ class _MediaCardHelpers { } // For other media types, show subtitle/parent/year - if (metadata.displaySubtitle != null) { - return Text(metadata.displaySubtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: subtitleStyle); - } else if (metadata.parentTitle != null) { - return Text(metadata.parentTitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: subtitleStyle); - } else if (metadata.year != null) { + if (mi.displaySubtitle != null) { + return Text(mi.displaySubtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: subtitleStyle); + } else if (mi.parentTitle != null) { + return Text(mi.parentTitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: subtitleStyle); + } else if (mi.year != null) { + final edition = mi.editionTitle; return Text( - metadata.editionTitle != null ? '${metadata.year} · ${metadata.editionTitle}' : '${metadata.year}', + edition != null ? '${mi.year} · $edition' : '${mi.year}', maxLines: 1, overflow: TextOverflow.ellipsis, style: subtitleStyle, @@ -768,19 +790,16 @@ class _MediaCardHelpers { } /// Builds watch progress overlay (checkmark for watched, progress bar for in-progress) - static Widget buildWatchProgress(BuildContext context, PlexMetadata metadata) { + static Widget buildWatchProgress(BuildContext context, MediaItem mi) { final showUnwatchedCount = context.select((s) => s.showUnwatchedCount); final hasActiveProgress = - metadata.viewOffset != null && - metadata.duration != null && - metadata.viewOffset! > 0 && - metadata.viewOffset! < metadata.duration!; + mi.viewOffsetMs != null && mi.durationMs != null && mi.viewOffsetMs! > 0 && mi.viewOffsetMs! < mi.durationMs!; return Stack( children: [ // Watched indicator (checkmark) - if (metadata.isWatched && !hasActiveProgress) + if (mi.isWatched && !hasActiveProgress) Positioned( top: 4, right: 4, @@ -795,9 +814,9 @@ class _MediaCardHelpers { ), ), if (showUnwatchedCount && - !metadata.isWatched && - (metadata.mediaType == PlexMediaType.show || metadata.mediaType == PlexMediaType.season) && - (metadata.leafCount != null && metadata.leafCount! > 0 && metadata.viewedLeafCount != null)) + !mi.isWatched && + (mi.kind == MediaKind.show || mi.kind == MediaKind.season) && + (mi.leafCount != null && mi.leafCount! > 0 && mi.viewedLeafCount != null)) Positioned( top: 4, right: 4, @@ -811,7 +830,7 @@ class _MediaCardHelpers { ), alignment: Alignment.center, child: Text( - '${metadata.leafCount! - metadata.viewedLeafCount!}', + '${mi.leafCount! - mi.viewedLeafCount!}', style: TextStyle(color: tokens(context).bg, fontSize: 12, fontWeight: FontWeight.bold), ), ), @@ -824,11 +843,11 @@ class _MediaCardHelpers { right: 0, child: ClipRRect( borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8)), - child: MediaProgressBar(viewOffset: metadata.viewOffset!, duration: metadata.duration!), + child: MediaProgressBar(viewOffset: mi.viewOffsetMs!, duration: mi.durationMs!), ), ), // Progress bar for seasons (viewedLeafCount / leafCount) - if (metadata.isSeason && metadata.isPartiallyWatched) + if (mi.isSeason && mi.isPartiallyWatched) Positioned( bottom: 0, left: 0, @@ -836,7 +855,7 @@ class _MediaCardHelpers { child: ClipRRect( borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8)), child: LinearProgressIndicator( - value: metadata.viewedLeafCount! / metadata.leafCount!, + value: mi.viewedLeafCount! / mi.leafCount!, backgroundColor: tokens(context).outline, valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.primary), minHeight: 4, @@ -848,26 +867,26 @@ class _MediaCardHelpers { } } -/// Whether this metadata item has a clickable title that navigates somewhere. +/// Whether this media item has a clickable title that navigates somewhere. /// Episodes/seasons navigate to their parent show; movies navigate to their detail page. -bool _hasClickableTitle(PlexMetadata metadata) { - if (metadata.isEpisode) return metadata.grandparentRatingKey != null; - if (metadata.isSeason) return metadata.parentRatingKey != null; - if (metadata.isMovie) return true; +bool _hasClickableTitle(MediaItem mi) { + if (mi.isEpisode) return mi.grandparentId != null; + if (mi.isSeason) return mi.parentId != null; + if (mi.isMovie) return true; return false; } /// Navigate to a show with the season tab pre-selected from episode metadata -void _navigateToSeason(BuildContext context, PlexMetadata episode, {bool isOffline = false}) { - if (episode.grandparentRatingKey != null) { +void _navigateToSeason(BuildContext context, MediaItem episode, {bool isOffline = false}) { + if (episode.grandparentId != null) { // Navigate to the show with the season pre-selected - final showStub = PlexMetadata( - ratingKey: episode.grandparentRatingKey!, - key: '/library/metadata/${episode.grandparentRatingKey}', - type: 'show', + final showStub = MediaItem( + id: episode.grandparentId!, + backend: episode.backend, + kind: MediaKind.show, title: episode.grandparentTitle ?? episode.displayTitle, - thumb: episode.grandparentThumb, - art: episode.grandparentArt, + thumbPath: episode.grandparentThumbPath, + artPath: episode.grandparentArtPath, serverId: episode.serverId, serverName: episode.serverName, ); @@ -878,16 +897,16 @@ void _navigateToSeason(BuildContext context, PlexMetadata episode, {bool isOffli MediaDetailScreen(metadata: showStub, isOffline: isOffline, initialSeasonIndex: episode.parentIndex), ), ); - } else if (episode.parentRatingKey != null) { + } else if (episode.parentId != null) { // Fallback: navigate to season directly if no grandparent - final seasonStub = PlexMetadata( - ratingKey: episode.parentRatingKey!, - key: '/library/metadata/${episode.parentRatingKey}', - type: 'season', + final seasonStub = MediaItem( + id: episode.parentId!, + backend: episode.backend, + kind: MediaKind.season, title: episode.parentTitle ?? 'Season ${episode.parentIndex ?? ''}', index: episode.parentIndex, - parentRatingKey: episode.grandparentRatingKey, - thumb: episode.parentThumb, + parentId: episode.grandparentId, + thumbPath: episode.parentThumbPath, serverId: episode.serverId, serverName: episode.serverName, ); @@ -900,35 +919,35 @@ void _navigateToSeason(BuildContext context, PlexMetadata episode, {bool isOffli } } -/// Navigate to the detail screen for a metadata item. +/// Navigate to the detail screen for a media item. /// For episodes/seasons: navigates to the parent show with season pre-selected. /// For movies and other types: navigates to the item's own detail page. -void _navigateToDetail(BuildContext context, PlexMetadata metadata, {bool isOffline = false}) { - PlexMetadata target = metadata; +void _navigateToDetail(BuildContext context, MediaItem mi, {bool isOffline = false}) { + MediaItem target = mi; int? initialSeasonIndex; - if (metadata.isEpisode && metadata.grandparentRatingKey != null) { - target = PlexMetadata( - ratingKey: metadata.grandparentRatingKey!, - key: '/library/metadata/${metadata.grandparentRatingKey}', - type: 'show', - title: metadata.grandparentTitle ?? metadata.displayTitle, - thumb: metadata.grandparentThumb, - art: metadata.grandparentArt, - serverId: metadata.serverId, - serverName: metadata.serverName, + if (mi.isEpisode && mi.grandparentId != null) { + target = MediaItem( + id: mi.grandparentId!, + backend: mi.backend, + kind: MediaKind.show, + title: mi.grandparentTitle ?? mi.displayTitle, + thumbPath: mi.grandparentThumbPath, + artPath: mi.grandparentArtPath, + serverId: mi.serverId, + serverName: mi.serverName, ); - } else if (metadata.isSeason && metadata.parentRatingKey != null) { - initialSeasonIndex = metadata.index; - target = PlexMetadata( - ratingKey: metadata.parentRatingKey!, - key: '/library/metadata/${metadata.parentRatingKey}', - type: 'show', - title: metadata.grandparentTitle ?? metadata.parentTitle ?? metadata.displayTitle, - thumb: metadata.grandparentThumb ?? metadata.parentThumb, - art: metadata.grandparentArt, - serverId: metadata.serverId, - serverName: metadata.serverName, + } else if (mi.isSeason && mi.parentId != null) { + initialSeasonIndex = mi.index; + target = MediaItem( + id: mi.parentId!, + backend: mi.backend, + kind: MediaKind.show, + title: mi.grandparentTitle ?? mi.parentTitle ?? mi.displayTitle, + thumbPath: mi.grandparentThumbPath ?? mi.parentThumbPath, + artPath: mi.grandparentArtPath, + serverId: mi.serverId, + serverName: mi.serverName, ); } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index a769d756..91ddf6b9 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -4,20 +4,27 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../exceptions/media_server_exceptions.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_playlist.dart'; +import '../media/media_server_client.dart'; +import '../media/media_version.dart'; import '../services/plex_client.dart'; -import '../services/play_queue_launcher.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_playlist.dart'; +import '../services/media_list_playback_launcher.dart'; +import '../services/playlist_items_loader.dart'; +import '../models/transcode_quality_preset.dart'; import '../utils/download_version_utils.dart'; import '../utils/download_utils.dart'; import '../utils/quality_preset_labels.dart'; -import '../utils/content_utils.dart'; import '../utils/global_key_utils.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/offline_mode_provider.dart'; import '../providers/offline_watch_provider.dart'; -import '../providers/user_profile_provider.dart'; +import '../profiles/active_profile_provider.dart'; +import '../profiles/profile.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/library_refresh_notifier.dart'; @@ -28,9 +35,9 @@ import '../utils/focus_utils.dart'; import '../services/external_player_service.dart'; import '../focus/focusable_button.dart'; import '../focus/dpad_navigator.dart'; -import '../screens/match_screen.dart'; +import '../screens/plex_match_screen.dart'; import '../screens/media_detail_screen.dart'; -import '../screens/metadata_edit_screen.dart'; +import '../screens/plex_metadata_edit_screen.dart'; import '../utils/smart_deletion_handler.dart'; import '../utils/video_player_navigation.dart'; import '../utils/deletion_notifier.dart'; @@ -53,11 +60,24 @@ class _MenuAction { _MenuAction({required this.value, required this.icon, required this.label, this.hoverColor, this.foregroundColor}); } +bool isAdminActionAllowedForMediaItem({ + required bool isOwnerOrAdmin, + required MediaBackend? itemBackend, + required Profile? activeProfile, +}) { + final blockedByPlexHomeRole = + itemBackend == MediaBackend.plex && activeProfile != null && activeProfile.isPlexHome && !activeProfile.plexAdmin; + return isOwnerOrAdmin && !blockedByPlexHomeRole; +} + /// A reusable wrapper widget that adds a context menu (long press / right click) /// to any media item with appropriate actions based on the item type. class MediaContextMenu extends StatefulWidget { - final dynamic item; // Can be PlexMetadata or PlexPlaylist - final void Function(String ratingKey)? onRefresh; + /// Either a [MediaItem] or a [MediaPlaylist]. Typed as [Object] because + /// Dart has no nominal union type — guarded at runtime via the + /// [_itemAsMediaItem] / [_itemAsPlaylist] helpers. + final Object item; + final void Function(String itemId)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onListRefresh; // For refreshing list after deletion final VoidCallback? onTap; @@ -89,6 +109,12 @@ class MediaContextMenuState extends State { bool get isContextMenuOpen => _isContextMenuOpen; + /// The widget's [item] cast as a [MediaItem]. Returns `null` for playlists. + MediaItem? get _mediaItem => widget.item is MediaItem ? widget.item as MediaItem : null; + + /// The widget's [item] cast as a [MediaPlaylist]. Returns `null` for media items. + MediaPlaylist? get _playlist => widget.item is MediaPlaylist ? widget.item as MediaPlaylist : null; + /// Show the context menu programmatically. /// Used for keyboard/gamepad long-press activation. /// If [position] is null, the menu will appear at the center of this widget. @@ -108,15 +134,29 @@ class MediaContextMenuState extends State { _showContextMenu(menuContext); } - /// Get the serverId from the item (PlexMetadata or PlexPlaylist) - String? get _itemServerId { - if (widget.item is PlexMetadata) return (widget.item as PlexMetadata).serverId; - if (widget.item is PlexPlaylist) return (widget.item as PlexPlaylist).serverId; - return null; - } + /// Get the serverId from the typed item. + String? get _itemServerId => switch (widget.item) { + MediaItem(:final serverId) => serverId, + MediaPlaylist(:final serverId) => serverId, + _ => null, + }; - /// Get the correct PlexClient for this item's server - PlexClient _getClientForItem() => context.getClientWithFallback(_itemServerId); + /// Item identifier for refresh callbacks. + String _itemId() => switch (widget.item) { + MediaItem(:final id) => id, + MediaPlaylist(:final id) => id, + _ => '', + }; + + /// Get the correct PlexClient for this item's server. Throws on + /// non-Plex backends — Plex-only flows (Add to Collection, metadata + /// edit, etc.) call this directly. Backend-neutral flows must use + /// [_getMediaClientForItem] instead. + PlexClient _getClientForItem() => context.getPlexClientWithFallback(_itemServerId); + + /// Backend-neutral client for the active item's server. Used by flows + /// that work for Jellyfin too (downloads, basic browse). + MediaServerClient _getMediaClientForItem() => context.getMediaClientWithFallback(_itemServerId); void _showContextMenu(BuildContext context) async { if (_isContextMenuOpen) return; @@ -126,26 +166,48 @@ class MediaContextMenuState extends State { final previousFocus = FocusManager.instance.primaryFocus; bool didNavigate = false; - final isPlaylist = widget.item is PlexPlaylist; - final metadata = isPlaylist ? null : widget.item as PlexMetadata; - final mediaType = isPlaylist ? null : metadata!.mediaType; - final isCollection = mediaType == PlexMediaType.collection; + final mediaItem = _mediaItem; + final playlist = _playlist; + final isPlaylist = playlist != null; + final mediaKind = mediaItem?.kind; + final isCollection = mediaKind == MediaKind.collection; - final isPartiallyWatched = !isPlaylist && metadata!.isPartiallyWatched; + // Backend-aware gate: a few menu items remain Plex-only because the + // server-side feature has no Jellyfin equivalent (metadata edit, match). + // 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. + final itemBackend = mediaItem?.backend ?? playlist?.backend; + final isPlex = itemBackend == MediaBackend.plex; + + final isPartiallyWatched = mediaItem?.isPartiallyWatched ?? false; final hasActiveProgress = - mediaType != null && - (mediaType == PlexMediaType.movie || mediaType == PlexMediaType.episode) && - metadata?.hasActiveProgress == true; + mediaKind != null && + (mediaKind == MediaKind.movie || mediaKind == MediaKind.episode) && + mediaItem?.hasActiveProgress == true; // Check if we should use bottom sheet (on iOS and Android) final useBottomSheet = Platform.isIOS || Platform.isAndroid; - // Check if user has admin privileges (server owned + admin or single user) + // 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` + // captured at sign-in. final multiServerProvider = Provider.of(context, listen: false); - final server = _itemServerId != null ? multiServerProvider.serverManager.getServer(_itemServerId!) : null; - final currentUser = context.read().currentUser; - final isAdmin = server?.owned == true && (currentUser == null || currentUser.admin); + final activeProfile = context.read().active; + final isOwnerOrAdmin = _itemServerId != null && multiServerProvider.serverManager.isOwnerOrAdmin(_itemServerId!); + final isAdmin = isAdminActionAllowedForMediaItem( + isOwnerOrAdmin: isOwnerOrAdmin, + itemBackend: itemBackend, + activeProfile: activeProfile, + ); + + // Backend capabilities — used to gate the "Play Version" item below. + // Reads the same `capabilities.videoTranscoding` flag the in-player + // sheet uses so the two surfaces never disagree about what's offered. + final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(_itemServerId!) : null; + final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false; // Build menu actions final menuActions = <_MenuAction>[]; @@ -160,9 +222,9 @@ class MediaContextMenuState extends State { // Download + sync-rule management. Video playlists and any collection // qualify — collections can contain movies, episodes, and shows. - final isVideoPlaylist = isPlaylist && (widget.item as PlexPlaylist).playlistType == 'video'; + final isVideoPlaylist = isPlaylist && playlist.playlistType == 'video'; if ((isVideoPlaylist || isCollection) && !PlatformDetector.isAppleTV()) { - final hasRule = Provider.of(context, listen: false).hasSyncRule(_itemGlobalKey()); + final hasRule = Provider.of(context, listen: false).hasSyncRule(_itemSyncRuleKey(context)); if (hasRule) { menuActions.add( _MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule), @@ -196,14 +258,14 @@ class MediaContextMenuState extends State { } // Mark as Watched - if (!metadata!.isWatched || isPartiallyWatched || hasActiveProgress) { + if (!mediaItem!.isWatched || isPartiallyWatched || hasActiveProgress) { menuActions.add( _MenuAction(value: 'watch', icon: Symbols.check_circle_outline_rounded, label: t.mediaMenu.markAsWatched), ); } // Mark as Unwatched - if (metadata.isWatched || isPartiallyWatched || hasActiveProgress) { + if (mediaItem.isWatched || isPartiallyWatched || hasActiveProgress) { menuActions.add( _MenuAction( value: 'unwatch', @@ -225,39 +287,46 @@ class MediaContextMenuState extends State { } // Rate (for movies, shows, seasons, and episodes) - if (mediaType == PlexMediaType.movie || - mediaType == PlexMediaType.show || - mediaType == PlexMediaType.season || - mediaType == PlexMediaType.episode) { + if (mediaKind == MediaKind.movie || + mediaKind == MediaKind.show || + mediaKind == MediaKind.season || + mediaKind == MediaKind.episode) { menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate)); } // Edit Metadata (for movies, shows, seasons, and episodes) — admin only - if (isAdmin && - (mediaType == PlexMediaType.movie || - mediaType == PlexMediaType.show || - mediaType == PlexMediaType.season || - mediaType == PlexMediaType.episode)) { + // Plex-only: opens PlexMetadataEditScreen which talks to Plex's + // `/library/metadata/{id}` PUT API; Jellyfin has no equivalent in v1. + if (isPlex && + isAdmin && + (mediaKind == MediaKind.movie || + mediaKind == MediaKind.show || + mediaKind == MediaKind.season || + mediaKind == MediaKind.episode)) { menuActions.add( _MenuAction(value: 'edit_metadata', icon: Symbols.edit_rounded, label: t.metadataEdit.editMetadata), ); } - if (isAdmin && (mediaType == PlexMediaType.movie || mediaType == PlexMediaType.show)) { + // Match / Unmatch — Plex-only (Jellyfin doesn't expose match agents). + if (isPlex && isAdmin && (mediaKind == MediaKind.movie || mediaKind == MediaKind.show)) { + final isUnmatched = _isUnmatched(mediaItem); menuActions.add( _MenuAction( value: 'match', icon: Symbols.search_rounded, - label: metadata.isUnmatched ? t.matchScreen.match : t.matchScreen.fixMatch, + label: isUnmatched ? t.matchScreen.match : t.matchScreen.fixMatch, ), ); - if (!metadata.isUnmatched) { + if (!isUnmatched) { menuActions.add(_MenuAction(value: 'unmatch', icon: Symbols.link_off_rounded, label: t.matchScreen.unmatch)); } } - // Remove from Collection (only when viewing items within a collection) - if (widget.collectionId != null) { + // 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. + if (isPlex && widget.collectionId != null) { menuActions.add( _MenuAction( value: 'remove_from_collection', @@ -270,52 +339,60 @@ class MediaContextMenuState extends State { // Go to Series (for episodes and seasons) — hide if already on that series' detail screen final ancestorMediaDetail = context.findAncestorWidgetOfExactType(); final ancestorMeta = ancestorMediaDetail?.metadata; - final ancestorSeriesKey = ancestorMeta != null && ancestorMeta.isSeason - ? ancestorMeta.parentRatingKey - : ancestorMeta?.ratingKey; - // For episodes, the show key is grandparentRatingKey; for seasons, it's parentRatingKey - final itemSeriesKey = mediaType == PlexMediaType.episode - ? metadata.grandparentRatingKey - : metadata.parentRatingKey; - if ((mediaType == PlexMediaType.episode || mediaType == PlexMediaType.season) && + final ancestorSeriesKey = ancestorMeta != null && ancestorMeta.kind == MediaKind.season + ? ancestorMeta.parentId + : ancestorMeta?.id; + // For episodes, the show key is grandparentId; for seasons, it's parentId + final itemSeriesKey = mediaKind == MediaKind.episode ? mediaItem.grandparentId : mediaItem.parentId; + if ((mediaKind == MediaKind.episode || mediaKind == MediaKind.season) && itemSeriesKey != null && ancestorSeriesKey != itemSeriesKey) { menuActions.add(_MenuAction(value: 'series', icon: Symbols.tv_rounded, label: t.mediaMenu.goToSeries)); } // Go to Season (for episodes) — hide if already viewing that season's MediaDetailScreen - if (mediaType == PlexMediaType.episode && - metadata.parentTitle != null && - !(ancestorMeta != null && ancestorMeta.isSeason && ancestorMeta.ratingKey == metadata.parentRatingKey)) { + if (mediaKind == MediaKind.episode && + mediaItem.parentTitle != null && + !(ancestorMeta != null && ancestorMeta.kind == MediaKind.season && ancestorMeta.id == mediaItem.parentId)) { menuActions.add( _MenuAction(value: 'season', icon: Symbols.playlist_play_rounded, label: t.mediaMenu.goToSeason), ); } // Shuffle Play (for shows and seasons) - if (mediaType == PlexMediaType.show || mediaType == PlexMediaType.season) { + if (mediaKind == MediaKind.show || mediaKind == MediaKind.season) { menuActions.add( _MenuAction(value: 'shuffle_play', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay), ); } - // Play Version (for episodes and movies). Always shown — even for - // single-version items — so the user can still pick a streaming - // quality. The handler skips the version picker if only one version - // is present, jumping straight to the quality picker. - if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) { + // Play Version (for episodes and movies). Hidden when there's + // nothing to choose: a single source on a backend that can't + // transcode (Jellyfin v1, or Plex installs without a working + // transcoder) would just bounce straight to playback with default + // settings, which is what the regular Play action already does. + // Both backends inline their version list in browse responses + // (`Media[]` for Plex, `MediaSources` for Jellyfin), so the count + // is known up front. + final versionCount = (mediaItem.mediaVersions ?? const []).length; + final hasVersionChoice = versionCount > 1; + if ((mediaKind == MediaKind.episode || mediaKind == MediaKind.movie) && (hasVersionChoice || canTranscode)) { menuActions.add( _MenuAction(value: 'play_version', icon: Symbols.video_file_rounded, label: t.mediaMenu.playVersion), ); } - // File Info (for episodes and movies) - if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) { + // File Info (for episodes and movies). Backend-neutral — both + // PlexClient and JellyfinClient implement [getFileInfo], reading + // codec/stream metadata from `Media`/`MediaSources` respectively. + // Hidden when the item has no backend marker so we don't fan out + // to an arbitrary client. + if (itemBackend != null && (mediaKind == MediaKind.episode || mediaKind == MediaKind.movie)) { menuActions.add(_MenuAction(value: 'fileinfo', icon: Symbols.info_rounded, label: t.mediaMenu.fileInfo)); } // Play in External Player (for episodes and movies) - if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) { + if (mediaKind == MediaKind.episode || mediaKind == MediaKind.movie) { menuActions.add( _MenuAction( value: 'play_external', @@ -328,14 +405,13 @@ class MediaContextMenuState extends State { // Download options (for episodes, movies, shows, and seasons). // Apple TV has no user-accessible file storage — skip entirely. if (!PlatformDetector.isAppleTV() && - (mediaType == PlexMediaType.episode || - mediaType == PlexMediaType.movie || - mediaType == PlexMediaType.show || - mediaType == PlexMediaType.season)) { + (mediaKind == MediaKind.episode || + mediaKind == MediaKind.movie || + mediaKind == MediaKind.show || + mediaKind == MediaKind.season)) { final downloadProvider = Provider.of(context, listen: false); - final globalKey = metadata.globalKey; - final isDownloaded = downloadProvider.isDownloaded(globalKey); - final hasSyncRule = downloadProvider.hasSyncRule(globalKey); + final globalKey = mediaItem.globalKey; + final hasSyncRule = downloadProvider.hasSyncRule(_itemSyncRuleKey(context)); final hasAnyDownload = downloadProvider.getProgress(globalKey) != null; if (hasSyncRule) { @@ -351,8 +427,8 @@ class MediaContextMenuState extends State { _MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload), ); } - } else if (isDownloaded) { - // Show delete download option + } else if (hasAnyDownload) { + // Show delete option for any download state (completed, partial, queued, downloading, failed) menuActions.add( _MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload), ); @@ -364,20 +440,26 @@ class MediaContextMenuState extends State { } } - // Add to... (for episodes, movies, shows, and seasons) - if (mediaType == PlexMediaType.episode || - mediaType == PlexMediaType.movie || - mediaType == PlexMediaType.show || - mediaType == PlexMediaType.season) { + // 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. + if (isPlex && + (mediaKind == MediaKind.episode || + mediaKind == MediaKind.movie || + mediaKind == MediaKind.show || + mediaKind == MediaKind.season)) { menuActions.add(_MenuAction(value: 'add_to', icon: Symbols.add_rounded, label: t.common.addTo)); } - // Delete media item (for episodes, movies, shows, and seasons) — admin only + // Delete media item (for episodes, movies, shows, and seasons) — admin + // only. Backend-neutral: routed through `MediaServerClient.deleteMediaItem`, + // which both Plex and Jellyfin implement (DELETE /library/metadata/{id} + // and DELETE /Items/{id} respectively). if (isAdmin && - (mediaType == PlexMediaType.episode || - mediaType == PlexMediaType.movie || - mediaType == PlexMediaType.show || - mediaType == PlexMediaType.season)) { + (mediaKind == MediaKind.episode || + mediaKind == MediaKind.movie || + mediaKind == MediaKind.show || + mediaKind == MediaKind.season)) { menuActions.add( _MenuAction( value: 'delete_media', @@ -400,7 +482,7 @@ class MediaContextMenuState extends State { context, showDragHandle: true, builder: (context) => _FocusableContextMenuSheet( - title: widget.item.displayTitle, + title: _itemDisplayTitle(), actions: menuActions, focusFirstItem: openedFromKeyboard, ), @@ -433,47 +515,46 @@ class MediaContextMenuState extends State { case 'play_from_beginning': didNavigate = true; if (context.mounted) { - await navigateToVideoPlayer(context, metadata: metadata!.copyWith(viewOffset: 0)); + await navigateToVideoPlayer(context, metadata: mediaItem!.copyWith(viewOffsetMs: 0)); } break; case 'watch': final isOffline = context.read().isOffline; - if (isOffline && metadata?.serverId != null) { + if (isOffline && mediaItem?.serverId != null) { // Offline mode: queue action for later sync (emits WatchStateEvent) final offlineWatch = context.read(); - await offlineWatch.markAsWatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey); + await offlineWatch.markAsWatched(serverId: mediaItem!.serverId!, itemId: mediaItem.id); if (context.mounted) { showAppSnackBar(context, t.messages.markedAsWatchedOffline); - widget.onRefresh?.call(metadata.ratingKey); + widget.onRefresh?.call(mediaItem.id); } } else { - // Pass metadata to emit WatchStateEvent for cross-screen updates - await _executeAction( - context, - () => _getClientForItem().markAsWatched(metadata!.ratingKey, metadata: metadata), - t.messages.markedAsWatched, - ); + // Resolve the right backend client — Plex hits scrobble, Jellyfin + // hits /UserPlayedItems. WatchStateNotifier event is fired in both + // paths so cross-screen UI updates regardless of backend. + await _executeAction(context, () async { + final client = context.tryGetMediaClientForServer(_itemServerId!); + if (client != null) await client.markWatched(mediaItem!); + }, t.messages.markedAsWatched); } break; case 'unwatch': final isOffline = context.read().isOffline; - if (isOffline && metadata?.serverId != null) { + if (isOffline && mediaItem?.serverId != null) { // Offline mode: queue action for later sync (emits WatchStateEvent) final offlineWatch = context.read(); - await offlineWatch.markAsUnwatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey); + await offlineWatch.markAsUnwatched(serverId: mediaItem!.serverId!, itemId: mediaItem.id); if (context.mounted) { showAppSnackBar(context, t.messages.markedAsUnwatchedOffline); - widget.onRefresh?.call(metadata.ratingKey); + widget.onRefresh?.call(mediaItem.id); } } else { - // Pass metadata to emit WatchStateEvent for cross-screen updates - await _executeAction( - context, - () => _getClientForItem().markAsUnwatched(metadata!.ratingKey, metadata: metadata), - t.messages.markedAsUnwatched, - ); + await _executeAction(context, () async { + final client = context.tryGetMediaClientForServer(_itemServerId!); + if (client != null) await client.markUnwatched(mediaItem!); + }, t.messages.markedAsUnwatched); } break; @@ -482,15 +563,15 @@ class MediaContextMenuState extends State { // This preserves the progression for partially watched items // and doesn't mark unwatched next episodes as watched try { - final client = _getClientForItem(); - await client.removeFromOnDeck(metadata!.ratingKey); + final client = _getMediaClientForItem(); + await client.removeFromContinueWatching(mediaItem!); if (context.mounted) { showSuccessSnackBar(context, t.messages.removedFromContinueWatching); // Use specific callback if provided, otherwise fallback to onRefresh if (widget.onRemoveFromContinueWatching != null) { widget.onRemoveFromContinueWatching!(); } else { - widget.onRefresh?.call(metadata.ratingKey); + widget.onRefresh?.call(mediaItem.id); } } } catch (e) { @@ -503,8 +584,8 @@ class MediaContextMenuState extends State { case 'rate': if (context.mounted) { try { - final client = _getClientForItem(); - await _showRatingSheet(context, metadata!, client); + final client = _getMediaClientForItem(); + await _showRatingSheet(context, mediaItem!, client); } catch (e) { if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); @@ -518,34 +599,37 @@ class MediaContextMenuState extends State { if (context.mounted) { await Navigator.push( context, - MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: metadata!)), + MaterialPageRoute(builder: (context) => PlexMetadataEditScreen(metadata: mediaItem!)), ); - widget.onRefresh?.call(metadata!.ratingKey); + widget.onRefresh?.call(mediaItem!.id); } break; case 'match': didNavigate = true; if (context.mounted) { - await Navigator.push(context, MaterialPageRoute(builder: (context) => MatchScreen(metadata: metadata!))); - widget.onRefresh?.call(metadata!.ratingKey); + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => PlexMatchScreen(metadata: mediaItem!)), + ); + widget.onRefresh?.call(mediaItem!.id); } break; case 'unmatch': - await _handleUnmatch(context, metadata!); + await _handleUnmatch(context, mediaItem!); break; case 'remove_from_collection': - await _handleRemoveFromCollection(context, metadata!); + await _handleRemoveFromCollection(context, mediaItem!); break; case 'series': didNavigate = true; await _navigateToRelated( context, - metadata!.mediaType == PlexMediaType.season ? metadata.parentRatingKey : metadata.grandparentRatingKey, - (metadata) => MediaDetailScreen(metadata: metadata), + mediaItem!.kind == MediaKind.season ? mediaItem.parentId : mediaItem.grandparentId, + (item) => MediaDetailScreen(metadata: item), t.messages.errorLoadingSeries, ); break; @@ -553,10 +637,8 @@ class MediaContextMenuState extends State { case 'season': didNavigate = true; // Navigate to the show with the season tab pre-selected - final seasonParentKey = metadata!.mediaType == PlexMediaType.episode - ? metadata.grandparentRatingKey - : metadata.parentRatingKey; - final seasonIndex = metadata.parentIndex; + final seasonParentKey = mediaItem!.kind == MediaKind.episode ? mediaItem.grandparentId : mediaItem.parentId; + final seasonIndex = mediaItem.parentIndex; await _navigateToRelated( context, seasonParentKey, @@ -622,7 +704,7 @@ class MediaContextMenuState extends State { break; case 'delete_media': - await _handleDeleteMediaItem(context, mediaType); + await _handleDeleteMediaItem(context, mediaKind); break; } } finally { @@ -646,7 +728,7 @@ class MediaContextMenuState extends State { await action(); if (context.mounted) { showSuccessSnackBar(context, successMessage); - widget.onRefresh?.call(widget.item.ratingKey); + widget.onRefresh?.call(_itemId()); } } catch (e) { if (context.mounted) { @@ -655,7 +737,14 @@ class MediaContextMenuState extends State { } } - Future _handleUnmatch(BuildContext context, PlexMetadata metadata) async { + /// Plex-only: an item is unmatched when its [MediaItem.guid] is missing or + /// references the Plex no-agent marker. + bool _isUnmatched(MediaItem item) { + final g = item.guid; + return g == null || g.isEmpty || g.contains('agents.none://'); + } + + Future _handleUnmatch(BuildContext context, MediaItem item) async { final confirmed = await showConfirmDialog( context, title: t.matchScreen.unmatch, @@ -667,11 +756,11 @@ class MediaContextMenuState extends State { final client = _getClientForItem(); try { - final success = await client.unmatchItem(metadata.ratingKey); + final success = await client.unmatchItem(item.id); if (!context.mounted) return; if (success) { showSuccessSnackBar(context, t.matchScreen.unmatchSuccess); - widget.onRefresh?.call(metadata.ratingKey); + widget.onRefresh?.call(item.id); } else { showErrorSnackBar(context, t.matchScreen.unmatchFailed); } @@ -685,19 +774,19 @@ class MediaContextMenuState extends State { /// Navigate to a related item (series or season) Future _navigateToRelated( BuildContext context, - String? ratingKey, - Widget Function(PlexMetadata) screenBuilder, + String? id, + Widget Function(MediaItem) screenBuilder, String errorPrefix, ) async { - if (ratingKey == null) return; + if (id == null) return; - final client = _getClientForItem(); + final client = _getMediaClientForItem(); try { - final metadata = await client.getMetadataWithImages(ratingKey); + final metadata = await client.fetchItem(id); if (metadata != null && context.mounted) { await Navigator.push(context, MaterialPageRoute(builder: (context) => screenBuilder(metadata))); - widget.onRefresh?.call(widget.item.ratingKey); + widget.onRefresh?.call(_itemId()); } } catch (e) { if (context.mounted) { @@ -708,7 +797,7 @@ class MediaContextMenuState extends State { /// Show file info bottom sheet Future _showFileInfo(BuildContext context) async { - final client = _getClientForItem(); + final client = _getMediaClientForItem(); try { if (context.mounted) { @@ -716,8 +805,8 @@ class MediaContextMenuState extends State { } // Fetch file info - final metadata = widget.item as PlexMetadata; - final fileInfo = await client.getFileInfo(metadata.ratingKey); + final item = _mediaItem!; + final fileInfo = await client.getFileInfo(item); // Close loading indicator if (context.mounted) { @@ -729,7 +818,7 @@ class MediaContextMenuState extends State { await OverlaySheetController.showAdaptive( context, isScrollControlled: true, - builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.displayTitle), + builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: item.displayTitle), ); } else if (context.mounted) { showErrorSnackBar(context, t.messages.fileInfoNotAvailable); @@ -747,8 +836,14 @@ class MediaContextMenuState extends State { } Future _handlePlayVersion(BuildContext context) async { - final metadata = widget.item as PlexMetadata; - final versions = metadata.mediaVersions ?? const []; + final item = _mediaItem!; + // Same flag the in-player Version & Quality sheet reads — keeps both + // surfaces honest about what the active backend can actually do. + final canTranscode = _itemServerId == null + ? false + : (context.read().getClientForServer(_itemServerId!)?.capabilities.videoTranscoding ?? + false); + final versions = item.mediaVersions ?? const []; int selectedVersionIndex = 0; if (versions.length > 1) { @@ -757,36 +852,51 @@ class MediaContextMenuState extends State { selectedVersionIndex = picked; } - final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null; - final selectedQuality = await showQualityPickerDialog( - context, - sourceBitrateKbps: selectedVersion?.bitrate, - sourceDurationMs: metadata.duration, - ); - if (selectedQuality == null || !context.mounted) return false; + TranscodeQualityPreset selectedQuality = TranscodeQualityPreset.original; + if (canTranscode) { + final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null; + final picked = await showQualityPickerDialog( + context, + sourceBitrateKbps: selectedVersion?.bitrate, + sourceDurationMs: item.durationMs, + sourceSizeBytes: _versionSizeBytes(selectedVersion), + ); + if (picked == null || !context.mounted) return false; + selectedQuality = picked; + } await navigateToVideoPlayer( context, - metadata: metadata, + metadata: item, selectedMediaIndex: selectedVersionIndex, selectedQualityPreset: selectedQuality, ); return true; } - /// Handle shuffle play using play queues + /// Sum of [MediaPart.sizeBytes] across all parts of [version]. Returns + /// null when any part is missing a size (a partial sum would be misleading + /// for the "Original" row in the quality picker). + int? _versionSizeBytes(MediaVersion? version) { + if (version == null || version.parts.isEmpty) return null; + var total = 0; + for (final p in version.parts) { + final s = p.sizeBytes; + if (s == null || s <= 0) return null; + total += s; + } + return total > 0 ? total : null; + } + + /// Handle shuffle play using play queues — dispatches via the + /// neutral [MediaListPlaybackLauncher] so Jellyfin items get routed to + /// [JellyfinSequentialLauncher] instead of falling through to the + /// Plex-only `/playQueues` flow. Future _handleShufflePlayWithQueue(BuildContext context) async { - final client = _getClientForItem(); - final metadata = widget.item as PlexMetadata; - - final launcher = PlayQueueLauncher( - context: context, - client: client, - serverId: metadata.serverId, - serverName: metadata.serverName, - ); - - await launcher.launchShuffledShow(metadata: metadata, showLoadingIndicator: true); + final mediaItem = _mediaItem; + if (mediaItem == null) return; + final launcher = MediaListPlaybackLauncher.forItem(context, mediaItem); + await launcher.launchShuffledShow(metadata: mediaItem, showLoadingIndicator: true); } /// Show submenu for Add to... (Playlist or Collection) @@ -810,14 +920,12 @@ class MediaContextMenuState extends State { /// Show dialog to select playlist and add item Future _showAddToPlaylistDialog(BuildContext context) async { - final client = _getClientForItem(); + final client = _getMediaClientForItem(); try { - final metadata = widget.item as PlexMetadata; - final itemType = metadata.mediaType.name; + final item = _mediaItem!; - // Load playlists - final playlists = await client.getPlaylists(playlistType: 'video'); + final playlists = await client.fetchPlaylists(playlistType: 'video'); if (!context.mounted) return; @@ -829,13 +937,6 @@ class MediaContextMenuState extends State { if (result == null || !context.mounted) return; - // Build URI for the item (works for all types: movies, episodes, seasons, shows) - // For seasons/shows, the Plex API should automatically expand to include all episodes - final itemUri = await client.buildMetadataUri(metadata.ratingKey); - appLogger.d('Built URI for $itemType: $itemUri'); - - if (!context.mounted) return; - if (result == '_create_new') { // Create new playlist flow final playlistName = await showTextInputDialog( @@ -850,8 +951,8 @@ class MediaContextMenuState extends State { } // Create playlist with the item(s) - appLogger.d('Creating playlist "$playlistName" with URI length: ${itemUri.length}'); - final newPlaylist = await client.createPlaylist(title: playlistName, uri: itemUri); + appLogger.d('Creating playlist "$playlistName" seeded with item ${item.id}'); + final newPlaylist = await client.createPlaylist(title: playlistName, items: [item]); if (!context.mounted) return; @@ -868,8 +969,8 @@ class MediaContextMenuState extends State { } } else { // Add to existing playlist - appLogger.d('Adding to playlist $result with URI: $itemUri'); - final success = await client.addToPlaylist(playlistId: result, uri: itemUri); + appLogger.d('Adding item ${item.id} to playlist $result'); + final success = await client.addToPlaylist(playlistId: result, items: [item]); if (!context.mounted) return; @@ -896,57 +997,39 @@ class MediaContextMenuState extends State { /// Show dialog to select collection and add item Future _showAddToCollectionDialog(BuildContext context) async { - final client = _getClientForItem(); + final client = _getMediaClientForItem(); try { - final metadata = widget.item as PlexMetadata; - final itemType = metadata.mediaType; + final item = _mediaItem!; + final itemKind = item.kind; - // Get the library section ID from the item - // First try from the metadata itself - int? sectionId = metadata.librarySectionID; - appLogger.d('Attempting to get section ID for ${metadata.title}'); - appLogger.d(' - librarySectionID: $sectionId'); - appLogger.d(' - key: ${metadata.key}'); + // Resolve the library/section id from the item itself, falling back to + // a metadata round-trip and the show's library if missing. Both + // backends store this on [MediaItem.libraryId]. + String? libraryId = item.libraryId; + appLogger.d('Resolving libraryId for ${item.title} (initial: $libraryId)'); - // If not available, fetch the full metadata which should include the section ID - if (sectionId == null) { + if (libraryId == null || libraryId.isEmpty) { try { - appLogger.d(' - Fetching full metadata for: ${metadata.ratingKey}'); - final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey); - if (fullMetadata != null) { - sectionId = fullMetadata.librarySectionID; - appLogger.d(' - Section ID from full metadata: $sectionId'); - } + final fullMetadata = await client.fetchItem(item.id); + libraryId = fullMetadata?.libraryId; + appLogger.d(' - libraryId from full metadata: $libraryId'); } catch (e) { - appLogger.w('Failed to get full metadata for section ID: $e'); + appLogger.w('Failed to get full metadata for libraryId: $e'); } } - // If still not found, try to extract from the key field - if (sectionId == null && metadata.key != null) { - final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key!); - if (keyMatch != null) { - sectionId = int.tryParse(keyMatch.group(1)!); - appLogger.d(' - Extracted from key: $sectionId'); - } - } - - // Last resort: try to get it from the item's parent (for episodes/seasons) - if (sectionId == null && metadata.grandparentRatingKey != null) { + if ((libraryId == null || libraryId.isEmpty) && item.grandparentId != null) { try { - appLogger.d(' - Trying to get from parent: ${metadata.grandparentRatingKey}'); - final parentMeta = await client.getMetadataWithImages(metadata.grandparentRatingKey!); - sectionId = parentMeta?.librarySectionID; - appLogger.d(' - Parent sectionId: $sectionId'); + final parentMeta = await client.fetchItem(item.grandparentId!); + libraryId = parentMeta?.libraryId; + appLogger.d(' - libraryId from grandparent: $libraryId'); } catch (e) { - appLogger.w('Failed to get parent metadata for section ID: $e'); + appLogger.w('Failed to get parent metadata for libraryId: $e'); } } - appLogger.d(' - Final sectionId: $sectionId'); - - if (sectionId == null) { + if (libraryId == null || libraryId.isEmpty) { if (context.mounted) { showErrorSnackBar(context, t.messages.unableToDetermineLibrarySection); } @@ -954,7 +1037,7 @@ class MediaContextMenuState extends State { } // Load collections for this library section - final collections = await client.getLibraryCollections(sectionId.toString()); + final collections = await client.fetchCollections(libraryId); if (!context.mounted) return; @@ -966,12 +1049,6 @@ class MediaContextMenuState extends State { if (result == null || !context.mounted) return; - // Build URI for the item - final itemUri = await client.buildMetadataUri(metadata.ratingKey); - appLogger.d('Built URI for $itemType: $itemUri'); - - if (!context.mounted) return; - if (result == '_create_new') { // Create new collection flow final collectionName = await showTextInputDialog( @@ -985,32 +1062,12 @@ class MediaContextMenuState extends State { return; } - // Create collection first (without items) - // Determine the collection type based on the item type - int? collectionType; - switch (itemType) { - case PlexMediaType.movie: - collectionType = 1; - break; - case PlexMediaType.show: - collectionType = 2; - break; - case PlexMediaType.season: - collectionType = 3; - break; - case PlexMediaType.episode: - collectionType = 4; - break; - default: - break; - } - - appLogger.d('Creating collection "$collectionName" with type $collectionType'); + appLogger.d('Creating collection "$collectionName" seeded with item ${item.id}'); final newCollectionId = await client.createCollection( - sectionId: sectionId.toString(), + libraryId: libraryId, title: collectionName, - uri: '', // Empty for regular collections - type: collectionType, + items: [item], + itemKind: itemKind, ); if (!context.mounted) return; @@ -1018,23 +1075,10 @@ class MediaContextMenuState extends State { if (context.mounted) { if (newCollectionId != null) { appLogger.d('Successfully created collection with ID: $newCollectionId'); - - // Now add the item to the newly created collection - appLogger.d('Adding item to new collection $newCollectionId with URI: $itemUri'); - final addSuccess = await client.addToCollection(collectionId: newCollectionId, uri: itemUri); - - if (!context.mounted) return; - - if (addSuccess) { - appLogger.d('Successfully added item to new collection'); - showSuccessSnackBar(context, t.collections.created); - // Trigger refresh of collections tab - LibraryRefreshNotifier().notifyCollectionsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId); - } else { - appLogger.e('Failed to add item to new collection'); - showErrorSnackBar(context, t.collections.errorAddingToCollection); - } + showSuccessSnackBar(context, t.collections.created); + // Trigger refresh of collections tab + LibraryRefreshNotifier().notifyCollectionsChanged(); + _triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId); } else { appLogger.e('Failed to create collection - API returned null'); showErrorSnackBar(context, t.collections.errorAddingToCollection); @@ -1042,8 +1086,8 @@ class MediaContextMenuState extends State { } } else { // Add to existing collection - appLogger.d('Adding to collection $result with URI: $itemUri'); - final success = await client.addToCollection(collectionId: result, uri: itemUri); + appLogger.d('Adding item ${item.id} to collection $result'); + final success = await client.addToCollection(collectionId: result, items: [item]); if (!context.mounted) return; @@ -1068,31 +1112,39 @@ class MediaContextMenuState extends State { } } - /// Handle remove from collection action - Future _showRatingSheet(BuildContext context, PlexMetadata metadata, PlexClient client) async { - final currentStarValue = (metadata.userRating != null && metadata.userRating! > 0) - ? metadata.userRating! / 2.0 - : 0.0; + Future _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async { + final currentStarValue = (item.userRating != null && item.userRating! > 0) ? item.userRating! / 2.0 : 0.0; await OverlaySheetController.showAdaptive( context, showDragHandle: true, builder: (context) => RatingBottomSheet( currentRating: currentStarValue, onRate: (stars) async { - final plexRating = stars * 2.0; - final success = await client.rateItem(metadata.ratingKey, plexRating); - if (success) widget.onRefresh?.call(metadata.ratingKey); + // 0-10 scale used by both Plex and Jellyfin rate endpoints. + final rating = stars * 2.0; + try { + await client.rate(item, rating); + widget.onRefresh?.call(item.id); + } on MediaServerHttpException catch (e) { + appLogger.w('Failed to set rating', error: e); + if (context.mounted) showErrorSnackBar(context, t.errors.failedToRate); + } }, onClear: () async { - final success = await client.rateItem(metadata.ratingKey, -1); - if (success) widget.onRefresh?.call(metadata.ratingKey); + try { + await client.rate(item, -1); + widget.onRefresh?.call(item.id); + } on MediaServerHttpException catch (e) { + appLogger.w('Failed to clear rating', error: e); + if (context.mounted) showErrorSnackBar(context, t.errors.failedToRate); + } }, ), ); } - Future _handleRemoveFromCollection(BuildContext context, PlexMetadata metadata) async { - final client = _getClientForItem(); + Future _handleRemoveFromCollection(BuildContext context, MediaItem item) async { + final client = _getMediaClientForItem(); if (widget.collectionId == null) { appLogger.e('Cannot remove from collection: collectionId is null'); @@ -1103,14 +1155,14 @@ class MediaContextMenuState extends State { final confirmed = await showDeleteConfirmation( context, title: t.collections.removeFromCollection, - message: t.collections.removeFromCollectionConfirm(title: metadata.displayTitle), + message: t.collections.removeFromCollectionConfirm(title: item.displayTitle), ); if (!confirmed || !context.mounted) return; try { - appLogger.d('Removing item ${metadata.ratingKey} from collection ${widget.collectionId}'); - final success = await client.removeFromCollection(collectionId: widget.collectionId!, itemId: metadata.ratingKey); + appLogger.d('Removing item ${item.id} from collection ${widget.collectionId}'); + final success = await client.removeFromCollection(collectionId: widget.collectionId!, item: item); if (context.mounted) { if (success) { @@ -1141,26 +1193,22 @@ class MediaContextMenuState extends State { await _launchCollectionOrPlaylist(context, shuffle: true); } - /// Launch playback for collection or playlist + /// Launch playback for collection or playlist. + /// + /// Dispatches to the right launcher implementation based on the item's + /// backend — Plex uses server-side `/playQueues`, Jellyfin builds an + /// in-memory queue locally. Future _launchCollectionOrPlaylist(BuildContext context, {required bool shuffle}) async { - final client = _getClientForItem(); - final item = widget.item; - - final launcher = PlayQueueLauncher( - context: context, - client: client, - serverId: item is PlexMetadata ? item.serverId : (item as PlexPlaylist).serverId, - serverName: item is PlexMetadata ? item.serverName : (item as PlexPlaylist).serverName, - ); - - await launcher.launchFromCollectionOrPlaylist(item: item, shuffle: shuffle, showLoadingIndicator: false); + // Launcher accepts both MediaItem (for collections) and MediaPlaylist. + final launcher = MediaListPlaybackLauncher.forItem(context, widget.item); + await launcher.launchFromCollectionOrPlaylist(item: widget.item, shuffle: shuffle, showLoadingIndicator: false); } /// Handle delete action for collections and playlists Future _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async { - final client = _getClientForItem(); + final client = _getMediaClientForItem(); - final itemTitle = widget.item.displayTitle; + final itemTitle = _itemDisplayTitle(); final itemTypeLabel = isCollection ? t.collections.collection : t.playlists.playlist; // Show confirmation dialog @@ -1178,12 +1226,9 @@ class MediaContextMenuState extends State { bool success = false; if (isCollection) { - final metadata = widget.item as PlexMetadata; - final sectionId = metadata.librarySectionID?.toString() ?? '0'; - success = await client.deleteCollection(sectionId, metadata.ratingKey); + success = await client.deleteCollection(_mediaItem!); } else if (isPlaylist) { - final playlist = widget.item as PlexPlaylist; - success = await client.deletePlaylist(playlist.ratingKey); + success = await client.deletePlaylist(_playlist!); } if (context.mounted) { @@ -1208,11 +1253,11 @@ class MediaContextMenuState extends State { /// Handle play in external player action Future _handlePlayExternal(BuildContext context) async { - final metadata = widget.item as PlexMetadata; + final item = _mediaItem!; // Check if the item is downloaded and use local file path if available final downloadProvider = Provider.of(context, listen: false); - final globalKey = metadata.globalKey; + final globalKey = item.globalKey; if (downloadProvider.isDownloaded(globalKey)) { final videoPath = await downloadProvider.getVideoFilePath(globalKey); if (videoPath != null && context.mounted) { @@ -1222,20 +1267,23 @@ class MediaContextMenuState extends State { } } - final client = _getClientForItem(); + final client = _getMediaClientForItem(); if (!context.mounted) return; - await ExternalPlayerService.launch(context: context, metadata: metadata, client: client); + await ExternalPlayerService.launch(context: context, metadata: item, client: client); } /// Handle download collection action — opens the same sync/one-time dialog /// as playlists, wired to [showCollectionDownloadOptionsAndQueue]. Future _handleDownloadCollection(BuildContext context) async { - final collection = widget.item as PlexMetadata; + final collection = _mediaItem!; final downloadProvider = Provider.of(context, listen: false); - final client = _getClientForItem(); + final client = _getMediaClientForItem(); try { - final items = await client.fetchAllCollectionItems(collection.ratingKey); + // [fetchChildren] is the neutral equivalent of the previous Plex-only + // `fetchAllCollectionItemsAsMediaItems` — both backends return the + // collection's contents. + final items = await client.fetchChildren(collection.id); if (!context.mounted) return; final result = await showCollectionDownloadOptionsAndQueue( @@ -1262,19 +1310,22 @@ class MediaContextMenuState extends State { /// Handle download playlist action Future _handleDownloadPlaylist(BuildContext context) async { - final playlist = widget.item as PlexPlaylist; + final playlist = _playlist!; final downloadProvider = Provider.of(context, listen: false); - final client = _getClientForItem(); + final client = _getMediaClientForItem(); try { - final items = await client.fetchAllPlaylistItems(playlist.ratingKey); + // Page through the playlist via the neutral interface so Jellyfin + // playlists download too. + final items = await fetchAllPlaylistItems(client, playlist.id); if (!context.mounted) return; - final playlistMetadata = PlexMetadata( - ratingKey: playlist.ratingKey, - type: ContentTypes.playlist, + final playlistMetadata = MediaItem( + id: playlist.id, + backend: playlist.backend, + kind: MediaKind.playlist, title: playlist.title, - thumb: playlist.thumb, + thumbPath: playlist.thumbPath, serverId: playlist.serverId ?? client.serverId, serverName: playlist.serverName, ); @@ -1304,13 +1355,14 @@ class MediaContextMenuState extends State { /// Handle download action Future _handleDownload(BuildContext context) async { final downloadProvider = Provider.of(context, listen: false); - final metadata = widget.item as PlexMetadata; - final client = _getClientForItem(); + final item = _mediaItem!; + // Backend-agnostic resolve so Jellyfin items can be downloaded too. + final client = context.getMediaClientWithFallback(_itemServerId); try { final result = await showDownloadOptionsAndQueue( context, - metadata: metadata, + metadata: item, client: client, downloadProvider: downloadProvider, ); @@ -1332,14 +1384,14 @@ class MediaContextMenuState extends State { /// Handle delete download action Future _handleDeleteDownload(BuildContext context) async { final downloadProvider = Provider.of(context, listen: false); - final metadata = widget.item as PlexMetadata; - final globalKey = metadata.globalKey; + final item = _mediaItem!; + final globalKey = item.globalKey; // Show confirmation dialog final confirmed = await showDeleteConfirmation( context, title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: metadata.displayTitle), + message: t.downloads.deleteConfirm(title: item.displayTitle), ); if (!confirmed || !context.mounted) return; @@ -1350,10 +1402,10 @@ class MediaContextMenuState extends State { if (context.mounted) { showSuccessSnackBar(context, t.downloads.downloadDeleted); - // Notify DeletionAware screens (e.g. offline season detail) - DeletionNotifier().notifyDeleted(metadata: metadata, isDownloadOnly: true); - // Refresh the view if needed - widget.onRefresh?.call(metadata.ratingKey); + // DownloadProvider.deleteDownload now broadcasts the DeletionEvent, + // so DeletionAware screens (e.g. offline season detail) update without + // a duplicate notification here. + widget.onRefresh?.call(item.id); } } catch (e) { appLogger.e('Failed to delete download', error: e); @@ -1364,25 +1416,33 @@ class MediaContextMenuState extends State { } /// Resolve the sync-rule global key for whatever the menu item is — works - /// for both PlexMetadata (shows/seasons/collections/movies/episodes) and - /// PlexPlaylist. + /// for items (shows/seasons/collections/movies/episodes) and playlists. String _itemGlobalKey() { - final item = widget.item; - if (item is PlexPlaylist) { - final serverId = item.serverId ?? _getClientForItem().serverId; - return buildGlobalKey(serverId, item.ratingKey); - } - return (item as PlexMetadata).globalKey; + final raw = widget.item; + return switch (raw) { + MediaItem() => raw.globalKey, + MediaPlaylist() => raw.globalKey, + _ => '', + }; } - String _itemDisplayTitle() { - final item = widget.item; - if (item is PlexPlaylist) return item.title; - return (item as PlexMetadata).displayTitle; + String _itemSyncRuleKey(BuildContext context) { + final globalKey = _itemGlobalKey(); + final serverId = _itemServerId; + if (serverId == null) return globalKey; + final client = context.tryGetMediaClientForServer(serverId); + if (client == null) return globalKey; + return context.read().syncRuleKeyForClient(client, _itemId(), serverId: serverId); } + String _itemDisplayTitle() => switch (widget.item) { + MediaItem(:final displayTitle) => displayTitle, + MediaPlaylist(:final displayTitle) => displayTitle, + _ => '', + }; + Future _handleManageSyncRule(BuildContext context) => - manageSyncRule(context, downloadProvider: context.read(), globalKey: _itemGlobalKey()); + manageSyncRule(context, downloadProvider: context.read(), globalKey: _itemSyncRuleKey(context)); /// Fire-and-forget: if a sync rule exists for the target list, run it now so /// newly-added items download immediately instead of waiting for the next @@ -1390,7 +1450,10 @@ class MediaContextMenuState extends State { static void _triggerEagerSyncIfRuleExists(BuildContext context, String serverId, String listId) { try { final downloadProvider = Provider.of(context, listen: false); - final globalKey = buildGlobalKey(serverId, listId); + final client = Provider.of(context, listen: false).getClientForServer(serverId); + final globalKey = client == null + ? buildGlobalKey(serverId, listId) + : downloadProvider.syncRuleKeyForClient(client, listId, serverId: serverId); if (!downloadProvider.hasSyncRule(globalKey)) return; final serverManager = Provider.of(context, listen: false).serverManager; unawaited( @@ -1407,15 +1470,15 @@ class MediaContextMenuState extends State { Future _handleRemoveSyncRule(BuildContext context) => removeSyncRuleAndSnack( context, downloadProvider: context.read(), - globalKey: _itemGlobalKey(), + globalKey: _itemSyncRuleKey(context), displayTitle: _itemDisplayTitle(), ); /// Handle delete media item action /// This permanently removes the media item and its associated files from the server - Future _handleDeleteMediaItem(BuildContext context, PlexMediaType? mediaType) async { - final metadata = widget.item as PlexMetadata; - final isMultipleMediaItems = mediaType == PlexMediaType.show || mediaType == PlexMediaType.season; + Future _handleDeleteMediaItem(BuildContext context, MediaKind? mediaKind) async { + final item = _mediaItem!; + final isMultipleMediaItems = mediaKind == MediaKind.show || mediaKind == MediaKind.season; // Show confirmation dialog final confirmed = await showDeleteConfirmation( @@ -1428,14 +1491,14 @@ class MediaContextMenuState extends State { if (!confirmed || !context.mounted) return; try { - final client = _getClientForItem(); - final success = await client.deleteMediaItem(metadata.ratingKey); + final client = _getMediaClientForItem(); + final success = await client.deleteMediaItem(item); if (context.mounted) { if (success) { showSuccessSnackBar(context, t.mediaMenu.mediaDeletedSuccessfully); // Broadcast deletion event for cross-screen propagation - DeletionNotifier().notifyDeleted(metadata: metadata); + DeletionNotifier().notifyDeletedItem(item: item); // Backward-compatible list refresh for screens that are not DeletionAware yet widget.onListRefresh?.call(); } else { @@ -1461,7 +1524,7 @@ class MediaContextMenuState extends State { /// Dialog to select a playlist or create a new one class _PlaylistSelectionDialog extends StatelessWidget { - final List playlists; + final List playlists; const _PlaylistSelectionDialog({required this.playlists}); @@ -1496,7 +1559,7 @@ class _PlaylistSelectionDialog extends StatelessWidget { subtitle: playlist.leafCount != null ? Text(subtitleText) : null, onTap: playlist.smart ? null // Disable smart playlists - : () => Navigator.pop(context, playlist.ratingKey), + : () => Navigator.pop(context, playlist.id), enabled: !playlist.smart, ); }, @@ -1515,7 +1578,7 @@ class _PlaylistSelectionDialog extends StatelessWidget { /// Dialog to select a collection or create a new one class _CollectionSelectionDialog extends StatefulWidget { - final List collections; + final List collections; const _CollectionSelectionDialog({required this.collections}); @@ -1525,7 +1588,7 @@ class _CollectionSelectionDialog extends StatefulWidget { class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> { final _filterController = TextEditingController(); - late List _filteredCollections = widget.collections; + late List _filteredCollections = widget.collections; @override void dispose() { @@ -1580,11 +1643,11 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> final collection = _filteredCollections[index - 1]; return ListTile( leading: const AppIcon(Symbols.collections_rounded, fill: 1), - title: Text(collection.title!), + title: Text(collection.title ?? ''), subtitle: collection.childCount != null ? Text(t.playlists.itemCount(count: collection.childCount!)) : null, - onTap: () => Navigator.pop(context, collection.ratingKey), + onTap: () => Navigator.pop(context, collection.id), ); }, ), diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart index e8d191a5..fb36d70d 100644 --- a/lib/widgets/oauth_proxy_dialog.dart +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -7,6 +7,7 @@ import '../i18n/strings.g.dart'; import '../services/trackers/oauth_proxy_client.dart'; import '../utils/snackbar_helper.dart'; import 'dialog_action_button.dart'; +import 'loading_indicator_box.dart'; /// Sign-in dialog for OAuth-proxy flows (MAL, AniList). /// @@ -80,7 +81,7 @@ class OAuthProxyDialog extends StatelessWidget { const SizedBox(height: 16), Row( children: [ - const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), + const LoadingIndicatorBox(size: 16), const SizedBox(width: 12), Expanded(child: Text(t.trackers.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)), ], diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/optimized_media_image.dart similarity index 89% rename from lib/widgets/plex_optimized_image.dart rename to lib/widgets/optimized_media_image.dart index ecb26d50..7c9fad87 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -5,9 +5,10 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../services/image_cache_service.dart'; -import '../../services/plex_client.dart'; +import '../media/media_server_client.dart'; import '../utils/app_logger.dart'; -import '../utils/plex_image_helper.dart'; +import '../utils/media_image_helper.dart'; +import '../utils/obfuscation_utils.dart'; /// Tracks recent image load failures to log a periodic summary instead of /// spamming per-image. Resets after [_logInterval] so recurring issues @@ -16,23 +17,6 @@ int _imageFailureCount = 0; DateTime _lastFailureLog = DateTime.now(); const _logInterval = Duration(seconds: 10); -/// Set to `true` to blur all artwork (for store screenshots). -const kBlurArtwork = false; - -/// Wraps [child] with a blur filter when [kBlurArtwork] is `true`. -/// Rotates vowels (a→e, e→i, i→o, o→u, u→a) when [kBlurArtwork] is `true`. -String obfuscateText(String text) { - if (!kBlurArtwork) return text; - const from = 'aeiouAEIOU'; - const to = 'eiouaEIOUA'; - final buf = StringBuffer(); - for (var i = 0; i < text.length; i++) { - final idx = from.indexOf(text[i]); - buf.write(idx >= 0 ? to[idx] : text[i]); - } - return buf.toString(); -} - Widget blurArtwork(Widget child, {double sigma = 30, bool clip = true}) { if (!kBlurArtwork) return child; final filtered = ImageFiltered( @@ -42,8 +26,8 @@ Widget blurArtwork(Widget child, {double sigma = 30, bool clip = true}) { return clip ? ClipRect(child: filtered) : filtered; } -class PlexOptimizedImage extends StatelessWidget { - final PlexClient? client; +class OptimizedMediaImage extends StatelessWidget { + final MediaServerClient? client; final String? imagePath; final double? width; final double? height; @@ -59,7 +43,7 @@ class PlexOptimizedImage extends StatelessWidget { final ImageType imageType; final String? localFilePath; - const PlexOptimizedImage._({ + const OptimizedMediaImage._({ super.key, this.client, required this.imagePath, @@ -79,9 +63,9 @@ class PlexOptimizedImage extends StatelessWidget { }); /// Generic constructor for optimized images. - const factory PlexOptimizedImage({ + const factory OptimizedMediaImage({ Key? key, - PlexClient? client, + MediaServerClient? client, required String? imagePath, double? width, double? height, @@ -96,12 +80,12 @@ class PlexOptimizedImage extends StatelessWidget { IconData? fallbackIcon, ImageType imageType, String? localFilePath, - }) = PlexOptimizedImage._; + }) = OptimizedMediaImage._; /// Named constructor for poster images with default fallback icon. - const PlexOptimizedImage.poster({ + const OptimizedMediaImage.poster({ Key? key, - PlexClient? client, + MediaServerClient? client, required String? imagePath, double? width, double? height, @@ -134,9 +118,9 @@ class PlexOptimizedImage extends StatelessWidget { ); /// Named constructor for episode thumbnails. - const PlexOptimizedImage.thumb({ + const OptimizedMediaImage.thumb({ Key? key, - PlexClient? client, + MediaServerClient? client, required String? imagePath, double? width, double? height, @@ -169,9 +153,9 @@ class PlexOptimizedImage extends StatelessWidget { ); /// Named constructor for playlist images. - const PlexOptimizedImage.playlist({ + const OptimizedMediaImage.playlist({ Key? key, - PlexClient? client, + MediaServerClient? client, required String? imagePath, double? width, double? height, @@ -241,10 +225,10 @@ class PlexOptimizedImage extends StatelessWidget { } Widget _buildLocalFileImage(BuildContext context, File file, double effectiveWidth, double effectiveHeight) { - final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); final scaledWidth = effectiveWidth * dpr; final scaledHeight = effectiveHeight * dpr; - final (_, memHeight) = PlexImageHelper.getMemCacheDimensions( + final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0, displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0, imageType: imageType, @@ -284,16 +268,16 @@ class PlexOptimizedImage extends StatelessWidget { } Widget _buildCachedImage(BuildContext context, double effectiveWidth, double effectiveHeight) { - final devicePixelRatio = PlexImageHelper.effectiveDevicePixelRatio(context); + final devicePixelRatio = MediaImageHelper.effectiveDevicePixelRatio(context); // Get optimized image URL - final imageUrl = PlexImageHelper.getOptimizedImageUrl( + final imageUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: imagePath, maxWidth: effectiveWidth, maxHeight: effectiveHeight, devicePixelRatio: devicePixelRatio, - enableTranscoding: enableTranscoding && PlexImageHelper.shouldTranscode(imagePath), + enableTranscoding: enableTranscoding && MediaImageHelper.shouldTranscode(imagePath), imageType: imageType, ); @@ -304,7 +288,7 @@ class PlexOptimizedImage extends StatelessWidget { // Calculate memory cache dimensions final scaledWidth = effectiveWidth * devicePixelRatio; final scaledHeight = effectiveHeight * devicePixelRatio; - final (_, memHeight) = PlexImageHelper.getMemCacheDimensions( + final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0, displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0, imageType: imageType, diff --git a/lib/widgets/server_activities_button.dart b/lib/widgets/server_activities_button.dart index 7b9d2894..8dc1f7ea 100644 --- a/lib/widgets/server_activities_button.dart +++ b/lib/widgets/server_activities_button.dart @@ -8,7 +8,7 @@ import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../theme/mono_tokens.dart'; import 'package:plezy/widgets/app_icon.dart'; -import '../models/plex_activity.dart'; +import '../models/plex/plex_activity.dart'; import '../providers/multi_server_provider.dart'; class ServerActivitiesButton extends StatefulWidget { @@ -91,7 +91,8 @@ class _ServerActivitiesButtonState extends State { final serverIds = multiServer.onlineServerIds; final futures = serverIds.map((serverId) async { - final client = multiServer.getClientForServer(serverId); + // Plex-only: `/activities` API is Plex-specific. + final client = multiServer.getPlexClientForServer(serverId); if (client == null) return null; final activities = await client.getActivities(); return _ServerResult(serverId: serverId, serverName: client.serverName ?? serverId, activities: activities); @@ -133,7 +134,8 @@ class _ServerActivitiesButtonState extends State { Future _cancelActivity(String serverId, String uuid) async { final multiServer = Provider.of(context, listen: false); - final client = multiServer.getClientForServer(serverId); + // Plex-only: `/activities` API is Plex-specific. + final client = multiServer.getPlexClientForServer(serverId); if (client == null) return; try { await client.cancelActivity(uuid); diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 9929d7f3..01863b0a 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -9,7 +9,7 @@ import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; import '../focus/focus_memory_tracker.dart'; -import '../models/plex_library.dart'; +import '../media/media_library.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; @@ -18,6 +18,7 @@ import '../utils/platform_detector.dart'; import '../providers/multi_server_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../theme/mono_tokens.dart'; +import '../widgets/backend_badge.dart'; import '../i18n/strings.g.dart'; /// Reusable navigation rail item widget that handles focus, selection, and interaction @@ -260,7 +261,7 @@ class SideNavigationRailState extends State { } /// Build the set of valid focus keys (main nav + current libraries + server headers) - Set _buildValidFocusKeys(List libraries, Set serverIds) { + Set _buildValidFocusKeys(List libraries, Set serverIds) { return { _kHome, _kLibraries, @@ -279,7 +280,7 @@ class SideNavigationRailState extends State { /// Build the visual order of library items inside the libraries body. Must /// mirror what `_buildLibraryGroupedColumn` actually renders so D-pad /// navigation lands on the visually next item. - List _buildLibraryBodyOrder(List libs, {required bool showServerHeaders}) { + List _buildLibraryBodyOrder(List libs, {required bool showServerHeaders}) { if (!showServerHeaders) { return libs.map((lib) => lib.globalKey).toList(); } @@ -300,8 +301,8 @@ class SideNavigationRailState extends State { /// Ordered list of focusable keys matching visual top-to-bottom order. List _buildFocusOrder( - List visibleLibraries, - List hiddenLibraries, { + List visibleLibraries, + List hiddenLibraries, { required bool hasLiveTv, required bool showServerHeaders, }) { @@ -412,8 +413,8 @@ class SideNavigationRailState extends State { final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; final allLibraries = librariesProvider.libraries; - final visibleLibraries = []; - final hiddenLibraries = []; + final visibleLibraries = []; + final hiddenLibraries = []; final serverIds = {}; for (final lib in allLibraries) { if (lib.serverId != null) serverIds.add(lib.serverId!); @@ -675,8 +676,8 @@ class SideNavigationRailState extends State { } Widget _buildLibrariesSection( - List visibleLibraries, - List hiddenLibraries, + List visibleLibraries, + List hiddenLibraries, dynamic t, { bool isCollapsed = false, bool showServerHeaders = false, @@ -835,7 +836,7 @@ class SideNavigationRailState extends State { } /// Get set of library names that appear more than once (not globally unique) - Set _getNonUniqueLibraryNames(List libraries) { + Set _getNonUniqueLibraryNames(List libraries) { final nameCounts = {}; for (final lib in libraries) { nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1; @@ -847,11 +848,11 @@ class SideNavigationRailState extends State { /// first-seen position and bucketing libraries underneath. Returns the server /// order plus a per-server library list. Libraries without a serverId end up /// in a synthetic '' bucket appearing at their first occurrence. - ({List serverOrder, Map> byServer}) _groupByFirstAppearance( - List libs, + ({List serverOrder, Map> byServer}) _groupByFirstAppearance( + List libs, ) { final order = []; - final byServer = >{}; + final byServer = >{}; for (final lib in libs) { final key = lib.serverId ?? ''; if (!byServer.containsKey(key)) { @@ -863,7 +864,7 @@ class SideNavigationRailState extends State { return (serverOrder: order, byServer: byServer); } - Widget _buildLibraryGroupedColumn(List libraries, dynamic t, {required bool showServerHeaders}) { + Widget _buildLibraryGroupedColumn(List libraries, dynamic t, {required bool showServerHeaders}) { if (!showServerHeaders) { final nonUniqueNames = _getNonUniqueLibraryNames(libraries); return Column( @@ -894,10 +895,15 @@ class SideNavigationRailState extends State { } Widget _buildServerHeader(String serverId, String serverName, dynamic t) { + // Resolve backend per server so the badge matches the brand. Falls back + // to the generic `dns` icon if the client isn't registered yet (rare — + // can happen during a profile switch before the manager rehydrates). + final backend = context.read().serverManager.getClient(serverId)?.backend; return _buildCollapsibleHeader( focusKey: _kServerHeaderPrefix + serverId, icon: Symbols.dns_rounded, iconSize: 14, + leading: backend == null ? null : BackendBadge(backend: backend, size: 14, color: t.textMuted), label: serverName, labelStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.4, color: t.textMuted), verticalPadding: 6, @@ -933,6 +939,7 @@ class SideNavigationRailState extends State { required String focusKey, required IconData icon, required double iconSize, + Widget? leading, required String label, required TextStyle labelStyle, required double verticalPadding, @@ -978,7 +985,7 @@ class SideNavigationRailState extends State { padding: EdgeInsets.symmetric(vertical: verticalPadding, horizontal: 17), child: Row( children: [ - AppIcon(icon, fill: 1, size: iconSize, color: t.textMuted), + leading ?? AppIcon(icon, fill: 1, size: iconSize, color: t.textMuted), const SizedBox(width: 11), Expanded( child: Text(label, style: labelStyle, overflow: TextOverflow.ellipsis), @@ -1001,7 +1008,7 @@ class SideNavigationRailState extends State { ); } - Widget _buildLibraryItem(PlexLibrary library, dynamic t, {bool showServerName = false}) { + Widget _buildLibraryItem(MediaLibrary library, dynamic t, {bool showServerName = false}) { final isSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == library.globalKey; final isFocused = _focusTracker.isFocused(library.globalKey); @@ -1010,8 +1017,8 @@ class SideNavigationRailState extends State { return Padding( padding: const EdgeInsets.only(left: 12), child: NavigationRailItem( - icon: _getLibraryIcon(library.type), - selectedIcon: _getLibraryIcon(library.type), + icon: _getLibraryIcon(library.kind.id), + selectedIcon: _getLibraryIcon(library.kind.id), label: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 041e57aa..4ae1aaeb 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -7,10 +7,11 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../../focus/dpad_navigator.dart'; +import '../../media/media_item.dart'; import '../../mpv/mpv.dart'; -import '../../models/plex_media_info.dart'; -import '../../models/plex_metadata.dart'; +import '../../media/media_source_info.dart'; import '../../services/fullscreen_state_manager.dart'; +import '../../services/scrub_preview_source.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/platform_detector.dart'; import '../../utils/formatters.dart'; @@ -30,10 +31,10 @@ import 'widgets/track_chapter_controls.dart'; /// Desktop-specific video controls layout with top bar and bottom controls class DesktopVideoControls extends StatefulWidget { final Player player; - final PlexMetadata metadata; + final MediaItem metadata; final VoidCallback? onNext; final VoidCallback? onPrevious; - final List chapters; + final List chapters; final bool chaptersLoaded; final int seekTimeSmall; final VoidCallback onSeekToPreviousChapter; @@ -61,7 +62,7 @@ class DesktopVideoControls extends StatefulWidget { final ValueNotifier? hasFirstFrame; /// Optional callback that returns thumbnail image bytes for a given timestamp. - final Uint8List? Function(Duration time)? thumbnailDataBuilder; + final ScrubFrame? Function(Duration time)? thumbnailDataBuilder; /// Channel name for live TV display final String? liveChannelName; @@ -84,7 +85,7 @@ class DesktopVideoControls extends StatefulWidget { final bool showQueueTab; /// Called when a queue item is selected in the content strip - final Function(PlexMetadata)? onQueueItemSelected; + final Function(MediaItem)? onQueueItemSelected; /// Called to cancel auto-hide timer (e.g., when content strip is shown) final VoidCallback? onCancelAutoHide; diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index a41280d0..e023127f 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -1,12 +1,11 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../media/media_item.dart'; import '../../mpv/mpv.dart'; import '../../models/livetv_capture_buffer.dart'; -import '../../models/plex_media_info.dart'; -import '../../models/plex_metadata.dart'; +import '../../media/media_source_info.dart'; +import '../../services/scrub_preview_source.dart'; import '../../utils/desktop_window_padding.dart'; import '../../i18n/strings.g.dart'; import 'widgets/circular_control_button.dart'; @@ -29,8 +28,8 @@ import 'widgets/video_timeline_bar.dart'; /// fade out while the strip slides up — only the top bar stays fixed. class MobileVideoControls extends StatefulWidget { final Player player; - final PlexMetadata metadata; - final List chapters; + final MediaItem metadata; + final List chapters; final bool chaptersLoaded; final int seekTimeSmall; final Widget trackChapterControls; @@ -51,7 +50,7 @@ class MobileVideoControls extends StatefulWidget { final ValueNotifier? hasFirstFrame; /// Optional callback that returns thumbnail image bytes for a given timestamp. - final Uint8List? Function(Duration time)? thumbnailDataBuilder; + final ScrubFrame? Function(Duration time)? thumbnailDataBuilder; /// Whether this is a live TV stream final bool isLive; @@ -72,7 +71,7 @@ class MobileVideoControls extends StatefulWidget { final bool showQueueTab; /// Callback when a queue item is selected from the content strip - final Function(PlexMetadata)? onQueueItemSelected; + final Function(MediaItem)? onQueueItemSelected; /// Notifier for controls visibility (used to reset strip on hide) final ValueNotifier? controlsVisible; diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 2161f8e5..d12bf25c 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -1,20 +1,20 @@ import 'package:flutter/material.dart'; -import '../../../models/plex_media_info.dart'; -import '../../../models/plex_media_version.dart'; -import '../../../models/plex_metadata.dart'; +import '../../../media/media_item.dart'; +import '../../../media/media_version.dart'; +import '../../../media/media_source_info.dart'; import '../../../models/transcode_quality_preset.dart'; import '../../../mpv/mpv.dart'; import '../../../services/shader_service.dart'; /// Immutable configuration for track/chapter control widgets. class TrackControlsState { - final List availableVersions; + final List availableVersions; final int selectedMediaIndex; final TranscodeQualityPreset selectedQualityPreset; final bool serverSupportsTranscoding; final bool isTranscoding; - final List sourceAudioTracks; + final List sourceAudioTracks; final int? selectedAudioStreamId; /// Total media duration in milliseconds. Used by the version/quality sheet @@ -52,11 +52,17 @@ class TrackControlsState { final bool isLive; final bool subtitlesVisible; final bool showQueueButton; - final Function(PlexMetadata)? onQueueItemSelected; + final Function(MediaItem)? onQueueItemSelected; final String ratingKey; final String? mediaTitle; final Future Function()? onSubtitleDownloaded; + /// Whether OpenSubtitles search is reachable for this server. The Plex + /// server proxies the OpenSubtitles plugin; Jellyfin doesn't expose an + /// equivalent. The track sheet hides the "Search subtitles" tile when + /// this is false. + final bool subtitleSearchSupported; + const TrackControlsState({ this.availableVersions = const [], this.selectedMediaIndex = 0, @@ -102,5 +108,6 @@ class TrackControlsState { this.ratingKey = '', this.mediaTitle, this.onSubtitleDownloaded, + this.subtitleSearchSupported = true, }); } diff --git a/lib/widgets/video_controls/painters/buffer_range_painter.dart b/lib/widgets/video_controls/painters/buffer_range_painter.dart index 70ecb1c7..680a9ea2 100644 --- a/lib/widgets/video_controls/painters/buffer_range_painter.dart +++ b/lib/widgets/video_controls/painters/buffer_range_painter.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../../models/plex_media_info.dart'; +import '../../../media/media_source_info.dart'; import '../../../mpv/models.dart'; /// Custom painter that draws a segmented background track (split at chapter @@ -7,7 +7,7 @@ import '../../../mpv/models.dart'; class BufferRangePainter extends CustomPainter { final List ranges; final Duration duration; - final List chapters; + final List chapters; BufferRangePainter({required this.ranges, required this.duration, this.chapters = const []}); diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index d5ecbc91..52d60fa0 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -5,10 +5,10 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../i18n/strings.g.dart'; +import '../../../media/media_server_client.dart'; import '../../../mpv/mpv.dart'; -import '../../../services/plex_client.dart'; import '../../../services/download_storage_service.dart'; -import '../../../models/plex_media_info.dart'; +import '../../../media/media_source_info.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/formatters.dart'; import '../../../utils/player_utils.dart'; @@ -17,12 +17,12 @@ import '../../../utils/scroll_utils.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; -import '../../plex_optimized_image.dart'; +import '../../optimized_media_image.dart'; /// Bottom sheet for selecting chapters class ChapterSheet extends StatefulWidget { final Player player; - final List chapters; + final List chapters; final bool chaptersLoaded; final String? serverId; // Server ID for the metadata these chapters belong to final Function(Duration position)? onSeekCompleted; @@ -60,9 +60,9 @@ class _ChapterSheetState extends State { } } - /// Get the PlexClient for chapters, or null if unavailable (offline mode) - PlexClient? _tryGetClientForChapters(BuildContext context) { - return context.tryGetClientForServer(widget.serverId); + /// Get the media client for chapters, or null if unavailable (offline mode). + MediaServerClient? _tryGetClientForChapters(BuildContext context) { + return context.tryGetMediaClientForServer(widget.serverId); } @override @@ -72,7 +72,7 @@ class _ChapterSheetState extends State { initialData: widget.player.state.position, builder: (context, positionSnapshot) { final currentPosition = positionSnapshot.data ?? Duration.zero; - final currentChapterIndex = PlexChapter.indexAtPosition(currentPosition, widget.chapters); + final currentChapterIndex = MediaChapter.indexAtPosition(currentPosition, widget.chapters); Widget content; if (!widget.chaptersLoaded) { @@ -109,7 +109,7 @@ class _ChapterSheetState extends State { children: [ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), - child: PlexOptimizedImage.thumb( + child: OptimizedMediaImage.thumb( client: _tryGetClientForChapters(context), imagePath: chapter.thumb, localFilePath: localThumbPath, diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index b50d3228..d4ea7a1c 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -4,7 +4,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../../i18n/strings.g.dart'; -import '../../../models/plex_metadata.dart'; +import '../../../media/media_item.dart'; import '../../../providers/playback_state_provider.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/provider_extensions.dart'; @@ -12,14 +12,14 @@ import '../../../utils/scroll_utils.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; -import '../../plex_optimized_image.dart'; +import '../../optimized_media_image.dart'; const _kThumbWidth = 60.0; const _kThumbHeight = 34.0; /// Bottom sheet for viewing and navigating the play queue class QueueSheet extends StatefulWidget { - final Function(PlexMetadata) onItemSelected; + final Function(MediaItem) onItemSelected; const QueueSheet({super.key, required this.onItemSelected}); @@ -51,7 +51,7 @@ class _QueueSheetState extends State { child: Text(t.videoControls.noQueueItems, style: TextStyle(color: tokens(context).textMuted)), ); } else { - final currentIndex = items.indexWhere((item) => item.playQueueItemID == currentItemID); + final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); if (!_didInitialScroll && currentIndex > 0) { _didInitialScroll = true; scrollToCurrentItem(_scrollController, _firstItemKey, currentIndex); @@ -62,14 +62,14 @@ class _QueueSheetState extends State { itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; - final isCurrent = item.playQueueItemID == currentItemID; + final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; final primaryColor = Theme.of(context).colorScheme.primary; return FocusableListTile( key: index == 0 ? _firstItemKey : null, leading: _buildThumbnail(context, item, isCurrent), title: Text( - item.title!, + item.title ?? '', style: TextStyle( color: isCurrent ? primaryColor : null, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, @@ -101,11 +101,11 @@ class _QueueSheetState extends State { ); } - Widget? _buildThumbnail(BuildContext context, PlexMetadata item, bool isCurrent) { - if (item.thumb == null) return null; + Widget? _buildThumbnail(BuildContext context, MediaItem item, bool isCurrent) { + if (item.thumbPath == null) return null; // Try to get client for thumbnails, may fail in offline mode - final client = context.tryGetClientForServer(item.serverId); + final client = context.tryGetMediaClientForServer(item.serverId); return SizedBox( width: _kThumbWidth, @@ -114,9 +114,9 @@ class _QueueSheetState extends State { children: [ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), - child: PlexOptimizedImage.thumb( + child: OptimizedMediaImage.thumb( client: client, - imagePath: item.thumb, + imagePath: item.thumbPath, width: _kThumbWidth, height: _kThumbHeight, fit: BoxFit.cover, @@ -138,7 +138,7 @@ class _QueueSheetState extends State { ); } - String _buildSubtitle(PlexMetadata item) { + String _buildSubtitle(MediaItem item) { if (item.grandparentTitle != null && item.parentIndex != null && item.index != null) { return '${item.grandparentTitle} \u00b7 S${item.parentIndex}E${item.index}'; } @@ -146,8 +146,9 @@ class _QueueSheetState extends State { return item.grandparentTitle!; } if (item.year != null) { - return item.editionTitle != null ? '${item.year} · ${item.editionTitle}' : '${item.year}'; + final edition = item.editionTitle; + return edition != null ? '${item.year} · $edition' : '${item.year}'; } - return item.mediaType.name; + return item.kind.name; } } diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index 0a17306c..3d18a839 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -4,7 +4,8 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../i18n/strings.g.dart'; -import '../../../models/plex_subtitle_search_result.dart'; +import '../../../models/plex/plex_subtitle_search_result.dart'; +import '../../../services/plex_client.dart'; import '../../../utils/language_codes.dart'; import '../../../utils/provider_extensions.dart'; import '../../../utils/snackbar_helper.dart'; @@ -13,6 +14,7 @@ import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/pill_input_decoration.dart'; import 'base_video_control_sheet.dart'; +import '../../loading_indicator_box.dart'; class SubtitleSearchSheet extends StatefulWidget { final String ratingKey; @@ -77,7 +79,18 @@ class _SubtitleSearchSheetState extends State { }); try { - final client = context.getClientForServer(widget.serverId); + // Defense-in-depth: searchSubtitles is Plex-only. The UI gates this + // sheet on `subtitleSearchSupported` upstream, but if a future caller + // reaches us with a Jellyfin server, fail soft instead of throwing. + final neutral = context.tryGetMediaClientForServer(widget.serverId); + final client = neutral is PlexClient ? neutral : null; + if (client == null) { + if (!mounted) return; + setState(() { + _isSearching = false; + }); + return; + } final title = _titleController.text.trim(); final results = await client.searchSubtitles( widget.ratingKey, @@ -117,7 +130,15 @@ class _SubtitleSearchSheetState extends State { setState(() => _downloadingKey = result.key); try { - final client = context.getClientForServer(widget.serverId); + // Same Plex-only guard as in [_search]. Don't throw if a Jellyfin + // server somehow reaches the download path. + final neutral = context.tryGetMediaClientForServer(widget.serverId); + final client = neutral is PlexClient ? neutral : null; + if (client == null) { + if (!mounted) return; + setState(() => _downloadingKey = null); + return; + } final success = await client.downloadSubtitle( widget.ratingKey, key: result.key, @@ -248,7 +269,7 @@ class _SubtitleSearchSheetState extends State { Widget? trailing; if (isDownloading) { - trailing = const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)); + trailing = const LoadingIndicatorBox(size: 20); } else { final trailingChildren = []; if (result.perfectMatch) { diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 64ea54d1..4690d6b0 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../models/plex_media_info.dart'; +import '../../../media/media_source_info.dart'; import '../../../mpv/mpv.dart'; import '../../../i18n/strings.g.dart'; import '../../../utils/scroll_utils.dart'; @@ -30,10 +30,15 @@ class TrackSheet extends StatelessWidget { /// player's in-stream audio selection (the transcoded stream only has one /// audio track). final bool isTranscoding; - final List sourceAudioTracks; + final List sourceAudioTracks; final int? selectedAudioStreamId; final ValueChanged? onSwitchAudioStreamId; + /// Whether OpenSubtitles search is supported by the active server. Plex + /// proxies the OpenSubtitles plugin; Jellyfin doesn't expose an + /// equivalent today. + final bool subtitleSearchSupported; + const TrackSheet({ super.key, required this.player, @@ -48,6 +53,7 @@ class TrackSheet extends StatelessWidget { this.sourceAudioTracks = const [], this.selectedAudioStreamId, this.onSwitchAudioStreamId, + this.subtitleSearchSupported = true, }); @override @@ -130,6 +136,7 @@ class TrackSheet extends StatelessWidget { onSecondaryTrackChanged: onSecondarySubtitleTrackChanged, supportsSecondary: supportsSecondary, showHeader: true, + subtitleSearchSupported: subtitleSearchSupported, ), ), ), @@ -153,6 +160,7 @@ class TrackSheet extends StatelessWidget { onSecondaryTrackChanged: onSecondarySubtitleTrackChanged, supportsSecondary: supportsSecondary, showHeader: false, + subtitleSearchSupported: subtitleSearchSupported, ); }, ), @@ -163,7 +171,7 @@ class TrackSheet extends StatelessWidget { } class _SourceAudioColumn extends StatefulWidget { - final List tracks; + final List tracks; final int? selectedStreamId; final ValueChanged onSelected; final bool showHeader; @@ -318,6 +326,7 @@ class _SubtitleColumn extends StatefulWidget { final Function(SubtitleTrack)? onSecondaryTrackChanged; final bool supportsSecondary; final bool showHeader; + final bool subtitleSearchSupported; const _SubtitleColumn({ required this.tracks, @@ -331,6 +340,7 @@ class _SubtitleColumn extends StatefulWidget { this.onSecondaryTrackChanged, this.supportsSecondary = false, required this.showHeader, + this.subtitleSearchSupported = true, }); @override @@ -469,7 +479,7 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { }, ), ), - if (widget.ratingKey.isNotEmpty) ...[ + if (widget.ratingKey.isNotEmpty && widget.subtitleSearchSupported) ...[ Divider(height: 1, color: Theme.of(context).dividerColor), FocusableListTile( leading: const AppIcon(Symbols.search_rounded), diff --git a/lib/widgets/video_controls/sheets/version_quality_sheet.dart b/lib/widgets/video_controls/sheets/version_quality_sheet.dart index e073c1d0..3a6a478c 100644 --- a/lib/widgets/video_controls/sheets/version_quality_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_quality_sheet.dart @@ -3,7 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/widgets/app_icon.dart'; import '../../../i18n/strings.g.dart'; -import '../../../models/plex_media_version.dart'; +import '../../../media/media_version.dart'; import '../../../models/transcode_quality_preset.dart'; import '../../../utils/quality_preset_labels.dart'; import '../../../utils/scroll_utils.dart'; @@ -17,7 +17,7 @@ import 'base_video_control_sheet.dart'; /// support video transcoding, only [TranscodeQualityPreset.original] is /// enabled in the quality column. class VersionQualitySheet extends StatelessWidget { - final List availableVersions; + final List availableVersions; final int selectedMediaIndex; final TranscodeQualityPreset selectedQualityPreset; final bool serverSupportsTranscoding; @@ -39,7 +39,14 @@ class VersionQualitySheet extends StatelessWidget { @override Widget build(BuildContext context) { final showVersions = availableVersions.length > 1; - final title = showVersions ? t.videoControls.versionQualityButton : t.videoControls.qualityColumnHeader; + // Quality presets only do something useful when the server can transcode + // — otherwise non-Original options are disabled and the column degenerates + // into a single tappable row. Hide it entirely in that case so the + // versions list (when present) gets the full sheet. + final showQuality = serverSupportsTranscoding; + final title = showQuality + ? (showVersions ? t.videoControls.versionQualityButton : t.videoControls.qualityColumnHeader) + : t.videoControls.versionColumnHeader; final qualityColumn = FocusTraversalGroup( child: _QualityColumn( @@ -47,6 +54,7 @@ class VersionQualitySheet extends StatelessWidget { enabledForTranscoding: serverSupportsTranscoding, sourceBitrateKbps: _sourceBitrateKbps(), sourceDurationMs: sourceDurationMs, + sourceSizeBytes: _sourceSizeBytes(), onSelected: (preset) { OverlaySheetController.of(context).close(); onQualitySelected(preset); @@ -55,28 +63,30 @@ class VersionQualitySheet extends StatelessWidget { ), ); + final versionColumn = FocusTraversalGroup( + child: _VersionColumn( + versions: availableVersions, + selectedIndex: selectedMediaIndex, + onSelected: (index) { + OverlaySheetController.of(context).close(); + onVersionSelected(index); + }, + showHeader: showQuality, + ), + ); + final Widget body; - if (showVersions) { + if (showVersions && showQuality) { body = Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: FocusTraversalGroup( - child: _VersionColumn( - versions: availableVersions, - selectedIndex: selectedMediaIndex, - onSelected: (index) { - OverlaySheetController.of(context).close(); - onVersionSelected(index); - }, - showHeader: true, - ), - ), - ), + Expanded(child: versionColumn), VerticalDivider(width: 1, color: Theme.of(context).dividerColor), Expanded(child: qualityColumn), ], ); + } else if (showVersions) { + body = versionColumn; } else { body = qualityColumn; } @@ -92,10 +102,25 @@ class VersionQualitySheet extends StatelessWidget { if (b == null || b <= 0) return null; return b; } + + int? _sourceSizeBytes() { + if (selectedMediaIndex < 0 || selectedMediaIndex >= availableVersions.length) { + return null; + } + final parts = availableVersions[selectedMediaIndex].parts; + if (parts.isEmpty) return null; + var total = 0; + for (final p in parts) { + final s = p.sizeBytes; + if (s == null || s <= 0) return null; + total += s; + } + return total > 0 ? total : null; + } } class _VersionColumn extends StatefulWidget { - final List versions; + final List versions; final int selectedIndex; final ValueChanged onSelected; final bool showHeader; @@ -158,6 +183,7 @@ class _QualityColumn extends StatefulWidget { final bool enabledForTranscoding; final int? sourceBitrateKbps; final int? sourceDurationMs; + final int? sourceSizeBytes; final ValueChanged onSelected; final bool showHeader; @@ -166,6 +192,7 @@ class _QualityColumn extends StatefulWidget { required this.enabledForTranscoding, required this.sourceBitrateKbps, required this.sourceDurationMs, + required this.sourceSizeBytes, required this.onSelected, required this.showHeader, }); @@ -212,6 +239,7 @@ class _QualityColumnState extends State<_QualityColumn> { preset: preset, sourceBitrateKbps: widget.sourceBitrateKbps, sourceDurationMs: widget.sourceDurationMs, + sourceSizeBytes: widget.sourceSizeBytes, ); return _SelectionTile( diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index d68867c0..5db4a8c2 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1,6 +1,5 @@ import 'dart:async' show StreamSubscription, Timer, unawaited; import 'dart:io' show Platform; -import 'dart:typed_data'; import 'package:flutter/gestures.dart' show PointerSignalEvent, PointerScrollEvent; import 'package:flutter/material.dart'; @@ -27,20 +26,23 @@ import '../overlay_sheet.dart'; import '../../focus/dpad_navigator.dart'; import '../../focus/focusable_wrapper.dart'; +import '../../database/app_database.dart'; +import '../../media/media_backend.dart'; +import '../../media/media_item.dart'; import '../../models/livetv_capture_buffer.dart'; -import '../../services/plex_client.dart'; -import '../../services/plex_api_cache.dart'; -import '../../models/plex_media_info.dart'; +import '../../providers/multi_server_provider.dart'; +import '../../media/media_source_info.dart'; import '../../models/transcode_quality_preset.dart'; -import '../../models/plex_media_version.dart'; -import '../../models/plex_metadata.dart'; +import '../../media/media_version.dart'; import '../../screens/video_player_screen.dart'; import '../../focus/key_event_utils.dart'; import '../../services/keyboard_shortcuts_service.dart'; +import '../../services/cached_playback_metadata_service.dart'; +import '../../services/scrub_preview_source.dart'; import '../../services/settings_service.dart'; import '../../utils/formatters.dart'; +import '../../utils/global_key_utils.dart'; import '../../utils/platform_detector.dart'; -import '../../utils/plex_cache_parser.dart'; import '../../utils/player_utils.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/provider_extensions.dart'; @@ -65,15 +67,16 @@ import '../../services/shader_service.dart'; /// Custom video controls builder for Plex with chapter, audio, and subtitle support Widget plexVideoControlsBuilder( Player player, - PlexMetadata metadata, { + MediaItem metadata, { VoidCallback? onNext, VoidCallback? onPrevious, - List? availableVersions, + List? availableVersions, int? selectedMediaIndex, TranscodeQualityPreset selectedQualityPreset = TranscodeQualityPreset.original, bool serverSupportsTranscoding = false, bool isTranscoding = false, - List sourceAudioTracks = const [], + bool isOfflinePlayback = false, + List sourceAudioTracks = const [], int? selectedAudioStreamId, VoidCallback? onTogglePIPMode, int boxFitMode = 0, @@ -92,7 +95,7 @@ Widget plexVideoControlsBuilder( ValueNotifier? controlsVisible, ShaderService? shaderService, VoidCallback? onShaderChanged, - Uint8List? Function(Duration time)? thumbnailDataBuilder, + ScrubFrame? Function(Duration time)? thumbnailDataBuilder, bool isLive = false, String? liveChannelName, CaptureBuffer? captureBuffer, @@ -116,6 +119,7 @@ Widget plexVideoControlsBuilder( selectedQualityPreset: selectedQualityPreset, serverSupportsTranscoding: serverSupportsTranscoding, isTranscoding: isTranscoding, + isOfflinePlayback: isOfflinePlayback, sourceAudioTracks: sourceAudioTracks, selectedAudioStreamId: selectedAudioStreamId, boxFitMode: boxFitMode, @@ -149,17 +153,55 @@ Widget plexVideoControlsBuilder( ); } +@visibleForTesting +({ + List availableVersions, + bool serverSupportsTranscoding, + bool isTranscoding, + List sourceAudioTracks, + int? selectedAudioStreamId, + bool canSwitch, +}) +effectiveVersionQualityControls({ + required bool isOfflinePlayback, + required List availableVersions, + required bool serverSupportsTranscoding, + required bool isTranscoding, + required List sourceAudioTracks, + required int? selectedAudioStreamId, +}) { + if (isOfflinePlayback) { + return ( + availableVersions: const [], + serverSupportsTranscoding: false, + isTranscoding: false, + sourceAudioTracks: const [], + selectedAudioStreamId: null, + canSwitch: false, + ); + } + return ( + availableVersions: availableVersions, + serverSupportsTranscoding: serverSupportsTranscoding, + isTranscoding: isTranscoding, + sourceAudioTracks: sourceAudioTracks, + selectedAudioStreamId: selectedAudioStreamId, + canSwitch: true, + ); +} + class PlexVideoControls extends StatefulWidget { final Player player; - final PlexMetadata metadata; + final MediaItem metadata; final VoidCallback? onNext; final VoidCallback? onPrevious; - final List availableVersions; + final List availableVersions; final int selectedMediaIndex; final TranscodeQualityPreset selectedQualityPreset; final bool serverSupportsTranscoding; final bool isTranscoding; - final List sourceAudioTracks; + final bool isOfflinePlayback; + final List sourceAudioTracks; final int? selectedAudioStreamId; final int boxFitMode; final VoidCallback? onTogglePIPMode; @@ -200,7 +242,7 @@ class PlexVideoControls extends StatefulWidget { final VoidCallback? onShaderChanged; /// Optional callback that returns thumbnail image bytes for a given timestamp. - final Uint8List? Function(Duration time)? thumbnailDataBuilder; + final ScrubFrame? Function(Duration time)? thumbnailDataBuilder; /// Whether this is a live TV stream (disables seek, progress, etc.) final bool isLive; @@ -247,6 +289,7 @@ class PlexVideoControls extends StatefulWidget { this.selectedQualityPreset = TranscodeQualityPreset.original, this.serverSupportsTranscoding = false, this.isTranscoding = false, + this.isOfflinePlayback = false, this.sourceAudioTracks = const [], this.selectedAudioStreamId, this.boxFitMode = 0, @@ -287,7 +330,7 @@ class _PlexVideoControlsState extends State with WindowListen bool _showControls = true; bool _forceShowControls = false; bool _isLoadingExtras = false; - List _chapters = []; + List _chapters = []; bool _chaptersLoaded = false; Timer? _hideTimer; bool _isFullscreen = false; @@ -308,11 +351,6 @@ class _PlexVideoControlsState extends State with WindowListen // GlobalKey to access DesktopVideoControls state for focus management final GlobalKey _desktopControlsKey = GlobalKey(); - /// Get the correct PlexClient for this metadata's server - PlexClient _getClientForMetadata() { - return context.getClientForServer(widget.metadata.serverId!); - } - // Double-tap feedback state bool _showDoubleTapFeedback = false; double _doubleTapFeedbackOpacity = 0.0; @@ -327,8 +365,8 @@ class _PlexVideoControlsState extends State with WindowListen // Seek throttle late final Throttle _seekThrottle; // Current marker state - PlexMarker? _currentMarker; - List _markers = []; + MediaMarker? _currentMarker; + List _markers = []; bool _markersLoaded = false; // Playback state subscription for auto-hide timer StreamSubscription? _playingSubscription; @@ -469,7 +507,7 @@ class _PlexVideoControlsState extends State with WindowListen return; } - PlexMarker? foundMarker; + MediaMarker? foundMarker; for (final marker in _markers) { if (marker.containsPosition(position)) { foundMarker = marker; @@ -484,7 +522,7 @@ class _PlexVideoControlsState extends State with WindowListen } /// Updates the current marker and manages auto-skip/focus behavior. - void _updateCurrentMarker(PlexMarker? foundMarker) { + void _updateCurrentMarker(MediaMarker? foundMarker) { setState(() { _currentMarker = foundMarker; _skipButtonDismissed = false; @@ -569,7 +607,7 @@ class _PlexVideoControlsState extends State with WindowListen _cancelSkipButtonDismissTimer(); } - void _startAutoSkipTimer(PlexMarker marker) { + void _startAutoSkipTimer(MediaMarker marker) { _cancelAutoSkipTimer(); final shouldAutoSkip = (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro); @@ -634,7 +672,7 @@ class _PlexVideoControlsState extends State with WindowListen } /// Check if auto-skip should be active for the current marker - bool _shouldAutoSkipForMarker(PlexMarker marker) { + bool _shouldAutoSkipForMarker(MediaMarker marker) { return (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro); } @@ -1081,50 +1119,51 @@ class _PlexVideoControlsState extends State with WindowListen if (_isLoadingExtras) return; _isLoadingExtras = true; - try { - appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.ratingKey} (forceRefresh=$forceRefresh)'); - final client = _getClientForMetadata(); - appLogger.d('_loadPlaybackExtras: got client with serverId=${client.serverId}'); + final serverId = widget.metadata.serverId; + // Read providers before any await — `context` after an async gap is + // a lint trigger and can crash if the widget unmounts mid-load. + final client = serverId != null ? context.tryGetMediaClientForServer(serverId) : null; + final database = context.read(); + if (client == null) { + await _loadPlaybackExtrasFromCacheOnly(cacheServerId: await _resolveCacheServerId(database)); + _isLoadingExtras = false; + return; + } + try { + appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.id} (forceRefresh=$forceRefresh)'); final settings = await SettingsService.getInstance(); final introPattern = settings.read(SettingsService.introPattern); final creditsPattern = settings.read(SettingsService.creditsPattern); - final extras = await client.getPlaybackExtras( - widget.metadata.ratingKey, + // Backend-aware: Plex hits /library/metadata?includeChapters=1; Jellyfin + // pulls Chapters from /Users/{userId}/Items/{id}. + final extras = await client.fetchPlaybackExtras( + widget.metadata.id, introPattern: introPattern, creditsPattern: creditsPattern, forceRefresh: forceRefresh, ); appLogger.d('_loadPlaybackExtras: got ${extras.chapters.length} chapters'); - if (mounted) { - setState(() { - _chapters = extras.chapters; - _markers = extras.markers; - _chaptersLoaded = true; - _markersLoaded = true; - }); - } + _applyPlaybackExtras(extras); } catch (e, stack) { - // Fallback: try to load from cache directly (for offline playback) - appLogger.d('_loadPlaybackExtras: client unavailable, trying cache fallback'); - final serverId = widget.metadata.serverId; - if (serverId != null) { - final cacheKey = '/library/metadata/${widget.metadata.ratingKey}'; - final cached = await PlexApiCache.instance.get(serverId, cacheKey); - if (cached != null) { - final extras = await _parsePlaybackExtrasFromCache(cached); + // Fallback: serve extras from the per-backend cache (for offline + // playback after the network call threw). + appLogger.d('_loadPlaybackExtras: network path failed, trying cache fallback'); + try { + final settings = await SettingsService.getInstance(); + final extras = await client.fetchPlaybackExtrasFromCacheOnly( + widget.metadata.id, + introPattern: settings.read(SettingsService.introPattern), + creditsPattern: settings.read(SettingsService.creditsPattern), + ); + if (extras != null) { appLogger.d('_loadPlaybackExtras: loaded ${extras.chapters.length} chapters from cache'); - if (mounted) { - setState(() { - _chapters = extras.chapters; - _markers = extras.markers; - _chaptersLoaded = true; - _markersLoaded = true; - }); - } + _applyPlaybackExtras(extras); return; } + } catch (cacheError) { + appLogger.d('_loadPlaybackExtras: cache fallback failed', error: cacheError); } appLogger.e('_loadPlaybackExtras failed', error: e, stackTrace: stack); } finally { @@ -1132,66 +1171,70 @@ class _PlexVideoControlsState extends State with WindowListen } } - /// Parse PlaybackExtras from cached API response (for offline playback) - Future _parsePlaybackExtrasFromCache(Map cached) async { - final chapters = []; - final markers = []; - - final metadataJson = PlexCacheParser.extractFirstMetadata(cached); - if (metadataJson != null) { - // Parse chapters - if (metadataJson['Chapter'] != null) { - for (final chapter in metadataJson['Chapter'] as List) { - chapters.add( - PlexChapter( - id: chapter['id'] as int, - index: chapter['index'] as int?, - startTimeOffset: chapter['startTimeOffset'] as int?, - endTimeOffset: chapter['endTimeOffset'] as int?, - title: chapter['tag'] as String?, - thumb: chapter['thumb'] as String?, - ), - ); - } - } - - // Parse markers - if (metadataJson['Marker'] != null) { - for (final marker in metadataJson['Marker'] as List) { - markers.add( - PlexMarker( - id: marker['id'] as int, - type: marker['type'] as String, - startTimeOffset: marker['startTimeOffset'] as int, - endTimeOffset: marker['endTimeOffset'] as int, - ), - ); - } - } + Future _loadPlaybackExtrasFromCacheOnly({required String? cacheServerId}) async { + if (cacheServerId == null) { + appLogger.w('_loadPlaybackExtras: no client or cache scope for server ${widget.metadata.serverId}'); + return; } + try { + final settings = await SettingsService.getInstance(); + final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras( + backend: widget.metadata.backend, + cacheServerId: cacheServerId, + itemId: widget.metadata.id, + introPattern: settings.read(SettingsService.introPattern), + creditsPattern: settings.read(SettingsService.creditsPattern), + ); + if (extras != null) _applyPlaybackExtras(extras); + } catch (e) { + appLogger.d('_loadPlaybackExtras: cache-only path failed', error: e); + } + } - final settings = await SettingsService.getInstance(); - return PlaybackExtras.withChapterFallback( - chapters: chapters, - markers: markers, - introPatternStr: settings.read(SettingsService.introPattern), - creditsPatternStr: settings.read(SettingsService.creditsPattern), - ); + Future _resolveCacheServerId(AppDatabase database) async { + final serverId = widget.metadata.serverId; + if (serverId == null) return null; + try { + final row = await (database.select( + database.downloadedMedia, + )..where((tbl) => tbl.globalKey.equals(buildGlobalKey(serverId, widget.metadata.id)))).getSingleOrNull(); + return row?.clientScopeId ?? serverId; + } catch (_) { + return serverId; + } + } + + void _applyPlaybackExtras(PlaybackExtras extras) { + if (!mounted) return; + setState(() { + _chapters = extras.chapters; + _markers = extras.markers; + _chaptersLoaded = true; + _markersLoaded = true; + }); } TrackControlsState _buildTrackControlsState({ required PlaybackStateProvider playbackState, required VoidCallback? onToggleAlwaysOnTop, }) { - return TrackControlsState( + final versionQuality = effectiveVersionQualityControls( + isOfflinePlayback: widget.isOfflinePlayback, availableVersions: widget.availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - selectedQualityPreset: widget.selectedQualityPreset, serverSupportsTranscoding: widget.serverSupportsTranscoding, isTranscoding: widget.isTranscoding, sourceAudioTracks: widget.sourceAudioTracks, selectedAudioStreamId: widget.selectedAudioStreamId, - sourceDurationMs: widget.metadata.duration, + ); + return TrackControlsState( + availableVersions: versionQuality.availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + selectedQualityPreset: widget.selectedQualityPreset, + serverSupportsTranscoding: versionQuality.serverSupportsTranscoding, + isTranscoding: versionQuality.isTranscoding, + sourceAudioTracks: versionQuality.sourceAudioTracks, + selectedAudioStreamId: versionQuality.selectedAudioStreamId, + sourceDurationMs: widget.metadata.durationMs, boxFitMode: widget.boxFitMode, audioSyncOffset: _audioSyncOffset, subtitleSyncOffset: _subtitleSyncOffset, @@ -1205,9 +1248,9 @@ class _PlexVideoControlsState extends State with WindowListen onToggleScreenLock: _toggleScreenLock, onToggleFullscreen: _toggleFullscreen, onToggleAlwaysOnTop: onToggleAlwaysOnTop, - onSwitchVersion: (i) => _switchVersionAndQuality(newMediaIndex: i), - onSwitchQualityPreset: (p) => _switchVersionAndQuality(newPreset: p), - onSwitchAudioStreamId: (id) => _switchVersionAndQuality(newAudioStreamId: id), + onSwitchVersion: versionQuality.canSwitch ? (i) => _switchVersionAndQuality(newMediaIndex: i) : null, + onSwitchQualityPreset: versionQuality.canSwitch ? (p) => _switchVersionAndQuality(newPreset: p) : null, + onSwitchAudioStreamId: versionQuality.canSwitch ? (id) => _switchVersionAndQuality(newAudioStreamId: id) : null, onAudioTrackChanged: widget.onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, @@ -1237,12 +1280,32 @@ class _PlexVideoControlsState extends State with WindowListen subtitlesVisible: _subtitlesVisible, showQueueButton: playbackState.isQueueActive, onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, - ratingKey: widget.metadata.ratingKey, + ratingKey: widget.metadata.id, mediaTitle: widget.metadata.title, onSubtitleDownloaded: _onSubtitleDownloaded, + // Plex proxies OpenSubtitles via its server-side plugin; Jellyfin + // doesn't expose an equivalent so the Search Subtitles tile is hidden + // for Jellyfin items. The check uses the registered client type for + // this metadata's serverId. + subtitleSearchSupported: _isPlexBackedMetadata(), ); } + /// True when the active server supports external subtitle search (Plex + /// today). Requires a server id because the download callback needs the + /// Plex client/token for that server. + bool _isPlexBackedMetadata() { + try { + final serverId = widget.metadata.serverId; + if (serverId == null) return false; + final manager = context.read().serverManager; + final c = manager.getClient(serverId); + return c?.capabilities.externalSubtitleSearch ?? false; + } catch (_) { + return false; + } + } + Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) { final playbackState = context.watch(); final trackControlsState = _buildTrackControlsState( @@ -2496,7 +2559,7 @@ class _PlexVideoControlsState extends State with WindowListen } /// Switch to a different media version - void _onQueueItemSelected(PlexMetadata item) { + void _onQueueItemSelected(MediaItem item) { final videoPlayerState = context.findAncestorStateOfType(); videoPlayerState?.navigateToQueueItem(item); } @@ -2504,8 +2567,16 @@ class _PlexVideoControlsState extends State with WindowListen Future _onSubtitleDownloaded() async { if (!mounted) return; + // Plex-only: the OpenSubtitles polling flow uses [getVideoPlaybackData] + // and the Plex token. Jellyfin has no analogue and the entry point + // (`subtitleSearchSupported`) is already gated on backend, but guard + // here too in case a future caller wires the same handler elsewhere. + if (widget.metadata.backend != MediaBackend.plex) return; + final serverId = widget.metadata.serverId; + if (serverId == null) return; + try { - final client = _getClientForMetadata(); + final client = context.getPlexClientForServer(serverId); final token = client.config.token; if (token == null) return; @@ -2516,23 +2587,23 @@ class _PlexVideoControlsState extends State with WindowListen final existingUris = widget.player.state.tracks.subtitle.where((t) => t.uri != null).map((t) => t.uri!).toSet(); final deadline = DateTime.now().add(const Duration(seconds: 15)); - PlexSubtitleTrack? newTrack; + MediaSubtitleTrack? newTrack; String? newUrl; - PlexMediaInfo? latestInfo; + MediaSourceInfo? latestInfo; while (mounted && DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(seconds: 2)); if (!mounted) return; try { - final data = await client.getVideoPlaybackData(widget.metadata.ratingKey); + final data = await client.getVideoPlaybackData(widget.metadata.id); if (!mounted) return; if (data.mediaInfo == null) continue; latestInfo = data.mediaInfo; for (final plexTrack in data.mediaInfo!.subtitleTracks) { if (!plexTrack.isExternal) continue; - final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token); + final url = client.buildExternalSubtitleUrl(plexTrack); if (url == null) continue; if (existingUris.any((uri) => uri.contains(plexTrack.key!))) continue; @@ -2593,7 +2664,7 @@ class _PlexVideoControlsState extends State with WindowListen if (isVersionChange) { final settingsService = await SettingsService.getInstance(); - final seriesKey = widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey; + final seriesKey = widget.metadata.grandparentId ?? widget.metadata.id; await settingsService.write(SettingsService.mediaVersionPreferences, { ...settingsService.read(SettingsService.mediaVersionPreferences), seriesKey: effectiveMediaIndex, @@ -2618,7 +2689,7 @@ class _PlexVideoControlsState extends State with WindowListen context, PageRouteBuilder( pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( - metadata: widget.metadata.copyWith(viewOffset: currentPosition.inMilliseconds), + metadata: widget.metadata.copyWith(viewOffsetMs: currentPosition.inMilliseconds), selectedMediaIndex: effectiveMediaIndex, selectedQualityPreset: effectivePreset, selectedAudioStreamId: effectiveAudioStreamId, diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index dfd84e95..a1a508e7 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -8,26 +8,26 @@ import 'package:provider/provider.dart'; import '../../../focus/dpad_navigator.dart'; import '../../../focus/focusable_wrapper.dart'; import '../../../i18n/strings.g.dart'; +import '../../../media/media_item.dart'; +import '../../../media/media_server_client.dart'; import '../../../mpv/mpv.dart'; -import '../../../models/plex_media_info.dart'; -import '../../../models/plex_metadata.dart'; +import '../../../media/media_source_info.dart'; import '../../../providers/playback_state_provider.dart'; import '../../../services/download_storage_service.dart'; -import '../../../services/plex_client.dart'; import '../../../utils/formatters.dart'; import '../../../utils/player_utils.dart'; import '../../../utils/provider_extensions.dart'; import '../../app_icon.dart'; -import '../../plex_optimized_image.dart'; +import '../../optimized_media_image.dart'; /// Horizontal scrollable strip of chapter/queue items shown on swipe-up. class ContentStrip extends StatefulWidget { final Player player; - final List chapters; + final List chapters; final bool chaptersLoaded; final String? serverId; final bool showQueueTab; - final Function(PlexMetadata)? onQueueItemSelected; + final Function(MediaItem)? onQueueItemSelected; final Function(Duration position)? onSeekCompleted; /// Whether to use dpad/focus-based navigation (TV mode). @@ -137,7 +137,7 @@ class ContentStripState extends State { final playbackState = context.read(); final items = playbackState.loadedItems; final currentItemID = playbackState.currentPlayQueueItemID; - final idx = items.indexWhere((item) => item.playQueueItemID == currentItemID); + final idx = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); return idx >= 0 ? idx : null; } catch (_) { return null; @@ -234,8 +234,8 @@ class ContentStripState extends State { return KeyEventResult.ignored; } - PlexClient? _tryGetClient(BuildContext context, String? serverId) { - return context.tryGetClientForServer(serverId); + MediaServerClient? _tryGetClient(BuildContext context, String? serverId) { + return context.tryGetMediaClientForServer(serverId); } double _itemWidth(bool isTablet) => isTablet ? 212.0 : 132.0; // thumb + 12 padding @@ -327,7 +327,7 @@ class ContentStripState extends State { initialData: widget.player.state.position, builder: (context, positionSnapshot) { final currentPosition = positionSnapshot.data ?? Duration.zero; - final currentChapterIndex = PlexChapter.indexAtPosition(currentPosition, widget.chapters); + final currentChapterIndex = MediaChapter.indexAtPosition(currentPosition, widget.chapters); // Auto-scroll to current chapter on first build if (!_hasAutoScrolledChapters && currentChapterIndex != null) { @@ -363,7 +363,7 @@ class ContentStripState extends State { isCurrent: isCurrent, isTablet: isTablet, thumbnail: chapter.thumb != null - ? PlexOptimizedImage.thumb( + ? OptimizedMediaImage.thumb( client: _tryGetClient(context, widget.serverId), imagePath: chapter.thumb, localFilePath: localThumbPath, @@ -413,7 +413,7 @@ class ContentStripState extends State { builder: (context, playbackState, _) { final items = playbackState.loadedItems; final currentItemID = playbackState.currentPlayQueueItemID; - final currentIndex = items.indexWhere((item) => item.playQueueItemID == currentItemID); + final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); if (!_hasAutoScrolledQueue && currentIndex >= 0) { _hasAutoScrolledQueue = true; @@ -435,14 +435,9 @@ class ContentStripState extends State { padding: EdgeInsets.symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), itemBuilder: (context, index) { final item = items[index]; - final isCurrent = item.playQueueItemID == currentItemID; + final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; - PlexClient? client; - if (item.serverId != null) { - try { - client = context.tryGetClientForServer(item.serverId); - } catch (_) {} - } + final client = item.serverId != null ? context.tryGetMediaClientForServer(item.serverId) : null; void onTap() => widget.onQueueItemSelected?.call(item); @@ -450,10 +445,10 @@ class ContentStripState extends State { context: context, isCurrent: isCurrent, isTablet: isTablet, - thumbnail: item.thumb != null - ? PlexOptimizedImage.thumb( + thumbnail: item.thumbPath != null + ? OptimizedMediaImage.thumb( client: client, - imagePath: item.thumb, + imagePath: item.thumbPath, width: thumbWidth, height: thumbHeight, fit: BoxFit.cover, @@ -461,7 +456,7 @@ class ContentStripState extends State { const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), ) : null, - title: item.title!, + title: item.title ?? '', subtitle: _buildQueueSubtitle(item), onTap: onTap, ); @@ -492,13 +487,16 @@ class ContentStripState extends State { ); } - String _buildQueueSubtitle(PlexMetadata item) { + String _buildQueueSubtitle(MediaItem item) { if (item.grandparentTitle != null && item.parentIndex != null && item.index != null) { return '${item.grandparentTitle} \u00b7 S${item.parentIndex}E${item.index}'; } if (item.grandparentTitle != null) return item.grandparentTitle!; - if (item.year != null) return item.editionTitle != null ? '${item.year} · ${item.editionTitle}' : '${item.year}'; - return item.mediaType.name; + if (item.year != null) { + final edition = item.editionTitle; + return edition != null ? '${item.year} · $edition' : '${item.year}'; + } + return item.kind.name; } Widget _buildStripItem({ diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index d337d80c..d4fcd0a6 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -1,11 +1,10 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; -import '../../../models/plex_media_info.dart'; +import '../../../media/media_source_info.dart'; import '../../../mpv/models.dart'; import '../../../i18n/strings.g.dart'; import '../../../focus/focusable_wrapper.dart'; import '../../../focus/input_mode_tracker.dart'; +import '../../../services/scrub_preview_source.dart'; import '../../../utils/formatters.dart'; import '../painters/buffer_range_painter.dart'; @@ -17,7 +16,7 @@ class TimelineSlider extends StatefulWidget { final Duration position; final Duration duration; final List bufferRanges; - final List chapters; + final List chapters; final bool chaptersLoaded; final ValueChanged onSeek; final ValueChanged onSeekEnd; @@ -34,8 +33,10 @@ class TimelineSlider extends StatefulWidget { /// Whether the slider is enabled for interaction. final bool enabled; - /// Optional callback that returns thumbnail image bytes for a given timestamp. - final Uint8List? Function(Duration time)? thumbnailDataBuilder; + /// Optional callback that returns a scrub-preview frame for a given timestamp. + /// Plex returns [BytesScrubFrame] (BIF JPEG bytes); Jellyfin returns + /// [SheetScrubFrame] (sprite-sheet URL + crop). The tooltip renders both. + final ScrubFrame? Function(Duration time)? thumbnailDataBuilder; /// When true, show the preview thumbnail at the current playback position. /// Intended for sustained dpad/keyboard seeking where the decoder cannot @@ -72,14 +73,13 @@ class _TimelineSliderState extends State { static const _sliderPadding = 0.0; static const _thumbWidth = 160.0; - static const _thumbHeight = 90.0; Widget _buildTooltip(double sliderWidth, double pixelX, Duration time) { - final thumbnailData = widget.thumbnailDataBuilder?.call(time); - final hasThumbnail = thumbnailData != null; + final frame = widget.thumbnailDataBuilder?.call(time); + final hasThumbnail = frame != null; final tooltipWidth = hasThumbnail ? _thumbWidth : 64.0; - final tooltipHeight = hasThumbnail ? _thumbHeight : 26.0; + final tooltipHeight = hasThumbnail ? _thumbWidth / frame.aspectRatio : 26.0; final tooltipTop = -(tooltipHeight + 2.0); // Center tooltip on cursor, clamped so it stays within the slider bounds @@ -108,8 +108,8 @@ class _TimelineSliderState extends State { child: IgnorePointer( child: hasThumbnail ? Container( - width: _thumbWidth, - height: _thumbHeight, + width: tooltipWidth, + height: tooltipHeight, decoration: BoxDecoration( color: Colors.black, borderRadius: const BorderRadius.all(Radius.circular(6)), @@ -119,12 +119,7 @@ class _TimelineSliderState extends State { child: Stack( fit: StackFit.expand, children: [ - Image.memory( - thumbnailData, - fit: BoxFit.cover, - gaplessPlayback: true, - errorBuilder: (_, _, _) => const SizedBox.shrink(), - ), + _ScrubFrameView(frame: frame), Positioned(bottom: 4, left: 0, right: 0, child: Center(child: timeLabel)), ], ), @@ -255,3 +250,52 @@ class _TimelineSliderState extends State { ); } } + +class _ScrubFrameView extends StatelessWidget { + final ScrubFrame frame; + const _ScrubFrameView({required this.frame}); + + @override + Widget build(BuildContext context) { + final f = frame; + switch (f) { + case BytesScrubFrame(): + return Image.memory( + f.bytes, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ); + case SheetScrubFrame(): + // The parent tooltip box matches the source tile aspect (see + // `tooltipHeight = tooltipWidth / frame.aspectRatio` above), so each + // source tile maps 1:1 to the box without distortion or cropping. + return LayoutBuilder( + builder: (context, constraints) { + final tileW = constraints.maxWidth; + final tileH = constraints.maxHeight; + final sheetW = tileW * f.sheetColumns; + final sheetH = tileH * f.sheetRows; + return ClipRect( + child: OverflowBox( + maxWidth: sheetW, + maxHeight: sheetH, + alignment: Alignment.topLeft, + child: Transform.translate( + offset: Offset(-f.tileColumn * tileW, -f.tileRow * tileH), + child: Image( + image: f.sheet, + width: sheetW, + height: sheetH, + fit: BoxFit.fill, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + ); + }, + ); + } + } +} diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 31691f10..d952c73c 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -3,14 +3,14 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../../../focus/dpad_navigator.dart'; +import '../../../media/media_item.dart'; +import '../../../media/media_version.dart'; import '../../../mpv/mpv.dart'; -import '../../../models/plex_media_info.dart'; -import '../../../models/plex_media_version.dart'; +import '../../../media/media_source_info.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../utils/platform_detector.dart'; import '../../../i18n/strings.g.dart'; import '../../../widgets/overlay_sheet.dart'; -import '../../../models/plex_metadata.dart'; import '../models/track_controls_state.dart'; import '../../../models/transcode_quality_preset.dart'; import '../sheets/chapter_sheet.dart'; @@ -25,7 +25,7 @@ import '../video_control_button.dart'; /// Row of track and chapter control buttons for the video player class TrackChapterControls extends StatelessWidget { final Player player; - final List chapters; + final List chapters; final bool chaptersLoaded; final TrackControlsState trackControlsState; final Function(Duration position)? onSeekCompleted; @@ -63,7 +63,7 @@ class TrackChapterControls extends StatelessWidget { this.hideChaptersAndQueue = false, }); - List get availableVersions => trackControlsState.availableVersions; + List get availableVersions => trackControlsState.availableVersions; int get selectedMediaIndex => trackControlsState.selectedMediaIndex; TranscodeQualityPreset get selectedQualityPreset => trackControlsState.selectedQualityPreset; bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding; @@ -98,7 +98,7 @@ class TrackChapterControls extends StatelessWidget { bool get isLive => trackControlsState.isLive; bool get subtitlesVisible => trackControlsState.subtitlesVisible; bool get showQueueButton => trackControlsState.showQueueButton; - Function(PlexMetadata)? get onQueueItemSelected => trackControlsState.onQueueItemSelected; + Function(MediaItem)? get onQueueItemSelected => trackControlsState.onQueueItemSelected; String get ratingKey => trackControlsState.ratingKey; String? get mediaTitle => trackControlsState.mediaTitle; Future Function()? get onSubtitleDownloaded => trackControlsState.onSubtitleDownloaded; @@ -273,6 +273,7 @@ class TrackChapterControls extends StatelessWidget { sourceAudioTracks: trackControlsState.sourceAudioTracks, selectedAudioStreamId: trackControlsState.selectedAudioStreamId, onSwitchAudioStreamId: trackControlsState.onSwitchAudioStreamId, + subtitleSearchSupported: trackControlsState.subtitleSearchSupported, ), ) .whenComplete(() => onStartAutoHide?.call()); @@ -342,12 +343,21 @@ class TrackChapterControls extends StatelessWidget { (onSwitchVersion != null || onSwitchQualityPreset != null); if (showVersionQuality) { final currentIndex = buttonIndex; + // Tooltip narrows to whichever column the sheet will actually + // render — Jellyfin items only show the version list, so calling + // the button "Version & Quality" implies a quality picker that + // isn't there. + final buttonLabel = serverSupportsTranscoding + ? (availableVersions.length > 1 + ? t.videoControls.versionQualityButton + : t.videoControls.qualityColumnHeader) + : t.videoControls.versionColumnHeader; buttons.add( _buildTrackButton( buttonIndex: currentIndex, icon: Symbols.high_quality_rounded, - tooltip: t.videoControls.versionQualityButton, - semanticLabel: t.videoControls.versionQualityButton, + tooltip: buttonLabel, + semanticLabel: buttonLabel, tracks: tracks, isMobile: isMobile, isDesktop: isDesktop, diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart index 9d8a30c2..12412955 100644 --- a/lib/widgets/video_controls/widgets/video_controls_header.dart +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:plezy/utils/formatters.dart'; -import '../../../models/plex_metadata.dart'; +import '../../../media/media_item.dart'; import '../../../i18n/strings.g.dart'; import '../../../watch_together/widgets/watch_together_overlay.dart'; import '../../../watch_together/providers/watch_together_provider.dart'; @@ -22,7 +22,7 @@ enum VideoHeaderStyle { /// Displays the video title with optional series/episode information. /// Supports both single-line (macOS) and multi-line (other platforms) layouts. class VideoControlsHeader extends StatelessWidget { - final PlexMetadata metadata; + final MediaItem metadata; final VideoHeaderStyle style; /// Optional trailing widget (e.g., track/chapter controls) @@ -91,8 +91,8 @@ class VideoControlsHeader extends StatelessWidget { secondLineParts.add(metadata.title!); } - if (metadata.duration != null) { - secondLineParts.add(formatDurationTextual(metadata.duration!)); + if (metadata.durationMs != null) { + secondLineParts.add(formatDurationTextual(metadata.durationMs!)); } return Column( diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 0b511029..2c1565c9 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -1,9 +1,8 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import '../../../mpv/mpv.dart'; -import '../../../models/plex_media_info.dart'; +import '../../../media/media_source_info.dart'; +import '../../../services/scrub_preview_source.dart'; import '../../../utils/formatters.dart'; import 'timeline_slider.dart'; @@ -14,7 +13,7 @@ import 'timeline_slider.dart'; /// layout (timestamps beside slider) and vertical layout (timestamps below slider). class VideoTimelineBar extends StatelessWidget { final Player player; - final List chapters; + final List chapters; final bool chaptersLoaded; final ValueChanged onSeek; final ValueChanged onSeekEnd; @@ -39,7 +38,7 @@ class VideoTimelineBar extends StatelessWidget { final bool showFinishTime; /// Optional callback that returns thumbnail image bytes for a given timestamp. - final Uint8List? Function(Duration time)? thumbnailDataBuilder; + final ScrubFrame? Function(Duration time)? thumbnailDataBuilder; /// When true, show the preview thumbnail at the current playback position /// (used during sustained dpad/keyboard key-repeat seeking). diff --git a/pubspec.lock b/pubspec.lock index 61991f92..02b59194 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "10.0.1" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: "825071d553c4aef2252196d46a665fbd8e0cb06de07725f25d1b29bd18d65fff" + sha256: "7df504f0c9d6891bacc9f73a5a8c5f6fe4fc49c90ec8e3379916372906ba0b32" url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "0.14.1" ansicolor: dependency: transitive description: @@ -231,7 +231,7 @@ packages: source: hosted version: "4.11.1" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -314,10 +314,10 @@ packages: dependency: "direct dev" description: name: dart_code_linter - sha256: "1b53722d9933a5f5d4580acc29c7f16b1fde66d21d1ecf7bb2a811caf3a42b42" + sha256: "8ece88f710621ca1c40b6c344b316d78bb2269d728d37d2a44f19a81d9d2cb93" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "4.0.2" dart_discord_presence: dependency: "direct main" description: @@ -330,10 +330,10 @@ packages: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.7" dbus: dependency: transitive description: @@ -606,10 +606,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3 + sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e" url: "https://pub.dev" source: hosted - version: "6.11.2" + version: "6.11.4" leak_tracker: dependency: transitive description: @@ -1143,10 +1143,10 @@ packages: dependency: transitive description: name: source_helper - sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + sha256: "1d3b229b2934034fb2e691fbb3d53e0f75a4af7b1407f88425ed8f209bcb1b8f" url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.11" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b86b65b1..d0dffcfc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,7 @@ dependencies: cupertino_http: ^2.4.0 cronet_http: ^1.6.0 win_http: ^0.2.0 + collection: ^1.18.0 dev_dependencies: flutter_test: @@ -77,7 +78,7 @@ dev_dependencies: build_runner: ^2.13.0 json_serializable: ^6.7.1 slang_build_runner: ^4.14.0 - dart_code_linter: ^3.2.1 + dart_code_linter: ^4.0.2 drift_dev: ^2.28.3 shared_preferences_platform_interface: ^2.4.0 path_provider_platform_interface: ^2.1.0 @@ -119,6 +120,8 @@ flutter: assets: - assets/plezy.png - assets/plezy_adaptive_foreground.svg + - assets/plex_chevron.svg + - assets/jellyfin_icon.svg - assets/trakt_circlemark.svg - assets/mal_mark.svg - assets/anilist_mark.svg diff --git a/scripts/ci_checks.sh b/scripts/ci_checks.sh index 65f3ca94..ae35047e 100755 --- a/scripts/ci_checks.sh +++ b/scripts/ci_checks.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -uo pipefail +# Git sets GIT_DIR (and friends) for hook invocations. Inside `flutter pub +# run`, that leaks into Flutter's own SDK-version probe (`git describe` from +# Flutter's checkout) and makes Flutter misreport its version as +# `1.35.1-0.0.pre-1`, which then fails dependency resolution. Strip those +# vars so the script behaves the same when invoked from a hook as it does +# from a plain shell. +unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_PREFIX + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$ROOT" @@ -74,7 +82,7 @@ if ! have_dart_code_linter; then skip "dart_code_linter unresolved — run 'flutter pub get'" else out="$(mktemp)" - dart run dart_code_linter:metrics check-unused-code lib >"$out" 2>&1 || true + flutter pub run dart_code_linter:metrics check-unused-code lib >"$out" 2>&1 || true if grep -qi "no unused code found" "$out"; then ok "none" else @@ -91,7 +99,7 @@ if ! have_dart_code_linter; then skip "dart_code_linter unresolved — run 'flutter pub get'" else out="$(mktemp)" - dart run dart_code_linter:metrics check-unused-files lib >"$out" 2>&1 || true + flutter pub run dart_code_linter:metrics check-unused-files lib >"$out" 2>&1 || true if grep -qi "no unused files found" "$out"; then ok "none" else diff --git a/scripts/generate_ducet_ranks.dart b/scripts/generate_ducet_ranks.dart index 3bd278aa..f3f852a1 100644 --- a/scripts/generate_ducet_ranks.dart +++ b/scripts/generate_ducet_ranks.dart @@ -5,6 +5,7 @@ /// /// Downloads the files automatically if not provided. library; + import 'dart:io'; // --------------------------------------------------------------------------- diff --git a/slang.yaml b/slang.yaml index b4014882..c07ac0dc 100644 --- a/slang.yaml +++ b/slang.yaml @@ -1,3 +1,8 @@ input_directory: lib/i18n input_file_pattern: .i18n.json output_directory: lib/i18n +# Fall back to base (en) for any key missing in a non-base locale. +# Without this, every locale would need every key declared up-front and +# the generator would emit `class is missing implementations for…` +# errors when a new English string lands. +fallback_strategy: base_locale diff --git a/test/connection/connection_bootstrap_test.dart b/test/connection/connection_bootstrap_test.dart new file mode 100644 index 00000000..7da48604 --- /dev/null +++ b/test/connection/connection_bootstrap_test.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_bootstrap.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/services/server_registry.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + late AppDatabase db; + late ConnectionRegistry registry; + late ProfileRegistry profileRegistry; + late StorageService storage; + late ServerRegistry serverRegistry; + late ConnectionBootstrap bootstrap; + late List fetchedHomeUsers; + late Map? fetchedUserInfo; + + setUp(() async { + resetSharedPreferencesForTest(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + registry = ConnectionRegistry(db); + profileRegistry = ProfileRegistry(db); + storage = await StorageService.getInstance(); + serverRegistry = ServerRegistry(storage); + fetchedHomeUsers = const []; + fetchedUserInfo = null; + bootstrap = ConnectionBootstrap( + storage: storage, + connectionRegistry: registry, + serverRegistry: serverRegistry, + profileRegistry: profileRegistry, + plexHomeUserFetcher: (_) async => fetchedHomeUsers, + plexUserInfoFetcher: (_) async => fetchedUserInfo ?? (throw StateError('user info unavailable')), + ); + }); + + tearDown(() async { + await db.close(); + }); + + group('ConnectionBootstrap.migrateLegacyPlexAccount', () { + test('returns null when no legacy Plex token is stored', () async { + final result = await bootstrap.migrateLegacyPlexAccount(); + expect(result, isNull); + expect(await registry.list(), isEmpty); + }); + + test('migrates a stored Plex token into a PlexAccountConnection', () async { + await storage.prefs.setString('plex_token', 'legacy-token-abc'); + + final result = await bootstrap.migrateLegacyPlexAccount(); + + expect(result, isA()); + expect(result!.accountToken, 'legacy-token-abc'); + // The clientIdentifier comes from StorageService — non-empty after + // first call to getOrCreateClientIdentifier. + expect(result.clientIdentifier, isNotEmpty); + // Account label falls back to "Plex" when the user-info call fails + // (no network in the test environment). + expect(result.accountLabel, isNotEmpty); + + // The migrated row is now in the registry. + final stored = await registry.list(); + expect(stored.length, 1); + expect(stored.single, isA()); + expect((stored.single as PlexAccountConnection).accountToken, 'legacy-token-abc'); + }); + + test('keeps the legacy plex_token until full bootstrap succeeds', () async { + await storage.prefs.setString('plex_token', 'legacy-token-xyz'); + + final first = await bootstrap.migrateLegacyPlexAccount(); + final second = await bootstrap.migrateLegacyPlexAccount(); + + expect(first, isA()); + // The token is cleared only after run() has also hydrated/selects a + // virtual Plex profile. Until then, keeping it makes failed profile + // hydration retryable. + expect(second, isA()); + expect(storage.prefs.getString('plex_token'), 'legacy-token-xyz'); + expect((await registry.list()).length, 1); + }); + + test('preserves stable id derived from the device clientIdentifier', () async { + await storage.prefs.setString('plex_token', 'legacy-token-1'); + final clientId = await storage.getOrCreateClientIdentifier(); + final migrated = await bootstrap.migrateLegacyPlexAccount(); + + expect(migrated!.id, 'plex.$clientId'); + }); + + test('uses Plex account UUID for migrated connection id when available', () async { + await storage.prefs.setString('plex_token', 'legacy-token-uuid'); + await storage.prefs.setString('client_identifier', 'device-client'); + fetchedUserInfo = {'uuid': 'account-uuid-1', 'username': 'edde'}; + + final migrated = await bootstrap.migrateLegacyPlexAccount(); + + expect(migrated!.id, 'plex.account-uuid-1'); + expect(migrated.clientIdentifier, 'device-client'); + expect(migrated.accountLabel, 'edde'); + final stored = await registry.list(); + expect(stored.single.id, 'plex.account-uuid-1'); + }); + + test('run promotes legacy Plex Home UUID and copies user cache to connection scope', () async { + await storage.prefs.setString('plex_token', 'legacy-token-home'); + await storage.prefs.setString('client_identifier', 'client-1'); + await storage.prefs.setString('servers_list', json.encode([_legacyPlexServerJson(accessToken: 'server-token')])); + await storage.prefs.setString('current_user_uuid', 'home-user-1'); + await storage.prefs.setString( + 'home_users_cache', + json.encode({ + 'id': 1, + 'name': 'Home', + 'guestUserID': null, + 'guestUserUUID': '', + 'guestEnabled': false, + 'subscription': true, + 'users': [ + { + 'id': 10, + 'uuid': 'home-user-1', + 'title': 'Kid', + 'thumb': '', + 'hasPassword': false, + 'restricted': false, + 'updatedAt': null, + 'admin': false, + 'guest': false, + 'protected': false, + }, + ], + }), + ); + + await bootstrap.run(); + + expect(storage.getActiveProfileId(), 'plex-home-plex.client-1-home-user-1'); + expect(storage.prefs.getString('plex_token'), isNull); + expect(storage.prefs.getString('servers_list'), isNull); + expect(storage.prefs.getString('current_user_uuid'), isNull); + expect(storage.prefs.getString('home_users_cache'), isNull); + + final migrated = storage.getPlexHomeUsersCacheJson('plex.client-1'); + expect(migrated, isNotNull); + final users = json.decode(migrated!) as List; + expect(users.single, containsPair('uuid', 'home-user-1')); + }); + + test('run selects fetched Plex Home admin as virtual active profile when no legacy UUID exists', () async { + await storage.prefs.setString('plex_token', 'legacy-owner-token'); + await storage.prefs.setString('client_identifier', 'client-owner'); + fetchedHomeUsers = [ + PlexHomeUser( + id: 10, + uuid: 'managed-user', + title: 'Managed', + thumb: '', + hasPassword: false, + restricted: true, + updatedAt: null, + admin: false, + guest: false, + protected: false, + ), + PlexHomeUser( + id: 1, + uuid: 'admin-user', + title: 'Owner', + thumb: '', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: true, + guest: false, + protected: false, + ), + ]; + + await bootstrap.run(); + + expect(await profileRegistry.list(), isEmpty); + expect(storage.getActiveProfileId(), 'plex-home-plex.client-owner-admin-user'); + final cached = storage.getPlexHomeUsersCacheJson('plex.client-owner'); + expect(cached, isNotNull); + final users = json.decode(cached!) as List; + expect(users.map((u) => (u as Map)['uuid']), containsAll(['managed-user', 'admin-user'])); + }); + + test('run clears leftover legacy servers_list when migration was already marked done', () async { + await storage.prefs.setBool('profile_migration_v1_done', true); + await storage.prefs.setString( + 'servers_list', + json.encode([_legacyPlexServerJson(accessToken: 'plain-server-token')]), + ); + + await bootstrap.run(); + + expect(storage.prefs.getString('servers_list'), isNull); + }); + + test('run retries later when Plex Home profiles cannot be hydrated', () async { + await storage.prefs.setString('plex_token', 'legacy-owner-token'); + await storage.prefs.setString('client_identifier', 'client-owner'); + + await bootstrap.run(); + + expect(storage.prefs.getBool('profile_migration_v1_done'), isNull); + expect(storage.prefs.getString('plex_token'), 'legacy-owner-token'); + expect(storage.getActiveProfileId(), isNull); + expect(await registry.list(), isEmpty); + expect(await profileRegistry.list(), isEmpty); + }); + }); +} + +Map _legacyPlexServerJson({required String accessToken}) { + return { + 'name': 'Plex', + 'clientIdentifier': 'server-1', + 'accessToken': accessToken, + 'owned': true, + 'connections': [ + {'protocol': 'http', 'address': '127.0.0.1', 'port': 32400, 'uri': 'http://127.0.0.1:32400'}, + ], + }; +} diff --git a/test/connection/connection_models_test.dart b/test/connection/connection_models_test.dart new file mode 100644 index 00000000..31f8effc --- /dev/null +++ b/test/connection/connection_models_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/media/media_backend.dart'; + +/// Backend-agnostic [Connection] sealed-class tests. The +/// `connection_registry_test` already covers DB persistence; these focus on +/// the model layer's `toConfigJson` / `fromConfigJson` round-trip and the +/// derived `kind` / `backend` mappings — the bits the registry treats as a +/// black box. +void main() { + group('ConnectionKind', () { + test('id round-trips through fromId', () { + for (final k in ConnectionKind.values) { + expect(ConnectionKind.fromId(k.id), k); + } + }); + + test('fromId throws on unknown id (no silent fallback)', () { + expect(() => ConnectionKind.fromId('emby'), throwsA(isA())); + }); + + test('backend mapping is total', () { + expect(ConnectionKind.plex.backend, MediaBackend.plex); + expect(ConnectionKind.jellyfin.backend, MediaBackend.jellyfin); + }); + }); + + group('JellyfinConnection serialization', () { + final base = JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jellyfin.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: 'tok-abc', + deviceId: 'dev-xyz', + createdAt: DateTime.utc(2026, 1, 15), + lastAuthenticatedAt: DateTime.utc(2026, 4, 25), + ); + + test('toConfigJson + fromConfigJson round-trip preserves every field', () { + final json = base.toConfigJson(); + final restored = JellyfinConnection.fromConfigJson( + id: base.id, + json: json, + status: base.status, + createdAt: base.createdAt, + lastAuthenticatedAt: base.lastAuthenticatedAt, + ); + expect(restored.id, base.id); + expect(restored.baseUrl, base.baseUrl); + expect(restored.serverName, base.serverName); + expect(restored.serverMachineId, base.serverMachineId); + expect(restored.userId, base.userId); + expect(restored.userName, base.userName); + expect(restored.accessToken, base.accessToken); + expect(restored.deviceId, base.deviceId); + expect(restored.createdAt, base.createdAt); + expect(restored.lastAuthenticatedAt, base.lastAuthenticatedAt); + }); + + test('fromConfigJson with empty payload uses safe defaults (no NPE)', () { + final restored = JellyfinConnection.fromConfigJson( + id: 'orphan', + json: const {}, + status: ConnectionStatus.unknown, + createdAt: DateTime.utc(2026), + ); + expect(restored.id, 'orphan'); + expect(restored.baseUrl, ''); + expect(restored.serverName, 'Jellyfin'); + expect(restored.accessToken, ''); + }); + + test('kind and backend match Jellyfin', () { + expect(base.kind, ConnectionKind.jellyfin); + expect(base.backend, MediaBackend.jellyfin); + }); + }); + + group('PlexAccountConnection serialization', () { + final base = PlexAccountConnection( + id: 'plex.client-uuid', + accountToken: 'token-xyz', + clientIdentifier: 'client-uuid', + accountLabel: 'edde', + servers: const [], + activeProfile: null, + createdAt: DateTime.utc(2026, 1, 15), + lastAuthenticatedAt: DateTime.utc(2026, 4, 25), + ); + + test('toConfigJson + fromConfigJson round-trip preserves identity fields', () { + final json = base.toConfigJson(); + final restored = PlexAccountConnection.fromConfigJson( + id: base.id, + json: json, + status: base.status, + createdAt: base.createdAt, + lastAuthenticatedAt: base.lastAuthenticatedAt, + ); + expect(restored.id, base.id); + expect(restored.accountToken, base.accountToken); + expect(restored.clientIdentifier, base.clientIdentifier); + expect(restored.accountLabel, base.accountLabel); + expect(restored.servers, isEmpty); + expect(restored.activeProfile, isNull); + expect(restored.createdAt, base.createdAt); + expect(restored.lastAuthenticatedAt, base.lastAuthenticatedAt); + }); + + test('fromConfigJson with empty payload uses safe defaults (no NPE)', () { + final restored = PlexAccountConnection.fromConfigJson( + id: 'orphan', + json: const {}, + status: ConnectionStatus.unknown, + createdAt: DateTime.utc(2026), + ); + expect(restored.id, 'orphan'); + expect(restored.accountToken, ''); + expect(restored.accountLabel, 'Plex'); + expect(restored.servers, isEmpty); + }); + + test('kind and backend match Plex', () { + expect(base.kind, ConnectionKind.plex); + expect(base.backend, MediaBackend.plex); + }); + }); +} diff --git a/test/connection/connection_registry_test.dart b/test/connection/connection_registry_test.dart new file mode 100644 index 00000000..315243d9 --- /dev/null +++ b/test/connection/connection_registry_test.dart @@ -0,0 +1,190 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +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/services/credential_vault.dart'; +import 'package:plezy/services/plex_auth_service.dart'; + +import '../test_helpers/prefs.dart'; + +JellyfinConnection _jellyfin({String id = 'srv-1', String userName = 'edde'}) { + return JellyfinConnection( + id: id, + baseUrl: 'https://jellyfin.local', + serverName: 'Home', + serverMachineId: 'jf-machine-$id', + userId: 'user-$id', + userName: userName, + accessToken: 'tok-$id', + deviceId: 'dev-1', + createdAt: DateTime.fromMillisecondsSinceEpoch(1_000_000), + ); +} + +PlexAccountConnection _plex({String id = 'plex-1'}) { + return PlexAccountConnection( + id: id, + accountToken: 'tok-$id', + clientIdentifier: 'cid-$id', + accountLabel: 'me@example.com', + servers: [ + PlexServer( + name: 'Server $id', + clientIdentifier: 'server-$id', + accessToken: 'server-token-$id', + connections: [ + PlexConnection( + protocol: 'https', + address: 'plex.example.com', + port: 443, + uri: 'https://plex.example.com', + local: false, + relay: false, + ipv6: false, + ), + ], + owned: true, + ), + ], + createdAt: DateTime.fromMillisecondsSinceEpoch(1_000_000), + ); +} + +void main() { + late AppDatabase db; + late ConnectionRegistry registry; + + setUp(() { + resetSharedPreferencesForTest(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + registry = ConnectionRegistry(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('ConnectionRegistry', () { + test('list() returns empty when no connections stored', () async { + expect(await registry.list(), isEmpty); + expect(await registry.getDefault(), isNull); + }); + + test('first upserted connection becomes the default', () async { + await registry.upsert(_jellyfin(id: 'a')); + final list = await registry.list(); + expect(list.length, 1); + expect(list.first.id, 'a'); + + final defaultConn = await registry.getDefault(); + expect(defaultConn?.id, 'a'); + }); + + test('upsert preserves type discriminator (Plex vs Jellyfin)', () async { + await registry.upsert(_plex(id: 'p')); + await registry.upsert(_jellyfin(id: 'j')); + + final plex = await registry.get('p'); + final jelly = await registry.get('j'); + expect(plex, isA()); + expect(jelly, isA()); + + expect((plex as PlexAccountConnection).accountToken, 'tok-p'); + expect((jelly as JellyfinConnection).baseUrl, 'https://jellyfin.local'); + }); + + test('upsert encrypts tokens at rest and decrypts on read', () async { + await registry.upsert(_plex(id: 'p')); + await registry.upsert(_jellyfin(id: 'j')); + + 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((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'); + }); + + test('read migrates legacy plaintext Plex server tokens', () async { + final plex = _plex(id: 'legacy'); + final config = plex.toConfigJson(); + config['accountToken'] = await CredentialVault.protect(plex.accountToken); + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: plex.id, + kind: plex.kind.id, + displayName: plex.displayName, + configJson: jsonEncode(config), + createdAt: plex.createdAt.millisecondsSinceEpoch, + isDefault: const Value(true), + ), + ); + + final restored = await registry.get('legacy') as PlexAccountConnection; + expect(restored.accountToken, 'tok-legacy'); + expect(restored.servers.single.accessToken, 'server-token-legacy'); + + final row = await (db.select(db.connections)..where((t) => t.id.equals('legacy'))).getSingle(); + expect(row.configJson, isNot(contains('server-token-legacy'))); + }); + + test('setDefault flips the flag and clears it on others', () async { + await registry.upsert(_jellyfin(id: 'a')); + await registry.upsert(_jellyfin(id: 'b')); + // First is default by default; explicitly switch to b. + await registry.setDefault('b'); + expect((await registry.getDefault())?.id, 'b'); + // Switch back to a. + await registry.setDefault('a'); + expect((await registry.getDefault())?.id, 'a'); + }); + + test('remove deletes a row and re-elects a default when needed', () async { + await registry.upsert(_jellyfin(id: 'a')); + await registry.upsert(_jellyfin(id: 'b')); + // a is default (first one in). + await registry.remove('a'); + // b should now be the default. + expect((await registry.getDefault())?.id, 'b'); + // Removing the last clears the default cleanly. + await registry.remove('b'); + expect(await registry.getDefault(), isNull); + }); + + test('re-upsert preserves the existing default flag', () async { + // Regression: a token/metadata refresh that re-upserts an existing + // default row used to clear `isDefault` because the writer always + // wrote `isFirst` (false on update). + await registry.upsert(_jellyfin(id: 'a')); + await registry.upsert(_jellyfin(id: 'b')); + expect((await registry.getDefault())?.id, 'a'); + + // Re-upsert the default with refreshed credentials. + await registry.upsert(_jellyfin(id: 'a', userName: 'refreshed')); + expect((await registry.getDefault())?.id, 'a'); + + // And re-upserting a non-default row doesn't accidentally promote it. + await registry.upsert(_jellyfin(id: 'b', userName: 'refreshed')); + expect((await registry.getDefault())?.id, 'a'); + }); + + test('recordAuthSuccess updates lastAuthenticatedAt without losing config', () async { + await registry.upsert(_jellyfin(id: 'a')); + final at = DateTime.fromMillisecondsSinceEpoch(2_000_000); + await registry.recordAuthSuccess('a', at); + + final c = await registry.get('a') as JellyfinConnection; + expect(c.lastAuthenticatedAt, at); + expect(c.baseUrl, 'https://jellyfin.local'); + expect(c.accessToken, 'tok-a'); + }); + }); +} diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index 68374058..db7ce1ff 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/database/download_operations.dart'; import 'package:plezy/models/download_models.dart'; void main() { @@ -20,16 +21,76 @@ void main() { // ============================================================ group('schema', () { - test('schemaVersion is 13', () { - expect(db.schemaVersion, 13); + test('schemaVersion is 14', () { + expect(db.schemaVersion, 14); }); test('all tables are accessible and start empty', () async { expect(await db.select(db.downloadedMedia).get(), isEmpty); + expect(await db.select(db.downloadOwners).get(), isEmpty); expect(await db.select(db.downloadQueue).get(), isEmpty); expect(await db.select(db.apiCache).get(), isEmpty); expect(await db.select(db.offlineWatchProgress).get(), isEmpty); expect(await db.select(db.syncRules).get(), isEmpty); + expect(await db.select(db.connections).get(), isEmpty); + expect(await db.select(db.profiles).get(), isEmpty); + expect(await db.select(db.profileConnections).get(), isEmpty); + }); + + test('ProfileConnections has no profile_id FK (virtual plex_home profiles)', () async { + // v20 dropped the profile_id FK so virtual Plex Home profiles can + // persist join rows without a parent `profiles` row. The two + // profile-delete sites (profile_detail_screen, profile_switch_screen) + // call ProfileConnectionRegistry.removeAllForProfile manually before + // deleting the profile, so the cascade isn't needed. + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert(id: 'c1', kind: 'plex', displayName: 'C1', configJson: '{}', createdAt: now), + ); + // No `profiles` row inserted — this would have failed pre-v20. + await db + .into(db.profileConnections) + .insert( + ProfileConnectionsCompanion.insert( + profileId: 'plex-home-c1-uuid', + connectionId: 'c1', + userIdentifier: 'uuid', + ), + ); + expect(await db.select(db.profileConnections).get(), hasLength(1)); + }); + + test('ProfileConnections FK cascades when a Connection is deleted', () async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert(id: 'c2', kind: 'plex', displayName: 'C2', configJson: '{}', createdAt: now), + ); + await db + .into(db.profiles) + .insert( + ProfilesCompanion.insert(id: 'p2', kind: 'local', displayName: 'P2', configJson: '{}', createdAt: now), + ); + await db + .into(db.profileConnections) + .insert(ProfileConnectionsCompanion.insert(profileId: 'p2', connectionId: 'c2', userIdentifier: 'u2')); + + await (db.delete(db.connections)..where((t) => t.id.equals('c2'))).go(); + expect(await db.select(db.profileConnections).get(), isEmpty); + }); + + test('hot-query indices exist in sqlite_master', () async { + // sqlite_master rows let us assert the indices physically exist + // without depending on Drift's `Migrator` having run them. + final rows = await db.customSelect("SELECT name FROM sqlite_master WHERE type = 'index'").get(); + final names = rows.map((r) => r.read('name')).toSet(); + expect( + names, + containsAll(['idx_profiles_kind', 'idx_profile_connections_profile_id', 'idx_offline_watch_progress_server']), + ); }); }); @@ -104,6 +165,7 @@ void main() { group('DownloadedMedia', () { Future insertMovie({ String serverId = 'srv1', + String? clientScopeId, String ratingKey = '100', int status = 0, // queued int progress = 0, @@ -113,6 +175,7 @@ void main() { .insert( DownloadedMediaCompanion.insert( serverId: serverId, + clientScopeId: Value(clientScopeId), ratingKey: ratingKey, globalKey: '$serverId:$ratingKey', type: 'movie', @@ -128,6 +191,7 @@ void main() { final rows = await db.select(db.downloadedMedia).get(); expect(rows, hasLength(1)); expect(rows.first.serverId, 'srv1'); + expect(rows.first.clientScopeId, isNull); expect(rows.first.ratingKey, '100'); expect(rows.first.globalKey, 'srv1:100'); expect(rows.first.type, 'movie'); @@ -140,6 +204,14 @@ void main() { expect(rows.first.totalBytes, isNull); }); + test('clientScopeId is persisted for user-scoped downloads', () async { + await insertMovie(serverId: 'jf-machine', clientScopeId: 'jf-machine/user-a'); + + final row = await db.select(db.downloadedMedia).getSingle(); + expect(row.serverId, 'jf-machine'); + expect(row.clientScopeId, 'jf-machine/user-a'); + }); + test('updating progress field works', () async { await insertMovie(); await (db.update(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:100'))).write( @@ -177,6 +249,33 @@ void main() { final completed = await db.getAllDownloadedMetadata(); expect(completed.map((i) => i.ratingKey).toSet(), {'2', '4'}); }); + + test('download owners keep profile visibility separate for one physical row', () async { + await insertMovie(ratingKey: '1', status: DownloadStatus.completed.index); + + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:1'); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv1:1'); + + expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'}); + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {'srv1:1'}); + expect(await db.getDownloadOwnerCount('srv1:1'), 2); + + await db.removeDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:1'); + expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty); + expect(await db.hasDownloadOwner('srv1:1'), isTrue); + expect(await db.hasDownloadOwner('srv1:1', excludingProfileId: 'profile-b'), isFalse); + }); + + test('adoptLegacyDownloadsForProfile claims only ownerless physical rows', () async { + await insertMovie(ratingKey: '1', status: DownloadStatus.completed.index); + await insertMovie(ratingKey: '2', status: DownloadStatus.completed.index); + await db.addDownloadOwner(profileId: 'profile-existing', globalKey: 'srv1:2'); + + await db.adoptLegacyDownloadsForProfile('profile-a'); + + expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'}); + expect(await db.getDownloadOwnerKeysForProfile('profile-existing'), {'srv1:2'}); + }); }); // ============================================================ @@ -196,7 +295,8 @@ void main() { final rows = await db.select(db.offlineWatchProgress).get(); expect(rows, hasLength(1)); expect(rows.first.globalKey, 'srv:42'); - expect(rows.first.actionType, OfflineActionType.progress.name); + expect(rows.first.clientScopeId, isNull); + expect(rows.first.actionType, OfflineActionType.progress.id); expect(rows.first.viewOffset, 5000); expect(rows.first.duration, 10000); expect(rows.first.shouldMarkWatched, isFalse); @@ -225,6 +325,32 @@ void main() { expect(rows.first.shouldMarkWatched, isTrue); }); + test('upsertProgressAction keeps scoped Jellyfin users separate', () async { + await db.upsertProgressAction( + serverId: 'srv', + clientScopeId: 'srv/user-a', + ratingKey: '42', + viewOffset: 1000, + duration: 10000, + shouldMarkWatched: false, + ); + await db.upsertProgressAction( + serverId: 'srv', + clientScopeId: 'srv/user-b', + ratingKey: '42', + viewOffset: 9000, + duration: 10000, + shouldMarkWatched: true, + ); + + final rows = await (db.select( + db.offlineWatchProgress, + )..orderBy([(t) => OrderingTerm.asc(t.clientScopeId)])).get(); + expect(rows, hasLength(2)); + expect(rows.map((r) => r.clientScopeId), ['srv/user-a', 'srv/user-b']); + expect(rows.map((r) => r.viewOffset), [1000, 9000]); + }); + test('insertWatchAction (watched) clears prior progress + insert single row', () async { // Existing progress row for the same item await db.upsertProgressAction( @@ -235,14 +361,49 @@ void main() { shouldMarkWatched: false, ); - await db.insertWatchAction(serverId: 'srv', ratingKey: '42', actionType: OfflineActionType.watched.name); + await db.insertWatchAction(serverId: 'srv', ratingKey: '42', actionType: OfflineActionType.watched.id); final rows = await db.select(db.offlineWatchProgress).get(); expect(rows, hasLength(1)); - expect(rows.first.actionType, OfflineActionType.watched.name); + expect(rows.first.actionType, OfflineActionType.watched.id); expect(rows.first.viewOffset, isNull); }); + test('insertWatchAction clears only matching clientScopeId conflicts', () async { + await db.upsertProgressAction( + serverId: 'srv', + clientScopeId: 'srv/user-a', + ratingKey: '42', + viewOffset: 1000, + duration: 10000, + shouldMarkWatched: false, + ); + await db.upsertProgressAction( + serverId: 'srv', + clientScopeId: 'srv/user-b', + ratingKey: '42', + viewOffset: 2000, + duration: 10000, + shouldMarkWatched: false, + ); + + await db.insertWatchAction( + serverId: 'srv', + clientScopeId: 'srv/user-a', + ratingKey: '42', + actionType: OfflineActionType.watched.id, + ); + + final rows = await (db.select( + db.offlineWatchProgress, + )..orderBy([(t) => OrderingTerm.asc(t.clientScopeId), (t) => OrderingTerm.asc(t.actionType)])).get(); + expect(rows, hasLength(2)); + expect(rows.map((r) => (r.clientScopeId, r.actionType)).toList(), [ + ('srv/user-a', OfflineActionType.watched.id), + ('srv/user-b', OfflineActionType.progress.id), + ]); + }); + test('getPendingWatchActions returns rows ordered by createdAt asc', () async { // Inject deterministic createdAt by raw inserts final now = DateTime.now().millisecondsSinceEpoch; @@ -253,7 +414,7 @@ void main() { serverId: 's', ratingKey: '1', globalKey: 's:1', - actionType: OfflineActionType.watched.name, + actionType: OfflineActionType.watched.id, createdAt: now + 100, updatedAt: now + 100, ), @@ -265,7 +426,7 @@ void main() { serverId: 's', ratingKey: '2', globalKey: 's:2', - actionType: OfflineActionType.watched.name, + actionType: OfflineActionType.watched.id, createdAt: now + 50, updatedAt: now + 50, ), @@ -275,10 +436,25 @@ void main() { expect(pending.map((p) => p.ratingKey).toList(), ['2', '1']); }); + test('adoptLegacyOfflineWatchActionsForProfile claims null-profile rows', () async { + await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction( + profileId: 'profile-existing', + serverId: 's', + ratingKey: '2', + actionType: OfflineActionType.watched.id, + ); + + await db.adoptLegacyOfflineWatchActionsForProfile('profile-a'); + + expect((await db.getPendingWatchActions(profileId: 'profile-a')).map((r) => r.ratingKey), ['1']); + expect((await db.getPendingWatchActions(profileId: 'profile-existing')).map((r) => r.ratingKey), ['2']); + }); + test('getPendingWatchActionsForServer filters by serverId', () async { - await db.insertWatchAction(serverId: 'a', ratingKey: '1', actionType: OfflineActionType.watched.name); - await db.insertWatchAction(serverId: 'b', ratingKey: '2', actionType: OfflineActionType.watched.name); - await db.insertWatchAction(serverId: 'a', ratingKey: '3', actionType: OfflineActionType.unwatched.name); + await db.insertWatchAction(serverId: 'a', ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: 'b', ratingKey: '2', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: 'a', ratingKey: '3', actionType: OfflineActionType.unwatched.id); final aRows = await db.getPendingWatchActionsForServer('a'); expect(aRows.map((r) => r.ratingKey).toSet(), {'1', '3'}); @@ -296,7 +472,7 @@ void main() { serverId: 's', ratingKey: '1', globalKey: 's:1', - actionType: OfflineActionType.progress.name, + actionType: OfflineActionType.progress.id, createdAt: now, updatedAt: now - 100, ), @@ -308,7 +484,7 @@ void main() { serverId: 's', ratingKey: '1', globalKey: 's:1', - actionType: OfflineActionType.watched.name, + actionType: OfflineActionType.watched.id, createdAt: now, updatedAt: now + 50, ), @@ -316,7 +492,7 @@ void main() { final latest = await db.getLatestWatchAction('s:1'); expect(latest, isNotNull); - expect(latest!.actionType, OfflineActionType.watched.name); + expect(latest!.actionType, OfflineActionType.watched.id); }); test('getLatestWatchAction returns null when no rows', () async { @@ -332,7 +508,7 @@ void main() { serverId: 's', ratingKey: '1', globalKey: 's:1', - actionType: OfflineActionType.progress.name, + actionType: OfflineActionType.progress.id, createdAt: now, updatedAt: now, ), @@ -344,7 +520,7 @@ void main() { serverId: 's', ratingKey: '1', globalKey: 's:1', - actionType: OfflineActionType.watched.name, + actionType: OfflineActionType.watched.id, createdAt: now, updatedAt: now + 100, ), @@ -356,7 +532,7 @@ void main() { serverId: 's', ratingKey: '2', globalKey: 's:2', - actionType: OfflineActionType.unwatched.name, + actionType: OfflineActionType.unwatched.id, createdAt: now, updatedAt: now, ), @@ -364,8 +540,82 @@ void main() { final result = await db.getLatestWatchActionsForKeys({'s:1', 's:2', 's:3-missing'}); expect(result.keys.toSet(), {'s:1', 's:2'}); - expect(result['s:1']!.actionType, OfflineActionType.watched.name); - expect(result['s:2']!.actionType, OfflineActionType.unwatched.name); + expect(result['s:1']!.actionType, OfflineActionType.watched.id); + expect(result['s:2']!.actionType, OfflineActionType.unwatched.id); + }); + + test('getLatestWatchActionsForKeys filters by expected clientScopeId', () async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 'jf', + clientScopeId: const Value('jf/user-a'), + ratingKey: '1', + globalKey: 'jf:1', + actionType: OfflineActionType.unwatched.id, + createdAt: now, + updatedAt: now, + ), + ); + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 'jf', + clientScopeId: const Value('jf/user-b'), + ratingKey: '1', + globalKey: 'jf:1', + actionType: OfflineActionType.watched.id, + createdAt: now, + updatedAt: now + 100, + ), + ); + + final userA = await db.getLatestWatchActionsForKeys({'jf:1'}, clientScopeIdsByGlobalKey: {'jf:1': 'jf/user-a'}); + final userB = await db.getLatestWatchActionsForKeys({'jf:1'}, clientScopeIdsByGlobalKey: {'jf:1': 'jf/user-b'}); + + expect(userA['jf:1']!.actionType, OfflineActionType.unwatched.id); + expect(userB['jf:1']!.actionType, OfflineActionType.watched.id); + }); + + test('getLatestWatchActionsForKeys filters by profile when requested', () async { + final now = DateTime.now().millisecondsSinceEpoch; + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 's', + profileId: const Value('profile-a'), + ratingKey: '1', + globalKey: 's:1', + actionType: OfflineActionType.unwatched.id, + createdAt: now, + updatedAt: now, + ), + ); + await db + .into(db.offlineWatchProgress) + .insert( + OfflineWatchProgressCompanion.insert( + serverId: 's', + profileId: const Value('profile-b'), + ratingKey: '1', + globalKey: 's:1', + actionType: OfflineActionType.watched.id, + createdAt: now, + updatedAt: now + 100, + ), + ); + + final profileA = await db.getLatestWatchActionsForKeys({'s:1'}, profileId: 'profile-a', filterProfile: true); + final profileB = await db.getLatestWatchActionsForKeys({'s:1'}, profileId: 'profile-b', filterProfile: true); + final globalLatest = await db.getLatestWatchActionsForKeys({'s:1'}); + + expect(profileA['s:1']!.actionType, OfflineActionType.unwatched.id); + expect(profileB['s:1']!.actionType, OfflineActionType.watched.id); + expect(globalLatest['s:1']!.actionType, OfflineActionType.watched.id); }); test('getLatestWatchActionsForKeys with empty input returns empty map (no query)', () async { @@ -373,7 +623,7 @@ void main() { }); test('updateSyncAttempt increments syncAttempts and stores lastError', () async { - await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name); + await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); final inserted = (await db.select(db.offlineWatchProgress).get()).single; await db.updateSyncAttempt(inserted.id, 'boom'); @@ -393,8 +643,8 @@ void main() { }); test('deleteWatchAction removes only the matching row', () async { - await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name); - await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.watched.name); + await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.watched.id); final rows = await db.select(db.offlineWatchProgress).get(); expect(rows, hasLength(2)); @@ -405,14 +655,14 @@ void main() { test('getPendingSyncCount counts every row', () async { expect(await db.getPendingSyncCount(), 0); - await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name); - await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.name); + await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.id); expect(await db.getPendingSyncCount(), 2); }); test('clearAllWatchActions empties the table', () async { - await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.name); - await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.name); + await db.insertWatchAction(serverId: 's', ratingKey: '1', actionType: OfflineActionType.watched.id); + await db.insertWatchAction(serverId: 's', ratingKey: '2', actionType: OfflineActionType.unwatched.id); await db.clearAllWatchActions(); expect(await db.select(db.offlineWatchProgress).get(), isEmpty); @@ -436,6 +686,7 @@ void main() { final rules = await db.getSyncRules(); expect(rules, hasLength(1)); expect(rules.first.targetType, 'show'); + expect(rules.first.profileId, ''); expect(rules.first.episodeCount, 5); expect(rules.first.enabled, isTrue); // default expect(rules.first.downloadFilter, 'unwatched'); // default @@ -443,10 +694,12 @@ void main() { expect(rules.first.lastExecutedAt, isNull); }); - test('insertSyncRule with duplicate globalKey throws on the UNIQUE constraint', () async { - // insertOnConflictUpdate only auto-targets the primary key (`id`), so a - // collision on the UNIQUE `global_key` column still throws — this pins - // current production behavior. + test('insertSyncRule upserts on the UNIQUE globalKey instead of crashing', () async { + // The auto-incremented primary key never collides, so a duplicate + // globalKey would fail the UNIQUE constraint with a vanilla + // `insertOnConflictUpdate`. The helper drives the upsert off + // [globalKey] so re-creating a rule for the same target updates the + // existing row rather than throwing. await db.insertSyncRule( serverId: 'srv', ratingKey: '10', @@ -454,16 +707,69 @@ void main() { targetType: 'show', episodeCount: 5, ); - expect( - () => db.insertSyncRule( - serverId: 'srv', - ratingKey: '10', - globalKey: 'srv:10', - targetType: 'season', - episodeCount: 99, - ), - throwsA(isA()), + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'season', + episodeCount: 99, + downloadFilter: 'all', ); + + final rules = await db.getSyncRules(); + expect(rules, hasLength(1)); + expect(rules.first.targetType, 'season'); + expect(rules.first.episodeCount, 99); + expect(rules.first.downloadFilter, 'all'); + }); + + test('insertSyncRule allows the same server item for different profiles', () async { + await db.insertSyncRule( + profileId: 'profile-a', + serverId: 'srv', + ratingKey: '10', + globalKey: 'profile-a|srv:10', + targetType: 'show', + episodeCount: 5, + ); + await db.insertSyncRule( + profileId: 'profile-b', + serverId: 'srv', + ratingKey: '10', + globalKey: 'profile-b|srv:10', + targetType: 'show', + episodeCount: 9, + ); + + expect(await db.getSyncRules(profileId: 'profile-a'), hasLength(1)); + expect((await db.getSyncRules(profileId: 'profile-a')).single.episodeCount, 5); + expect(await db.getSyncRules(profileId: 'profile-b'), hasLength(1)); + expect((await db.getSyncRules(profileId: 'profile-b')).single.episodeCount, 9); + }); + + test('insertSyncRule preserves enabled + lastExecutedAt across upserts', () async { + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 5, + ); + await db.updateSyncRuleEnabled('srv:10', false); + await db.updateSyncRuleLastExecuted('srv:10'); + final firstRun = (await db.getSyncRule('srv:10'))!; + + await db.insertSyncRule( + serverId: 'srv', + ratingKey: '10', + globalKey: 'srv:10', + targetType: 'show', + episodeCount: 8, + ); + final afterUpsert = (await db.getSyncRule('srv:10'))!; + expect(afterUpsert.episodeCount, 8); + expect(afterUpsert.enabled, isFalse, reason: 'upsert should preserve disabled flag'); + expect(afterUpsert.lastExecutedAt, firstRun.lastExecutedAt); }); test('getSyncRule returns the matching rule or null', () async { diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index 22d18d76..6f1f8a1e 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -365,6 +365,54 @@ void main() { expect(await db.getEpisodesBySeason('seasonZ'), isEmpty); }); + test('getEpisodesBySeason can filter by server and client scope', () async { + await db.insertDownload( + serverId: 'jf', + clientScopeId: 'jf/user-a', + ratingKey: 'ep-a', + globalKey: 'jf:ep-a', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.completed.index, + ); + await db.insertDownload( + serverId: 'jf', + clientScopeId: 'jf/user-b', + ratingKey: 'ep-b', + globalKey: 'jf:ep-b', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.completed.index, + ); + await db.insertDownload( + serverId: 'other', + ratingKey: 'ep-other', + globalKey: 'other:ep-other', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.completed.index, + ); + await db.insertDownload( + serverId: 'other', + clientScopeId: 'other/user-a', + ratingKey: 'ep-other-scoped', + globalKey: 'other:ep-other-scoped', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.completed.index, + ); + + final userA = await db.getEpisodesBySeason('season1', serverId: 'jf', clientScopeId: 'jf/user-a'); + final unscoped = await db.getEpisodesBySeason('season1', serverId: 'other', filterClientScope: true); + + expect(userA.map((e) => e.ratingKey), ['ep-a']); + expect(unscoped.map((e) => e.ratingKey), ['ep-other']); + }); + test('getEpisodesByShow filters by grandparentRatingKey', () async { await seedTree(); @@ -374,6 +422,33 @@ void main() { expect(await db.getEpisodesByShow('show-missing'), isEmpty); }); + test('getEpisodesByShow can filter by server and client scope', () async { + await db.insertDownload( + serverId: 'jf', + clientScopeId: 'jf/user-a', + ratingKey: 'ep-a', + globalKey: 'jf:ep-a', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.completed.index, + ); + await db.insertDownload( + serverId: 'jf', + clientScopeId: 'jf/user-b', + ratingKey: 'ep-b', + globalKey: 'jf:ep-b', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.completed.index, + ); + + final userB = await db.getEpisodesByShow('show1', serverId: 'jf', clientScopeId: 'jf/user-b'); + + expect(userB.map((e) => e.ratingKey), ['ep-b']); + }); + test('getDownloadsByServerId filters by serverId', () async { await seedTree(); @@ -387,6 +462,50 @@ void main() { }); }); + // ============================================================ + // Download owners + // ============================================================ + + group('download owners', () { + Future insertProfile(String id) async { + await db + .into(db.profiles) + .insert(ProfilesCompanion.insert(id: id, kind: 'local', displayName: id, configJson: '{}', createdAt: 0)); + } + + Future insertPlexConnection(String id) async { + await db + .into(db.connections) + .insert(ConnectionsCompanion.insert(id: id, kind: 'plex', displayName: id, configJson: '{}', createdAt: 0)); + } + + test('owner counts ignore orphan local profiles', () async { + await insertProfile('profile-a'); + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100'); + await db.addDownloadOwner(profileId: 'profile-deleted', globalKey: 'srv:100'); + + expect(await db.getDownloadOwnerCount('srv:100'), 1); + expect(await db.hasDownloadOwner('srv:100', excludingProfileId: 'profile-a'), isFalse); + }); + + test('owner counts preserve virtual Plex Home profile ids', () async { + const plexHomeProfileId = 'plex-home-account-1-00000000-0000-0000-0000-000000000001'; + await insertPlexConnection('account-1'); + await db.addDownloadOwner(profileId: plexHomeProfileId, globalKey: 'srv:100'); + + expect(await db.getDownloadOwnerCount('srv:100'), 1); + expect(await db.hasDownloadOwner('srv:100'), isTrue); + }); + + test('owner counts ignore Plex Home rows whose parent connection is gone', () async { + const plexHomeProfileId = 'plex-home-missing-account-00000000-0000-0000-0000-000000000001'; + await db.addDownloadOwner(profileId: plexHomeProfileId, globalKey: 'srv:100'); + + expect(await db.getDownloadOwnerCount('srv:100'), 0); + expect(await db.hasDownloadOwner('srv:100'), isFalse); + }); + }); + // ============================================================ // deleteDownload — removes from both tables // ============================================================ diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart new file mode 100644 index 00000000..5e3797a1 --- /dev/null +++ b/test/media/media_item_test.dart @@ -0,0 +1,204 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; + +/// Backend-agnostic [MediaItem] tests. Existing coverage is split between +/// `plex_mappers_test` and `jellyfin_mappers_test` — those exercise the +/// JSON mappers but never the neutral model itself. If a mapper is removed +/// or refactored these tests still pin the model contract: equality, +/// copyWith, watch-state derived getters. +MediaItem _movie({ + String id = 'm1', + String? title = 'Movie', + int? viewCount, + int? leafCount, + int? viewedLeafCount, + int? durationMs, + int? viewOffsetMs, + MediaBackend backend = MediaBackend.plex, +}) => MediaItem( + id: id, + backend: backend, + kind: MediaKind.movie, + title: title, + viewCount: viewCount, + leafCount: leafCount, + viewedLeafCount: viewedLeafCount, + durationMs: durationMs, + viewOffsetMs: viewOffsetMs, + serverId: 's1', +); + +void main() { + group('MediaItem.isWatched', () { + test('movie with viewCount > 0 is watched', () { + expect(_movie(viewCount: 1).isWatched, isTrue); + expect(_movie(viewCount: 5).isWatched, isTrue); + }); + + test('movie with viewCount 0 or null is unwatched', () { + expect(_movie(viewCount: 0).isWatched, isFalse); + expect(_movie(viewCount: null).isWatched, isFalse); + }); + + test('show with all leaves watched is watched', () { + final show = MediaItem( + id: 's', + backend: MediaBackend.plex, + kind: MediaKind.show, + leafCount: 10, + viewedLeafCount: 10, + serverId: 's1', + ); + expect(show.isWatched, isTrue); + }); + + test('show with viewedLeafCount > leafCount is still watched (defensive)', () { + final show = MediaItem( + id: 's', + backend: MediaBackend.plex, + kind: MediaKind.show, + leafCount: 10, + viewedLeafCount: 11, + serverId: 's1', + ); + expect(show.isWatched, isTrue); + }); + + test('show with no leaf info falls back to viewCount', () { + final show = MediaItem(id: 's', backend: MediaBackend.plex, kind: MediaKind.show, viewCount: 1, serverId: 's1'); + expect(show.isWatched, isTrue); + }); + }); + + group('MediaItem.isPartiallyWatched', () { + test('show with some leaves watched is partially watched', () { + final show = MediaItem( + id: 's', + backend: MediaBackend.plex, + kind: MediaKind.show, + leafCount: 10, + viewedLeafCount: 3, + serverId: 's1', + ); + expect(show.isPartiallyWatched, isTrue); + }); + + test('show with zero leaves watched is NOT partially watched', () { + final show = MediaItem( + id: 's', + backend: MediaBackend.plex, + kind: MediaKind.show, + leafCount: 10, + viewedLeafCount: 0, + serverId: 's1', + ); + expect(show.isPartiallyWatched, isFalse); + }); + + test('show with all leaves watched is NOT partially watched', () { + final show = MediaItem( + id: 's', + backend: MediaBackend.plex, + kind: MediaKind.show, + leafCount: 10, + viewedLeafCount: 10, + serverId: 's1', + ); + expect(show.isPartiallyWatched, isFalse); + }); + + test('movie without leaf info is NOT partially watched (concept doesn\'t apply)', () { + expect(_movie(viewCount: 0).isPartiallyWatched, isFalse); + expect(_movie(viewCount: 1).isPartiallyWatched, isFalse); + }); + }); + + group('MediaItem.hasActiveProgress', () { + test('viewOffset between 0 and duration counts as active progress', () { + expect(_movie(durationMs: 10000, viewOffsetMs: 5000).hasActiveProgress, isTrue); + }); + + test('viewOffset 0 is NOT active progress (haven\'t started yet)', () { + expect(_movie(durationMs: 10000, viewOffsetMs: 0).hasActiveProgress, isFalse); + }); + + test('viewOffset >= duration is NOT active progress (already finished)', () { + expect(_movie(durationMs: 10000, viewOffsetMs: 10000).hasActiveProgress, isFalse); + expect(_movie(durationMs: 10000, viewOffsetMs: 99999).hasActiveProgress, isFalse); + }); + + test('null durationMs or viewOffsetMs disables the check', () { + expect(_movie(durationMs: null, viewOffsetMs: 5000).hasActiveProgress, isFalse); + expect(_movie(durationMs: 10000, viewOffsetMs: null).hasActiveProgress, isFalse); + }); + }); + + group('MediaItem.copyWith', () { + test('round-trips an unchanged copy', () { + final original = _movie(viewCount: 1, durationMs: 1000); + final copy = original.copyWith(); + expect(copy.id, original.id); + expect(copy.viewCount, original.viewCount); + expect(copy.durationMs, original.durationMs); + expect(copy.kind, original.kind); + }); + + test('overrides only the named fields', () { + final original = _movie(title: 'Old', viewCount: 0); + final copy = original.copyWith(title: 'New', viewCount: 3); + expect(copy.title, 'New'); + expect(copy.viewCount, 3); + expect(copy.id, 'm1', reason: 'untouched fields preserved'); + }); + + test('preserves backend across copyWith for both backends', () { + for (final backend in MediaBackend.values) { + final original = _movie(backend: backend); + expect(original.backend, backend); + expect(original.copyWith(title: 'New').backend, backend, reason: 'copyWith must preserve backend'); + } + }); + }); + + group('MediaItem.displayTitle', () { + test('episode prefers grandparent (show) title', () { + final ep = MediaItem( + id: 'e1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Pilot', + grandparentTitle: 'Breaking Bad', + parentTitle: 'Season 1', + serverId: 's1', + ); + expect(ep.displayTitle, 'Breaking Bad'); + expect(ep.displaySubtitle, 'Pilot'); + }); + + test('season prefers grandparent over parent (when both present)', () { + final season = MediaItem( + id: 'sn1', + backend: MediaBackend.plex, + kind: MediaKind.season, + title: 'Season 1', + grandparentTitle: 'Breaking Bad', + parentTitle: null, + serverId: 's1', + ); + expect(season.displayTitle, 'Breaking Bad'); + }); + + test('movie returns its own title with no subtitle', () { + final movie = _movie(title: 'Inception'); + expect(movie.displayTitle, 'Inception'); + expect(movie.displaySubtitle, isNull); + }); + + test('null title degrades to empty string (no NPE)', () { + final movie = _movie(title: null); + expect(movie.displayTitle, ''); + }); + }); +} diff --git a/test/media/media_playlist_test.dart b/test/media/media_playlist_test.dart new file mode 100644 index 00000000..2ef0931f --- /dev/null +++ b/test/media/media_playlist_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_playlist.dart'; + +/// Backend-agnostic [MediaPlaylist] tests. Mappers (`plex_mappers_test` / +/// `jellyfin_mappers_test`) cover JSON → model translation; this file pins +/// the neutral model's surface so a future mapper swap can't silently +/// regress its derived getters. +/// +/// Note: [MediaPlaylist] does **not** override `==` / `hashCode`, so this +/// file deliberately avoids equality tests that would exercise default +/// identity behavior. +MediaPlaylist _playlist({ + String id = 'pl1', + MediaBackend backend = MediaBackend.plex, + String title = 'My Playlist', + String playlistType = 'video', + bool smart = false, + String? compositeImagePath, + String? thumbPath, + String? serverId = 's1', +}) => MediaPlaylist( + id: id, + backend: backend, + title: title, + playlistType: playlistType, + smart: smart, + compositeImagePath: compositeImagePath, + thumbPath: thumbPath, + serverId: serverId, +); + +void main() { + group('MediaPlaylist.copyWith', () { + test('returns an equivalent copy when no overrides are passed', () { + final original = _playlist( + id: 'pl-original', + title: 'Original', + compositeImagePath: '/library/metadata/123/composite/1700000000', + thumbPath: '/library/metadata/123/thumb', + serverId: 's-original', + ); + final copy = original.copyWith(); + expect(copy.id, original.id); + expect(copy.backend, original.backend); + expect(copy.title, original.title); + expect(copy.playlistType, original.playlistType); + expect(copy.compositeImagePath, original.compositeImagePath); + expect(copy.thumbPath, original.thumbPath); + expect(copy.serverId, original.serverId); + }); + + test('overrides apply to the copy without mutating the source', () { + final original = _playlist(title: 'Original', smart: false); + final renamed = original.copyWith(title: 'Renamed', smart: true); + expect(renamed.title, 'Renamed'); + expect(renamed.smart, isTrue); + // Source untouched — copyWith must be non-mutating. + expect(original.title, 'Original'); + expect(original.smart, isFalse); + }); + + test('every nullable field can be overridden', () { + final original = _playlist(); + final fully = original.copyWith( + id: 'new-id', + backend: MediaBackend.jellyfin, + title: 'New Title', + summary: 'A new summary', + guid: 'plex://playlist/abc', + smart: true, + playlistType: 'audio', + durationMs: 1234567, + leafCount: 42, + viewCount: 7, + addedAt: 1700000000, + updatedAt: 1700001000, + lastViewedAt: 1700002000, + compositeImagePath: '/composite/x', + thumbPath: '/thumb/x', + serverId: 'new-server', + serverName: 'New Server', + ); + expect(fully.id, 'new-id'); + expect(fully.backend, MediaBackend.jellyfin); + expect(fully.title, 'New Title'); + expect(fully.summary, 'A new summary'); + expect(fully.guid, 'plex://playlist/abc'); + expect(fully.smart, isTrue); + expect(fully.playlistType, 'audio'); + expect(fully.durationMs, 1234567); + expect(fully.leafCount, 42); + expect(fully.viewCount, 7); + expect(fully.addedAt, 1700000000); + expect(fully.updatedAt, 1700001000); + expect(fully.lastViewedAt, 1700002000); + expect(fully.compositeImagePath, '/composite/x'); + expect(fully.thumbPath, '/thumb/x'); + expect(fully.serverId, 'new-server'); + expect(fully.serverName, 'New Server'); + }); + }); + + group('MediaPlaylist.displayImagePath', () { + test('prefers compositeImagePath over thumbPath', () { + final pl = _playlist(compositeImagePath: '/composite/grid', thumbPath: '/thumb/single'); + expect(pl.displayImagePath, '/composite/grid'); + }); + + test('falls back to thumbPath when composite is null', () { + final pl = _playlist(compositeImagePath: null, thumbPath: '/thumb/single'); + expect(pl.displayImagePath, '/thumb/single'); + }); + + test('is null when both are null', () { + final pl = _playlist(compositeImagePath: null, thumbPath: null); + expect(pl.displayImagePath, isNull); + }); + }); + + group('MediaPlaylist.displayTitle', () { + test('is an alias of title', () { + final pl = _playlist(title: 'Anything'); + expect(pl.displayTitle, 'Anything'); + expect(pl.displayTitle, pl.title); + }); + }); + + group('MediaPlaylist.isEditable', () { + test('smart playlists are read-only (Plex semantics)', () { + expect(_playlist(smart: true).isEditable, isFalse); + }); + + test('manual playlists are editable', () { + expect(_playlist(smart: false).isEditable, isTrue); + }); + }); + + group('MediaPlaylist.globalKey', () { + test('uses ":" when serverId is set', () { + final pl = _playlist(id: 'pl-42', serverId: 'srv-9'); + expect(pl.globalKey, 'srv-9:pl-42'); + }); + + test('falls back to bare id when serverId is null', () { + final pl = _playlist(id: 'pl-42', serverId: null); + expect(pl.globalKey, 'pl-42'); + }); + }); + + group('MediaPlaylist construction', () { + test('tolerates all-optional fields being null', () { + final minimal = MediaPlaylist(id: 'pl', backend: MediaBackend.plex, title: 'Min', playlistType: 'video'); + expect(minimal.summary, isNull); + expect(minimal.guid, isNull); + expect(minimal.smart, isFalse); + expect(minimal.durationMs, isNull); + expect(minimal.leafCount, isNull); + expect(minimal.viewCount, isNull); + expect(minimal.addedAt, isNull); + expect(minimal.updatedAt, isNull); + expect(minimal.lastViewedAt, isNull); + expect(minimal.compositeImagePath, isNull); + expect(minimal.thumbPath, isNull); + expect(minimal.serverId, isNull); + expect(minimal.serverName, isNull); + expect(minimal.displayImagePath, isNull); + expect(minimal.displayTitle, 'Min'); + expect(minimal.isEditable, isTrue); + // Without a serverId, globalKey reduces to the bare id. + expect(minimal.globalKey, 'pl'); + }); + }); +} diff --git a/test/media/media_sort_test.dart b/test/media/media_sort_test.dart new file mode 100644 index 00000000..ad2c7b30 --- /dev/null +++ b/test/media/media_sort_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_sort.dart'; + +void main() { + group('MediaSort.getSortKey', () { + test('returns plain key for ascending', () { + final s = MediaSort(key: 'titleSort', title: 'Title'); + expect(s.getSortKey(), 'titleSort'); + expect(s.getSortKey(descending: false), 'titleSort'); + }); + + test('appends :desc when no descKey is provided', () { + final s = MediaSort(key: 'addedAt', title: 'Recently Added'); + expect(s.getSortKey(descending: true), 'addedAt:desc'); + }); + + test('uses explicit descKey when provided', () { + final s = MediaSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title'); + expect(s.getSortKey(descending: true), 'titleSort:desc'); + + final custom = MediaSort(key: 'rating', descKey: 'rating.desc.custom', title: 'Rating'); + expect(custom.getSortKey(descending: true), 'rating.desc.custom'); + }); + }); + + group('MediaSort.isDefaultDescending', () { + test('true for "desc" (case-insensitive)', () { + expect(MediaSort(key: 'k', title: 't', defaultDirection: 'desc').isDefaultDescending, isTrue); + expect(MediaSort(key: 'k', title: 't', defaultDirection: 'DESC').isDefaultDescending, isTrue); + expect(MediaSort(key: 'k', title: 't', defaultDirection: 'Desc').isDefaultDescending, isTrue); + }); + + test('false for "asc", null, or other values', () { + expect(MediaSort(key: 'k', title: 't', defaultDirection: 'asc').isDefaultDescending, isFalse); + expect(MediaSort(key: 'k', title: 't').isDefaultDescending, isFalse); + expect(MediaSort(key: 'k', title: 't', defaultDirection: '').isDefaultDescending, isFalse); + }); + }); + + group('MediaSort.fromJson', () { + test('parses all fields', () { + final s = MediaSort.fromJson({ + 'key': 'titleSort', + 'descKey': 'titleSort:desc', + 'title': 'Title', + 'defaultDirection': 'asc', + }); + expect(s.key, 'titleSort'); + expect(s.descKey, 'titleSort:desc'); + expect(s.title, 'Title'); + expect(s.defaultDirection, 'asc'); + }); + + test('tolerates missing optional fields', () { + final s = MediaSort.fromJson({'key': 'k', 'title': 't'}); + expect(s.descKey, isNull); + expect(s.defaultDirection, isNull); + }); + }); + + group('MediaSort equality & hashCode', () { + test('equality is based on key only (matches current contract)', () { + final a = MediaSort(key: 'k', descKey: 'k:desc', title: 'A', defaultDirection: 'asc'); + final b = MediaSort(key: 'k', descKey: 'other', title: 'B', defaultDirection: 'desc'); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + }); + + test('different keys are not equal', () { + final a = MediaSort(key: 'k1', title: 'A'); + final b = MediaSort(key: 'k2', title: 'A'); + expect(a, isNot(equals(b))); + }); + + test('identity short-circuit', () { + final a = MediaSort(key: 'k', title: 't'); + expect(a == a, isTrue); + }); + }); +} diff --git a/test/mixins/deletion_aware_test.dart b/test/mixins/deletion_aware_test.dart index e9998aad..879a3f5a 100644 --- a/test/mixins/deletion_aware_test.dart +++ b/test/mixins/deletion_aware_test.dart @@ -4,12 +4,12 @@ import 'package:plezy/mixins/deletion_aware.dart'; import 'package:plezy/utils/deletion_notifier.dart'; class _Probe extends StatefulWidget { - const _Probe({this.onState, this.serverIdOverride, this.globalKeysOverride, required this.ratingKeysOverride}); + const _Probe({this.onState, this.serverIdOverride, this.globalKeysOverride, required this.itemIdsOverride}); final void Function(_ProbeState)? onState; final String? serverIdOverride; final Set? globalKeysOverride; - final Set? ratingKeysOverride; + final Set? itemIdsOverride; @override State<_Probe> createState() => _ProbeState(); @@ -20,7 +20,7 @@ class _ProbeState extends State<_Probe> with DeletionAware { String? _serverId; Set? _globalKeys; - Set? _ratingKeys; + Set? _itemIds; @override String? get deletionServerId => _serverId; @@ -29,7 +29,7 @@ class _ProbeState extends State<_Probe> with DeletionAware { Set? get deletionGlobalKeys => _globalKeys; @override - Set? get deletionRatingKeys => _ratingKeys; + Set? get deletionIds => _itemIds; @override void onDeletionEvent(DeletionEvent event) { @@ -40,7 +40,7 @@ class _ProbeState extends State<_Probe> with DeletionAware { void initState() { _serverId = widget.serverIdOverride; _globalKeys = widget.globalKeysOverride; - _ratingKeys = widget.ratingKeysOverride; + _itemIds = widget.itemIdsOverride; super.initState(); widget.onState?.call(this); } @@ -51,10 +51,10 @@ class _ProbeState extends State<_Probe> with DeletionAware { DeletionEvent _ev({ required String serverId, - required String ratingKey, + required String itemId, List parentChain = const [], String mediaType = 'movie', -}) => DeletionEvent(ratingKey: ratingKey, serverId: serverId, parentChain: parentChain, mediaType: mediaType); +}) => DeletionEvent(itemId: itemId, serverId: serverId, parentChain: parentChain, mediaType: mediaType); Future _settle(WidgetTester tester) async { await tester.pump(Duration.zero); @@ -62,22 +62,22 @@ Future _settle(WidgetTester tester) async { void main() { group('DeletionAware', () { - testWidgets('receives events for ratingKeys it tracks', (tester) async { + testWidgets('receives events for itemIds it tracks', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {'42'})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - DeletionNotifier().notify(_ev(serverId: 's1', ratingKey: '42')); + DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); - expect(state.events.first.ratingKey, '42'); + expect(state.events.first.itemId, '42'); }); - testWidgets('drops events for ratingKeys outside its set', (tester) async { + testWidgets('drops events for itemIds outside its set', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {'42'})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - DeletionNotifier().notify(_ev(serverId: 's1', ratingKey: '999')); + DeletionNotifier().notify(_ev(serverId: 's1', itemId: '999')); await _settle(tester); expect(state.events, isEmpty); @@ -85,53 +85,51 @@ void main() { testWidgets('parent-chain hits are delivered (e.g. season deleted invalidates a show)', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {'show123'})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'show123'})); DeletionNotifier().notify( - _ev(serverId: 's1', ratingKey: 'season789', parentChain: const ['show123'], mediaType: 'season'), + _ev(serverId: 's1', itemId: 'season789', parentChain: const ['show123'], mediaType: 'season'), ); await _settle(tester); expect(state.events, hasLength(1)); - expect(state.events.first.ratingKey, 'season789'); + expect(state.events.first.itemId, 'season789'); }); testWidgets('serverId override scopes events', (tester) async { late _ProbeState state; - await tester.pumpWidget( - _Probe(onState: (s) => state = s, serverIdOverride: 's1', ratingKeysOverride: const {'42'}), - ); + await tester.pumpWidget(_Probe(onState: (s) => state = s, serverIdOverride: 's1', itemIdsOverride: const {'42'})); - DeletionNotifier().notify(_ev(serverId: 's2', ratingKey: '42')); + DeletionNotifier().notify(_ev(serverId: 's2', itemId: '42')); await _settle(tester); expect(state.events, isEmpty); - DeletionNotifier().notify(_ev(serverId: 's1', ratingKey: '42')); + DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); }); - testWidgets('globalKeys override takes precedence over ratingKeys', (tester) async { + testWidgets('globalKeys override takes precedence over itemIds', (tester) async { late _ProbeState state; await tester.pumpWidget( - _Probe(onState: (s) => state = s, globalKeysOverride: const {'s1:99'}, ratingKeysOverride: const {'5'}), + _Probe(onState: (s) => state = s, globalKeysOverride: const {'s1:99'}, itemIdsOverride: const {'5'}), ); - DeletionNotifier().notify(_ev(serverId: 's1', ratingKey: '5')); + DeletionNotifier().notify(_ev(serverId: 's1', itemId: '5')); await _settle(tester); expect(state.events, isEmpty); - DeletionNotifier().notify(_ev(serverId: 's1', ratingKey: '99')); + DeletionNotifier().notify(_ev(serverId: 's1', itemId: '99')); await _settle(tester); expect(state.events, hasLength(1)); - expect(state.events.first.ratingKey, '99'); + expect(state.events.first.itemId, '99'); }); - testWidgets('empty ratingKeys delivers nothing', (tester) async { + testWidgets('empty itemIds delivers nothing', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {})); - DeletionNotifier().notify(_ev(serverId: 's1', ratingKey: '1')); + DeletionNotifier().notify(_ev(serverId: 's1', itemId: '1')); await _settle(tester); expect(state.events, isEmpty); @@ -139,15 +137,15 @@ void main() { testWidgets('cancels its subscription on dispose', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {'42'})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - DeletionNotifier().notify(_ev(serverId: 's1', ratingKey: '42')); + DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); await tester.pumpWidget(const SizedBox.shrink()); - DeletionNotifier().notify(_ev(serverId: 's1', ratingKey: '42')); + DeletionNotifier().notify(_ev(serverId: 's1', itemId: '42')); await tester.pump(Duration.zero); expect(state.events, hasLength(1)); diff --git a/test/mixins/event_aware_test.dart b/test/mixins/event_aware_test.dart index 6854729b..dd3501e1 100644 --- a/test/mixins/event_aware_test.dart +++ b/test/mixins/event_aware_test.dart @@ -7,19 +7,19 @@ import 'package:plezy/utils/global_key_utils.dart'; import 'package:plezy/utils/hierarchical_event_mixin.dart'; class _FakeEvent with HierarchicalEventMixin { - _FakeEvent({required this.serverId, required this.ratingKey, this.parentChain = const []}); + _FakeEvent({required this.serverId, required this.itemId, this.parentChain = const []}); @override final String serverId; @override - final String ratingKey; + final String itemId; @override final List parentChain; @override - String get globalKey => buildGlobalKey(serverId, ratingKey); + String get globalKey => buildGlobalKey(serverId, itemId); } class _FakeNotifier extends BaseNotifier<_FakeEvent> {} @@ -45,11 +45,11 @@ void main() { mounted: () => true, serverId: () => null, globalKeys: () => null, - ratingKeys: () => null, + itemIds: () => null, onEvent: received.add, ); - final ev = _FakeEvent(serverId: 's1', ratingKey: '42'); + final ev = _FakeEvent(serverId: 's1', itemId: '42'); notifier.notify(ev); await _settle(); @@ -64,17 +64,17 @@ void main() { mounted: () => mounted, serverId: () => null, globalKeys: () => null, - ratingKeys: () => null, + itemIds: () => null, onEvent: received.add, ); - notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '42')); + notifier.notify(_FakeEvent(serverId: 's1', itemId: '42')); await _settle(); expect(received, isEmpty); // Once mounted, future events flow. mounted = true; - final ev = _FakeEvent(serverId: 's1', ratingKey: '99'); + final ev = _FakeEvent(serverId: 's1', itemId: '99'); notifier.notify(ev); await _settle(); expect(received, [ev]); @@ -88,12 +88,12 @@ void main() { mounted: () => true, serverId: () => 's1', globalKeys: () => null, - ratingKeys: () => null, + itemIds: () => null, onEvent: received.add, ); - final keep = _FakeEvent(serverId: 's1', ratingKey: '1'); - final drop = _FakeEvent(serverId: 's2', ratingKey: '1'); + final keep = _FakeEvent(serverId: 's1', itemId: '1'); + final drop = _FakeEvent(serverId: 's2', itemId: '1'); notifier.notify(drop); notifier.notify(keep); await _settle(); @@ -109,12 +109,12 @@ void main() { mounted: () => true, serverId: () => null, globalKeys: () => keys, - ratingKeys: () => null, + itemIds: () => null, onEvent: received.add, ); - final hit = _FakeEvent(serverId: 's1', ratingKey: '42'); - final miss = _FakeEvent(serverId: 's1', ratingKey: '9999'); + final hit = _FakeEvent(serverId: 's1', itemId: '42'); + final miss = _FakeEvent(serverId: 's1', itemId: '9999'); notifier.notify(hit); notifier.notify(miss); await _settle(); @@ -123,27 +123,27 @@ void main() { await sub.cancel(); }); - test('globalKeys filter takes precedence over ratingKeys', () async { - // Even though ratingKeys would match '5', globalKeys path returns early - // and short-circuits the ratingKeys check. + test('globalKeys filter takes precedence over itemIds', () async { + // Even though itemIds would match '5', globalKeys path returns early + // and short-circuits the itemIds check. final globalKeys = {buildGlobalKey('s1', '99')}; - final ratingKeys = {'5'}; + final itemIds = {'5'}; final sub = subscribeToHierarchicalEvents<_FakeEvent>( notifier: notifier, mounted: () => true, serverId: () => null, globalKeys: () => globalKeys, - ratingKeys: () => ratingKeys, + itemIds: () => itemIds, onEvent: received.add, ); - // ratingKey 5 matches the ratingKeys set but not the globalKeys set. - notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '5')); + // itemId 5 matches the itemIds set but not the globalKeys set. + notifier.notify(_FakeEvent(serverId: 's1', itemId: '5')); await _settle(); expect(received, isEmpty); // Now an event matching the globalKeys set comes through. - final hit = _FakeEvent(serverId: 's1', ratingKey: '99'); + final hit = _FakeEvent(serverId: 's1', itemId: '99'); notifier.notify(hit); await _settle(); expect(received, [hit]); @@ -151,18 +151,18 @@ void main() { await sub.cancel(); }); - test('null ratingKeys delivers all events (when no other filters)', () async { + test('null itemIds delivers all events (when no other filters)', () async { final sub = subscribeToHierarchicalEvents<_FakeEvent>( notifier: notifier, mounted: () => true, serverId: () => null, globalKeys: () => null, - ratingKeys: () => null, + itemIds: () => null, onEvent: received.add, ); - final a = _FakeEvent(serverId: 's1', ratingKey: '1'); - final b = _FakeEvent(serverId: 's2', ratingKey: '2'); + final a = _FakeEvent(serverId: 's1', itemId: '1'); + final b = _FakeEvent(serverId: 's2', itemId: '2'); notifier.notify(a); notifier.notify(b); await _settle(); @@ -171,36 +171,36 @@ void main() { await sub.cancel(); }); - test('empty ratingKeys delivers nothing', () async { + test('empty itemIds delivers nothing', () async { final sub = subscribeToHierarchicalEvents<_FakeEvent>( notifier: notifier, mounted: () => true, serverId: () => null, globalKeys: () => null, - ratingKeys: () => {}, + itemIds: () => {}, onEvent: received.add, ); - notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '1')); - notifier.notify(_FakeEvent(serverId: 's2', ratingKey: '2')); + notifier.notify(_FakeEvent(serverId: 's1', itemId: '1')); + notifier.notify(_FakeEvent(serverId: 's2', itemId: '2')); await _settle(); expect(received, isEmpty); await sub.cancel(); }); - test('ratingKeys filter delivers direct hits', () async { + test('itemIds filter delivers direct hits', () async { final sub = subscribeToHierarchicalEvents<_FakeEvent>( notifier: notifier, mounted: () => true, serverId: () => null, globalKeys: () => null, - ratingKeys: () => {'42'}, + itemIds: () => {'42'}, onEvent: received.add, ); - final hit = _FakeEvent(serverId: 's1', ratingKey: '42'); - final miss = _FakeEvent(serverId: 's1', ratingKey: '99'); + final hit = _FakeEvent(serverId: 's1', itemId: '42'); + final miss = _FakeEvent(serverId: 's1', itemId: '99'); notifier.notify(hit); notifier.notify(miss); await _settle(); @@ -209,19 +209,19 @@ void main() { await sub.cancel(); }); - test('ratingKeys filter delivers parent-chain hits', () async { - // Event for an episode whose parent chain includes the show ratingKey. - // The screen tracks the show ratingKey, so it should receive the event. + test('itemIds filter delivers parent-chain hits', () async { + // Event for an episode whose parent chain includes the show id. + // The screen tracks the show id, so it should receive the event. final sub = subscribeToHierarchicalEvents<_FakeEvent>( notifier: notifier, mounted: () => true, serverId: () => null, globalKeys: () => null, - ratingKeys: () => {'show123'}, + itemIds: () => {'show123'}, onEvent: received.add, ); - final episode = _FakeEvent(serverId: 's1', ratingKey: 'episode456', parentChain: ['season789', 'show123']); + final episode = _FakeEvent(serverId: 's1', itemId: 'episode456', parentChain: ['season789', 'show123']); notifier.notify(episode); await _settle(); @@ -230,27 +230,27 @@ void main() { }); test('filters re-evaluate on each event (dynamic getters)', () async { - var rk = {'1'}; + var ids = {'1'}; final sub = subscribeToHierarchicalEvents<_FakeEvent>( notifier: notifier, mounted: () => true, serverId: () => null, globalKeys: () => null, - ratingKeys: () => rk, + itemIds: () => ids, onEvent: received.add, ); - notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '1')); - notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '2')); + notifier.notify(_FakeEvent(serverId: 's1', itemId: '1')); + notifier.notify(_FakeEvent(serverId: 's1', itemId: '2')); await _settle(); - expect(received.map((e) => e.ratingKey).toList(), ['1']); + expect(received.map((e) => e.itemId).toList(), ['1']); // Change the filter set; the next event should be evaluated against it. - rk = {'2'}; - notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '1')); - notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '2')); + ids = {'2'}; + notifier.notify(_FakeEvent(serverId: 's1', itemId: '1')); + notifier.notify(_FakeEvent(serverId: 's1', itemId: '2')); await _settle(); - expect(received.map((e) => e.ratingKey).toList(), ['1', '2']); + expect(received.map((e) => e.itemId).toList(), ['1', '2']); await sub.cancel(); }); @@ -261,16 +261,16 @@ void main() { mounted: () => true, serverId: () => null, globalKeys: () => null, - ratingKeys: () => null, + itemIds: () => null, onEvent: received.add, ); - notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '1')); + notifier.notify(_FakeEvent(serverId: 's1', itemId: '1')); await _settle(); expect(received, hasLength(1)); await sub.cancel(); - notifier.notify(_FakeEvent(serverId: 's1', ratingKey: '2')); + notifier.notify(_FakeEvent(serverId: 's1', itemId: '2')); await _settle(); expect(received, hasLength(1)); }); @@ -281,7 +281,7 @@ void main() { mounted: () => true, serverId: () => null, globalKeys: () => null, - ratingKeys: () => null, + itemIds: () => null, onEvent: received.add, ); diff --git a/test/mixins/item_updatable_test.dart b/test/mixins/item_updatable_test.dart index d827ede5..ee7f4875 100644 --- a/test/mixins/item_updatable_test.dart +++ b/test/mixins/item_updatable_test.dart @@ -1,18 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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/mixins/item_updatable.dart'; -import 'package:plezy/models/plex_metadata.dart'; -import 'package:plezy/services/plex_client.dart'; -/// Probe that mixes in [ItemUpdatable] without supplying a real [PlexClient]. -/// -/// The `client` getter throws — these tests deliberately do not exercise the -/// `updateItem` network path (which would require a real or fake [PlexClient], -/// and PlexClient has a private constructor so it cannot be subclassed in -/// tests without modifying production code). Instead, we exercise the -/// `updateItemInLists` contract directly: that's the override-point screens -/// implement, and the only piece [ItemUpdatable] adds on top of a plain -/// `setState` call site. +/// Probe that mixes in [ItemUpdatable]. These tests exercise the +/// `updateItemInLists` contract directly — the override-point screens +/// implement and the only piece [ItemUpdatable] adds on top of a plain +/// `setState` call site. The network path (`updateItem`) keys off +/// `itemServerId`; left null here so it short-circuits. class _Probe extends StatefulWidget { const _Probe({this.onState}); final void Function(_ProbeState)? onState; @@ -23,24 +20,18 @@ class _Probe extends StatefulWidget { class _ProbeState extends State<_Probe> with ItemUpdatable { /// In-memory list, mirroring the typical screen pattern: a list keyed by - /// `ratingKey` whose entries get swapped out by `updateItemInLists`. - final List items = []; + /// `id` whose entries get swapped out by `updateItemInLists`. + final List items = []; /// Records every `updateItemInLists` invocation for assertions. - final List<({String ratingKey, PlexMetadata metadata})> updates = []; + final List<({String itemId, MediaItem metadata})> updates = []; @override - PlexClient get client => throw UnimplementedError( - 'updateItem network path requires a real PlexClient; not testable without ' - 'a fake. See test header for the gap.', - ); - - @override - void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { - updates.add((ratingKey: ratingKey, metadata: updatedMetadata)); - final index = items.indexWhere((item) => item.ratingKey == ratingKey); + void updateItemInLists(String itemId, MediaItem updatedItem) { + updates.add((itemId: itemId, metadata: updatedItem)); + final index = items.indexWhere((item) => item.id == itemId); if (index != -1) { - items[index] = updatedMetadata; + items[index] = updatedItem; } } @@ -54,7 +45,8 @@ class _ProbeState extends State<_Probe> with ItemUpdatable { Widget build(BuildContext context) => const SizedBox.shrink(); } -PlexMetadata _meta(String ratingKey, {String? title}) => PlexMetadata(ratingKey: ratingKey, title: title); +MediaItem _meta(String id, {String? title}) => + MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, title: title); void main() { group('ItemUpdatable', () { @@ -65,7 +57,7 @@ void main() { expect(state, isA()); }); - testWidgets('updateItemInLists is called with the forwarded ratingKey/metadata', (tester) async { + testWidgets('updateItemInLists is called with the forwarded itemId/metadata', (tester) async { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s)); @@ -73,11 +65,11 @@ void main() { state.updateItemInLists('42', updated); expect(state.updates, hasLength(1)); - expect(state.updates.first.ratingKey, '42'); + expect(state.updates.first.itemId, '42'); expect(identical(state.updates.first.metadata, updated), isTrue); }); - testWidgets('updateItemInLists swaps a matching entry by ratingKey', (tester) async { + testWidgets('updateItemInLists swaps a matching entry by id', (tester) async { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s)); @@ -93,7 +85,7 @@ void main() { expect(identical(state.items[1], replacement), isTrue); }); - testWidgets('updateItemInLists is a no-op for an unknown ratingKey', (tester) async { + testWidgets('updateItemInLists is a no-op for an unknown id', (tester) async { late _ProbeState state; await tester.pumpWidget(_Probe(onState: (s) => state = s)); @@ -103,7 +95,7 @@ void main() { state.updateItemInLists('999', _meta('999')); - expect(state.items.map((i) => i.ratingKey).toList(), ['1', '2']); + expect(state.items.map((i) => i.id).toList(), ['1', '2']); // Still recorded — the contract is "we received this update", regardless // of whether the screen's list contained the key. expect(state.updates, hasLength(1)); @@ -119,7 +111,7 @@ void main() { state.updateItemInLists('2', _meta('2', title: 'B')); state.updateItemInLists('1', _meta('1', title: 'A2')); - expect(state.updates.map((u) => u.ratingKey).toList(), ['1', '2', '1']); + expect(state.updates.map((u) => u.itemId).toList(), ['1', '2', '1']); expect(state.items[0].title, 'A2'); expect(state.items[1].title, 'B'); }); diff --git a/test/mixins/library_tab_state_test.dart b/test/mixins/library_tab_state_test.dart index 288c24b1..9bab555c 100644 --- a/test/mixins/library_tab_state_test.dart +++ b/test/mixins/library_tab_state_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_library.dart'; import 'package:plezy/mixins/library_tab_state.dart'; -import 'package:plezy/models/plex_library.dart'; import 'package:provider/provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; @@ -22,12 +24,12 @@ import 'package:plezy/services/multi_server_manager.dart'; // [PlexClient] inside a [MultiServerManager] (which itself requires a // server registry, network, and prefs) or a deep fake of the manager's // client cache. Not worth it for a mixin whose only contribution is -// `context.getClientForLibrary(library)`. +// `context.getPlexClientForLibrary(library)`. class _Probe extends StatefulWidget { const _Probe({required this.library, required this.onState}); - final PlexLibrary library; + final MediaLibrary library; final void Function(_ProbeState state, BuildContext context) onState; @override @@ -36,7 +38,7 @@ class _Probe extends StatefulWidget { class _ProbeState extends State<_Probe> with LibraryTabStateMixin<_Probe> { @override - PlexLibrary get library => widget.library; + MediaLibrary get library => widget.library; @override Widget build(BuildContext context) { @@ -49,8 +51,8 @@ class _ProbeState extends State<_Probe> with LibraryTabStateMixin<_Probe> { } } -PlexLibrary _lib({String? serverId, String key = '1'}) => - PlexLibrary(key: key, title: 'Movies', type: 'movie', serverId: serverId); +MediaLibrary _lib({String? serverId, String key = '1'}) => + MediaLibrary(id: key, backend: MediaBackend.plex, title: 'Movies', kind: MediaKind.movie, serverId: serverId); void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -65,7 +67,7 @@ void main() { expect(identical(state.library, library), isTrue); expect(state.library.serverId, 'srv-A'); - expect(state.library.key, 'lib-1'); + expect(state.library.id, 'lib-1'); }); testWidgets('getClientForLibrary throws when no server matches and no fallback online', (tester) async { diff --git a/test/mixins/paginated_item_loader_test.dart b/test/mixins/paginated_item_loader_test.dart index 24671577..5b07d250 100644 --- a/test/mixins/paginated_item_loader_test.dart +++ b/test/mixins/paginated_item_loader_test.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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/mixins/paginated_item_loader.dart'; -import 'package:plezy/models/plex_metadata.dart'; -import 'package:plezy/services/plex_client.dart'; -import 'package:plezy/utils/plex_http_client.dart'; -import 'package:plezy/utils/plex_http_exception.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; /// Test probe wired with a controllable `fetchPage` so individual tests can /// stage successes, failures, and slow responses. @@ -15,10 +17,10 @@ class _PaginatedProbe extends StatefulWidget { /// Returns a future for the requested `(start, size)` slice. Tests stage /// futures via this fetcher to control timing and error paths. - final Future Function(int start, int size, AbortController? abort) fetcher; + final Future> Function(int start, int size, AbortController? abort) fetcher; final void Function(_PaginatedProbeState)? onState; - final void Function(int start, List items)? onPageLoadedHook; + final void Function(int start, List items)? onPageLoadedHook; @override State<_PaginatedProbe> createState() => _PaginatedProbeState(); @@ -29,14 +31,14 @@ class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoad final List<({int start, int size})> fetchArgs = []; @override - Future fetchPage(int start, int size, AbortController? abort) { + Future> fetchPage(int start, int size, AbortController? abort) { fetchCalls++; fetchArgs.add((start: start, size: size)); return widget.fetcher(start, size, abort); } @override - void onPageLoaded(int start, List items) { + void onPageLoaded(int start, List items) { widget.onPageLoadedHook?.call(start, items); } @@ -56,10 +58,14 @@ class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoad Widget build(BuildContext context) => const SizedBox.shrink(); } -PlexMetadata _meta(int i) => PlexMetadata(ratingKey: 'k$i', title: 't$i'); +MediaItem _meta(int i) => MediaItem(id: 'k$i', backend: MediaBackend.plex, kind: MediaKind.movie, title: 't$i'); -LibraryContentResult _result({required int start, required int size, required int totalSize}) { - return LibraryContentResult(items: List.generate(size, (i) => _meta(start + i)), totalSize: totalSize); +LibraryPage _result({required int start, required int size, required int totalSize}) { + return LibraryPage( + items: List.generate(size, (i) => _meta(start + i)), + totalCount: totalSize, + offset: start, + ); } void main() { @@ -80,9 +86,9 @@ void main() { expect(state.fetchArgs.first, (start: 0, size: 10)); expect(state.totalSize, 42); expect(state.loadedItems.length, 10); - expect(state.loadedItems[0]?.ratingKey, 'k0'); - expect(state.loadedItems[9]?.ratingKey, 'k9'); - expect(result.totalSize, 42); + expect(state.loadedItems[0]?.id, 'k0'); + expect(state.loadedItems[9]?.id, 'k9'); + expect(result.totalCount, 42); }); testWidgets('onPageLoaded fires after a successful initial page', (tester) async { @@ -108,7 +114,7 @@ void main() { _PaginatedProbe( onState: (s) => state = s, // Empty list mirrors the "library has no items" wire response. - fetcher: (start, size, abort) async => const LibraryContentResult(items: [], totalSize: 0), + fetcher: (start, size, abort) async => const LibraryPage(items: [], totalCount: 0), ), ); @@ -194,7 +200,7 @@ void main() { rangeAttempt++; if (rangeAttempt == 1) { // First range fetch fails — triggers retry path. - throw PlexHttpException(type: PlexHttpErrorType.connectionError, message: 'boom'); + throw MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'boom'); } // Retry fetch succeeds. return _result(start: start, size: size, totalSize: 400); @@ -221,7 +227,7 @@ void main() { expect(rangeAttempt, greaterThanOrEqualTo(2)); // failed + retry }); - testWidgets('cancelled fetch (PlexHttpErrorType.cancelled) does not schedule a retry', (tester) async { + testWidgets('cancelled fetch (MediaServerHttpErrorType.cancelled) does not schedule a retry', (tester) async { late _PaginatedProbeState state; var sawCancellation = false; @@ -233,7 +239,7 @@ void main() { return _result(start: 0, size: size, totalSize: 400); } sawCancellation = true; - throw PlexHttpException(type: PlexHttpErrorType.cancelled, message: 'aborted'); + throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'aborted'); }, ), ); @@ -258,7 +264,7 @@ void main() { testWidgets('dispose during in-flight load is a no-op (no setState on unmounted)', (tester) async { late _PaginatedProbeState state; - final completer = Completer(); + final completer = Completer>(); await tester.pumpWidget( _PaginatedProbe(onState: (s) => state = s, fetcher: (start, size, abort) => completer.future), @@ -286,13 +292,13 @@ void main() { testWidgets('disposePagination clears state and aborts in-flight fetches', (tester) async { late _PaginatedProbeState state; - final futures = >[]; + final futures = >>[]; await tester.pumpWidget( _PaginatedProbe( onState: (s) => state = s, fetcher: (start, size, abort) { - final c = Completer(); + final c = Completer>(); futures.add(c); return c.future; }, @@ -335,10 +341,10 @@ void main() { expect(state.totalSize, 4); expect(state.loadedItems.length, 4); - expect(state.loadedItems[0]?.ratingKey, 'k0'); - expect(state.loadedItems[1]?.ratingKey, 'k1'); - expect(state.loadedItems[2]?.ratingKey, 'k3'); // shifted from index 3 - expect(state.loadedItems[3]?.ratingKey, 'k4'); // shifted from index 4 + expect(state.loadedItems[0]?.id, 'k0'); + expect(state.loadedItems[1]?.id, 'k1'); + expect(state.loadedItems[2]?.id, 'k3'); // shifted from index 3 + expect(state.loadedItems[3]?.id, 'k4'); // shifted from index 4 expect(state.loadedItems.containsKey(4), isFalse); }); @@ -422,7 +428,7 @@ void main() { testWidgets('clearPendingRanges allows another fetch attempt without dedupe', (tester) async { late _PaginatedProbeState state; - final completers = >[]; + final completers = >>[]; await tester.pumpWidget( _PaginatedProbe( @@ -432,7 +438,7 @@ void main() { if (start == 0 && completers.isEmpty) { return Future.value(_result(start: 0, size: size, totalSize: 400)); } - final c = Completer(); + final c = Completer>(); completers.add(c); return c.future; }, @@ -492,14 +498,14 @@ void main() { testWidgets('a stale in-flight fetch from before resetPaginationState is dropped', (tester) async { late _PaginatedProbeState state; - Completer? staleFetch; + Completer>? staleFetch; await tester.pumpWidget( _PaginatedProbe( onState: (s) => state = s, fetcher: (start, size, abort) { if (staleFetch == null) { - staleFetch = Completer(); + staleFetch = Completer>(); return staleFetch!.future; } return Future.value(_result(start: start, size: size, totalSize: 99)); diff --git a/test/mixins/server_bound_media_mixin_test.dart b/test/mixins/server_bound_media_mixin_test.dart index c2120172..f86de891 100644 --- a/test/mixins/server_bound_media_mixin_test.dart +++ b/test/mixins/server_bound_media_mixin_test.dart @@ -1,14 +1,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.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/mixins/server_bound_media_mixin.dart'; -import 'package:plezy/models/plex_metadata.dart'; /// Probe widget exposing the mixin's surface so tests can read its getters /// and call its helpers against a real BuildContext. class _Probe extends StatefulWidget { const _Probe({required this.metadata, required this.offline, required this.onState}); - final PlexMetadata metadata; + final MediaItem metadata; final bool offline; final void Function(_ProbeState state, BuildContext context) onState; @@ -18,7 +20,7 @@ class _Probe extends StatefulWidget { class _ProbeState extends State<_Probe> with ServerBoundMediaMixin<_Probe> { @override - PlexMetadata get serverBoundMetadata => widget.metadata; + MediaItem get serverBoundMetadata => widget.metadata; @override bool get isServerBoundOffline => widget.offline; @@ -34,8 +36,8 @@ class _ProbeState extends State<_Probe> with ServerBoundMediaMixin<_Probe> { } } -PlexMetadata _meta({String? serverId, String ratingKey = 'rk1'}) => - PlexMetadata(ratingKey: ratingKey, serverId: serverId); +MediaItem _meta({String? serverId, String ratingKey = 'rk1'}) => + MediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.movie, serverId: serverId); void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -124,7 +126,7 @@ void main() { expect(state.toServerBoundGlobalKey('rk-1'), ':rk-1'); }); - testWidgets('getServerBoundClient returns null in offline mode regardless of providers', (tester) async { + testWidgets('getServerBoundPlexClient returns null in offline mode regardless of providers', (tester) async { late _ProbeState state; late BuildContext ctx; await tester.pumpWidget( @@ -141,7 +143,7 @@ void main() { // The provider extension short-circuits to null when isOffline is true, // so no MultiServerProvider is required to exercise this branch. - expect(state.getServerBoundClient(ctx), isNull); + expect(state.getServerBoundPlexClient(ctx), isNull); }); }); } diff --git a/test/mixins/tab_navigation_mixin_test.dart b/test/mixins/tab_navigation_mixin_test.dart index 7fbeb030..8dec6e42 100644 --- a/test/mixins/tab_navigation_mixin_test.dart +++ b/test/mixins/tab_navigation_mixin_test.dart @@ -163,6 +163,32 @@ void main() { expect(state.tabController.index, 0); }); + testWidgets('dispose + re-init reseats the TabController without LateInitializationError', (tester) async { + // Regression: libraries_screen calls disposeTabNavigation() then + // initTabNavigation() inside _updateVisibleTabs whenever the visible + // tab set changes (e.g. switching between a Plex library with 4 tabs + // and a Jellyfin library with 1). A `late final` on `tabController` + // would throw LateInitializationError on the second init; the mixin + // must allow re-initialization for the lifetime of the State. + late _ProbeState state; + await tester.pumpWidget(_Probe(tabCount: 3, onState: (s) => state = s)); + final original = state.tabController; + + state.disposeTabNavigation(); + // After dispose+init the controller field must point at a fresh + // instance so the listener and gamepad bindings reattach cleanly. + state.initTabNavigation(); + + expect(identical(state.tabController, original), isFalse); + expect(state.tabController.length, 3); + expect(state.tabController.index, 0); + + // The original is disposed; touching it would throw — but the + // mixin's references all point at the new instance now. + expect(GamepadService.onL1Pressed, isNotNull); + expect(GamepadService.onR1Pressed, isNotNull); + }); + testWidgets('onTabChanged fires when tabController.index changes', (tester) async { late _ProbeState state; await tester.pumpWidget(_Probe(tabCount: 3, onState: (s) => state = s)); diff --git a/test/mixins/watch_state_aware_test.dart b/test/mixins/watch_state_aware_test.dart index f0f30a33..d010e39e 100644 --- a/test/mixins/watch_state_aware_test.dart +++ b/test/mixins/watch_state_aware_test.dart @@ -4,12 +4,12 @@ import 'package:plezy/mixins/watch_state_aware.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; class _Probe extends StatefulWidget { - const _Probe({this.onState, this.serverIdOverride, this.globalKeysOverride, required this.ratingKeysOverride}); + const _Probe({this.onState, this.serverIdOverride, this.globalKeysOverride, required this.itemIdsOverride}); final void Function(_ProbeState)? onState; final String? serverIdOverride; final Set? globalKeysOverride; - final Set? ratingKeysOverride; + final Set? itemIdsOverride; @override State<_Probe> createState() => _ProbeState(); @@ -22,7 +22,7 @@ class _ProbeState extends State<_Probe> with WatchStateAware { // tests mutate them after initState if needed. String? _serverId; Set? _globalKeys; - Set? _ratingKeys; + Set? _itemIds; @override String? get watchStateServerId => _serverId; @@ -31,7 +31,7 @@ class _ProbeState extends State<_Probe> with WatchStateAware { Set? get watchedGlobalKeys => _globalKeys; @override - Set? get watchedRatingKeys => _ratingKeys; + Set? get watchedIds => _itemIds; @override void onWatchStateChanged(WatchStateEvent event) { @@ -42,7 +42,7 @@ class _ProbeState extends State<_Probe> with WatchStateAware { void initState() { _serverId = widget.serverIdOverride; _globalKeys = widget.globalKeysOverride; - _ratingKeys = widget.ratingKeysOverride; + _itemIds = widget.itemIdsOverride; super.initState(); widget.onState?.call(this); } @@ -53,16 +53,11 @@ class _ProbeState extends State<_Probe> with WatchStateAware { WatchStateEvent _ev({ required String serverId, - required String ratingKey, + required String itemId, List parentChain = const [], WatchStateChangeType type = WatchStateChangeType.watched, -}) => WatchStateEvent( - ratingKey: ratingKey, - serverId: serverId, - changeType: type, - parentChain: parentChain, - mediaType: 'movie', -); +}) => + WatchStateEvent(itemId: itemId, serverId: serverId, changeType: type, parentChain: parentChain, mediaType: 'movie'); /// Drain microtasks the broadcast stream uses to deliver events. Future _settle(WidgetTester tester) async { @@ -71,23 +66,23 @@ Future _settle(WidgetTester tester) async { void main() { group('WatchStateAware', () { - testWidgets('receives events for ratingKeys it tracks', (tester) async { + testWidgets('receives events for itemIds it tracks', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {'42'})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - final hit = _ev(serverId: 's1', ratingKey: '42'); + final hit = _ev(serverId: 's1', itemId: '42'); WatchStateNotifier().notify(hit); await _settle(tester); expect(state.events, hasLength(1)); - expect(state.events.first.ratingKey, '42'); + expect(state.events.first.itemId, '42'); }); - testWidgets('drops events for ratingKeys outside its set', (tester) async { + testWidgets('drops events for itemIds outside its set', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {'42'})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - WatchStateNotifier().notify(_ev(serverId: 's1', ratingKey: '999')); + WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '999')); await _settle(tester); expect(state.events, isEmpty); @@ -95,56 +90,54 @@ void main() { testWidgets('parent-chain hits are delivered', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {'show123'})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'show123'})); // Episode whose parent chain contains the show this screen tracks. WatchStateNotifier().notify( - _ev(serverId: 's1', ratingKey: 'episode456', parentChain: const ['season789', 'show123']), + _ev(serverId: 's1', itemId: 'episode456', parentChain: const ['season789', 'show123']), ); await _settle(tester); expect(state.events, hasLength(1)); - expect(state.events.first.ratingKey, 'episode456'); + expect(state.events.first.itemId, 'episode456'); }); testWidgets('serverId override scopes events', (tester) async { late _ProbeState state; - await tester.pumpWidget( - _Probe(onState: (s) => state = s, serverIdOverride: 's1', ratingKeysOverride: const {'42'}), - ); + await tester.pumpWidget(_Probe(onState: (s) => state = s, serverIdOverride: 's1', itemIdsOverride: const {'42'})); - WatchStateNotifier().notify(_ev(serverId: 's2', ratingKey: '42')); + WatchStateNotifier().notify(_ev(serverId: 's2', itemId: '42')); await _settle(tester); expect(state.events, isEmpty); - WatchStateNotifier().notify(_ev(serverId: 's1', ratingKey: '42')); + WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); }); - testWidgets('globalKeys override takes precedence over ratingKeys', (tester) async { + testWidgets('globalKeys override takes precedence over itemIds', (tester) async { late _ProbeState state; await tester.pumpWidget( - _Probe(onState: (s) => state = s, globalKeysOverride: const {'s1:99'}, ratingKeysOverride: const {'5'}), + _Probe(onState: (s) => state = s, globalKeysOverride: const {'s1:99'}, itemIdsOverride: const {'5'}), ); - // ratingKey 5 matches the ratingKeys set, but globalKeys is the active filter. - WatchStateNotifier().notify(_ev(serverId: 's1', ratingKey: '5')); + // itemId 5 matches the itemIds set, but globalKeys is the active filter. + WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '5')); await _settle(tester); expect(state.events, isEmpty); - WatchStateNotifier().notify(_ev(serverId: 's1', ratingKey: '99')); + WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '99')); await _settle(tester); expect(state.events, hasLength(1)); - expect(state.events.first.ratingKey, '99'); + expect(state.events.first.itemId, '99'); }); - testWidgets('empty ratingKeys delivers nothing', (tester) async { + testWidgets('empty itemIds delivers nothing', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {})); - WatchStateNotifier().notify(_ev(serverId: 's1', ratingKey: '1')); - WatchStateNotifier().notify(_ev(serverId: 's2', ratingKey: '2')); + WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '1')); + WatchStateNotifier().notify(_ev(serverId: 's2', itemId: '2')); await _settle(tester); expect(state.events, isEmpty); @@ -152,16 +145,16 @@ void main() { testWidgets('disposes its subscription so events stop after unmount', (tester) async { late _ProbeState state; - await tester.pumpWidget(_Probe(onState: (s) => state = s, ratingKeysOverride: const {'42'})); + await tester.pumpWidget(_Probe(onState: (s) => state = s, itemIdsOverride: const {'42'})); - WatchStateNotifier().notify(_ev(serverId: 's1', ratingKey: '42')); + WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '42')); await _settle(tester); expect(state.events, hasLength(1)); // Replace the tree to dispose the probe. await tester.pumpWidget(const SizedBox.shrink()); - WatchStateNotifier().notify(_ev(serverId: 's1', ratingKey: '42')); + WatchStateNotifier().notify(_ev(serverId: 's1', itemId: '42')); await tester.pump(Duration.zero); // No second delivery — subscription cancelled. diff --git a/test/models/livetv_channel_test.dart b/test/models/livetv_channel_test.dart new file mode 100644 index 00000000..97396288 --- /dev/null +++ b/test/models/livetv_channel_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/livetv_channel.dart'; + +void main() { + test('favoriteChannelKey includes source and id', () { + expect(favoriteChannelKey('server://a/provider', '101'), isNot(favoriteChannelKey('server://b/provider', '101'))); + expect( + FavoriteChannel(source: 'server://a/provider', id: '101').stableKey, + favoriteChannelKey('server://a/provider', '101'), + ); + }); + + test('liveTvChannelScopeKey includes server, dvr, and channel key', () { + final a = LiveTvChannel(key: '101', serverId: 'server-1', liveDvrKey: 'dvr-a'); + final b = LiveTvChannel(key: '101', serverId: 'server-1', liveDvrKey: 'dvr-b'); + + expect(liveTvChannelScopeKey(a), isNot(liveTvChannelScopeKey(b))); + }); +} diff --git a/test/models/plex_media_version_test.dart b/test/models/plex_media_version_test.dart index b15e9cfe..89bcdde9 100644 --- a/test/models/plex_media_version_test.dart +++ b/test/models/plex_media_version_test.dart @@ -1,5 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_media_version.dart'; +import 'package:plezy/media/media_part.dart'; +import 'package:plezy/media/media_version.dart'; +import 'package:plezy/services/plex_mappers.dart'; Map _media({ int id = 1, @@ -20,11 +22,11 @@ Map _media({ } void main() { - group('PlexMediaVersion accessibility parsing', () { + group('Plex media version accessibility parsing', () { test('accessible/exists are null when Plex did not include them', () { - final v = PlexMediaVersion.fromJson(_media()); - expect(v.accessible, isNull); - expect(v.exists, isNull); + final v = PlexMappers.mediaVersionFromJson(_media()); + expect(v.parts.single.accessible, isNull); + expect(v.parts.single.exists, isNull); expect( v.isPlayable, isTrue, @@ -33,38 +35,41 @@ void main() { }); test('parses int 0/1 from Plex JSON output', () { - final notExists = PlexMediaVersion.fromJson(_media(partExtras: {'exists': 0, 'accessible': 1})); - expect(notExists.exists, isFalse); - expect(notExists.accessible, isTrue); + final notExists = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': 0, 'accessible': 1})); + expect(notExists.parts.single.exists, isFalse); + expect(notExists.parts.single.accessible, isTrue); expect(notExists.isPlayable, isFalse); - final notAccessible = PlexMediaVersion.fromJson(_media(partExtras: {'exists': 1, 'accessible': 0})); - expect(notAccessible.exists, isTrue); - expect(notAccessible.accessible, isFalse); + final notAccessible = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': 1, 'accessible': 0})); + expect(notAccessible.parts.single.exists, isTrue); + expect(notAccessible.parts.single.accessible, isFalse); expect(notAccessible.isPlayable, isFalse); - final ok = PlexMediaVersion.fromJson(_media(partExtras: {'exists': 1, 'accessible': 1})); + final ok = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': 1, 'accessible': 1})); expect(ok.isPlayable, isTrue); }); test('parses native bool', () { - final v = PlexMediaVersion.fromJson(_media(partExtras: {'exists': false, 'accessible': true})); - expect(v.exists, isFalse); - expect(v.accessible, isTrue); + final v = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': false, 'accessible': true})); + expect(v.parts.single.exists, isFalse); + expect(v.parts.single.accessible, isTrue); expect(v.isPlayable, isFalse); }); test('parses string "0"/"1" forms (XML-to-JSON conversion)', () { - final v = PlexMediaVersion.fromJson(_media(partExtras: {'exists': '0', 'accessible': '1'})); - expect(v.exists, isFalse); - expect(v.accessible, isTrue); + final v = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': '0', 'accessible': '1'})); + expect(v.parts.single.exists, isFalse); + expect(v.parts.single.accessible, isTrue); }); test('isPlayable truth table mirrors Plex web semantics', () { // Mirrors plex-web.js:28926: !1 !== e.exists && !1 !== e.accessible // Anything but explicit `false` for both fields → playable. bool playable({bool? acc, bool? ex}) { - return PlexMediaVersion(id: 1, partKey: '/k', accessible: acc, exists: ex).isPlayable; + return MediaVersion( + id: '1', + parts: [MediaPart(id: '1', streamPath: '/k', accessible: acc, exists: ex)], + ).isPlayable; } expect(playable(acc: null, ex: null), isTrue); @@ -87,16 +92,16 @@ void main() { 'container': 'mkv', 'Part': {'id': 101, 'key': '/library/parts/1/file.mkv', 'exists': 0}, }; - final v = PlexMediaVersion.fromJson(json); - expect(v.exists, isFalse); + final v = PlexMappers.mediaVersionFromJson(json); + expect(v.parts.single.exists, isFalse); expect(v.isPlayable, isFalse); }); test('missing Part array leaves accessibility fields null', () { final json = {'id': 1, 'videoResolution': '1080', 'videoCodec': 'h264', 'container': 'mkv'}; - final v = PlexMediaVersion.fromJson(json); - expect(v.accessible, isNull); - expect(v.exists, isNull); + final v = PlexMappers.mediaVersionFromJson(json); + expect(v.parts.single.accessible, isNull); + expect(v.parts.single.exists, isNull); expect(v.isPlayable, isTrue); }); }); diff --git a/test/models/plex_metadata_test.dart b/test/models/plex_metadata_test.dart deleted file mode 100644 index 91049e56..00000000 --- a/test/models/plex_metadata_test.dart +++ /dev/null @@ -1,396 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_metadata.dart'; -import 'package:plezy/services/settings_service.dart' show EpisodePosterMode; - -PlexMetadata _make({ - String ratingKey = '1', - String? key, - String? type, - String? title, - String? parentTitle, - String? grandparentTitle, - String? thumb, - String? parentThumb, - String? grandparentThumb, - String? art, - String? parentRatingKey, - String? grandparentRatingKey, - int? duration, - int? viewOffset, - int? viewCount, - int? leafCount, - int? viewedLeafCount, - String? serverId, -}) { - return PlexMetadata( - ratingKey: ratingKey, - key: key, - type: type, - title: title, - parentTitle: parentTitle, - grandparentTitle: grandparentTitle, - thumb: thumb, - parentThumb: parentThumb, - grandparentThumb: grandparentThumb, - art: art, - parentRatingKey: parentRatingKey, - grandparentRatingKey: grandparentRatingKey, - duration: duration, - viewOffset: viewOffset, - viewCount: viewCount, - leafCount: leafCount, - viewedLeafCount: viewedLeafCount, - serverId: serverId, - ); -} - -void main() { - group('PlexMetadata.mediaType', () { - test('maps known lowercase type strings', () { - for (final pair in const [ - ('movie', PlexMediaType.movie), - ('show', PlexMediaType.show), - ('season', PlexMediaType.season), - ('episode', PlexMediaType.episode), - ('artist', PlexMediaType.artist), - ('album', PlexMediaType.album), - ('track', PlexMediaType.track), - ('collection', PlexMediaType.collection), - ('playlist', PlexMediaType.playlist), - ('clip', PlexMediaType.clip), - ('photo', PlexMediaType.photo), - ]) { - expect(_make(type: pair.$1).mediaType, pair.$2, reason: 'type=${pair.$1}'); - } - }); - - test('case-insensitive', () { - expect(_make(type: 'MOVIE').mediaType, PlexMediaType.movie); - expect(_make(type: 'Episode').mediaType, PlexMediaType.episode); - }); - - test('unknown / null -> PlexMediaType.unknown', () { - expect(_make(type: null).mediaType, PlexMediaType.unknown); - expect(_make(type: '').mediaType, PlexMediaType.unknown); - expect(_make(type: 'weird').mediaType, PlexMediaType.unknown); - }); - }); - - group('PlexMediaType enum extensions', () { - test('isVideo', () { - expect(PlexMediaType.movie.isVideo, isTrue); - expect(PlexMediaType.episode.isVideo, isTrue); - expect(PlexMediaType.clip.isVideo, isTrue); - expect(PlexMediaType.show.isVideo, isFalse); - expect(PlexMediaType.season.isVideo, isFalse); - expect(PlexMediaType.track.isVideo, isFalse); - }); - - test('isShowRelated', () { - expect(PlexMediaType.show.isShowRelated, isTrue); - expect(PlexMediaType.season.isShowRelated, isTrue); - expect(PlexMediaType.episode.isShowRelated, isTrue); - expect(PlexMediaType.movie.isShowRelated, isFalse); - expect(PlexMediaType.clip.isShowRelated, isFalse); - }); - - test('isMusic', () { - expect(PlexMediaType.artist.isMusic, isTrue); - expect(PlexMediaType.album.isMusic, isTrue); - expect(PlexMediaType.track.isMusic, isTrue); - expect(PlexMediaType.movie.isMusic, isFalse); - }); - - test('isPlayable', () { - expect(PlexMediaType.movie.isPlayable, isTrue); - expect(PlexMediaType.episode.isPlayable, isTrue); - expect(PlexMediaType.clip.isPlayable, isTrue); - expect(PlexMediaType.track.isPlayable, isTrue); - expect(PlexMediaType.show.isPlayable, isFalse); - expect(PlexMediaType.artist.isPlayable, isFalse); - }); - - test('typeNumber for API-addressable types', () { - expect(PlexMediaType.movie.typeNumber, 1); - expect(PlexMediaType.show.typeNumber, 2); - expect(PlexMediaType.season.typeNumber, 3); - expect(PlexMediaType.episode.typeNumber, 4); - expect(PlexMediaType.artist.typeNumber, 8); - expect(PlexMediaType.album.typeNumber, 9); - expect(PlexMediaType.track.typeNumber, 10); - }); - - test('typeNumber fallback is 0 for collection/playlist/clip/photo/unknown', () { - expect(PlexMediaType.collection.typeNumber, 0); - expect(PlexMediaType.playlist.typeNumber, 0); - expect(PlexMediaType.clip.typeNumber, 0); - expect(PlexMediaType.photo.typeNumber, 0); - expect(PlexMediaType.unknown.typeNumber, 0); - }); - }); - - group('globalKey', () { - test('joins serverId:ratingKey when serverId present', () { - expect(_make(ratingKey: '42', serverId: 'srv').globalKey, 'srv:42'); - }); - - test('falls back to bare ratingKey when serverId is null', () { - expect(_make(ratingKey: '42').globalKey, '42'); - }); - }); - - group('parentChain', () { - test('movie (no parents) -> empty list', () { - expect(_make(type: 'movie').parentChain, isEmpty); - }); - - test('season (show parent only) -> [show]', () { - expect(_make(type: 'season', grandparentRatingKey: 's1').parentChain, ['s1']); - }); - - test('episode (season + show) -> [season, show]', () { - expect(_make(type: 'episode', parentRatingKey: 'se1', grandparentRatingKey: 'sh1').parentChain, ['se1', 'sh1']); - }); - - test('omits null entries (only parent, no grandparent)', () { - expect(_make(parentRatingKey: 'p').parentChain, ['p']); - }); - }); - - group('isLibrarySection & librarySectionKey', () { - test('non-library-section key', () { - final m = _make(key: '/library/metadata/12345'); - expect(m.isLibrarySection, isFalse); - expect(m.librarySectionKey, isNull); - }); - - test('library-section key extracts numeric id', () { - final m = _make(key: '/library/sections/7/all'); - expect(m.isLibrarySection, isTrue); - expect(m.librarySectionKey, '7'); - }); - - test('library-section without trailing path still extracts id', () { - final m = _make(key: '/library/sections/12'); - expect(m.isLibrarySection, isTrue); - expect(m.librarySectionKey, '12'); - }); - - test('null key -> not a library section', () { - final m = _make(); - expect(m.isLibrarySection, isFalse); - expect(m.librarySectionKey, isNull); - }); - }); - - group('displayTitle / displaySubtitle', () { - test('episode with grandparentTitle prefers show name', () { - final m = _make(type: 'episode', title: 'Pilot', grandparentTitle: 'My Show'); - expect(m.displayTitle, 'My Show'); - expect(m.displaySubtitle, 'Pilot'); - }); - - test('episode without grandparentTitle falls back to title', () { - final m = _make(type: 'episode', title: 'Pilot'); - expect(m.displayTitle, 'Pilot'); - expect(m.displaySubtitle, isNull); - }); - - test('season with grandparentTitle shows show name as title, season name as subtitle', () { - final m = _make(type: 'season', title: 'Season 1', grandparentTitle: 'My Show'); - expect(m.displayTitle, 'My Show'); - expect(m.displaySubtitle, 'Season 1'); - }); - - test('season without grandparent falls back to parentTitle', () { - final m = _make(type: 'season', title: 'Season 1', parentTitle: 'My Show'); - expect(m.displayTitle, 'My Show'); - expect(m.displaySubtitle, 'Season 1'); - }); - - test('movie uses its own title, no subtitle', () { - final m = _make(type: 'movie', title: 'Inception'); - expect(m.displayTitle, 'Inception'); - expect(m.displaySubtitle, isNull); - }); - - test('missing title returns empty displayTitle', () { - final m = _make(type: 'movie'); - expect(m.displayTitle, ''); - }); - }); - - group('posterThumb', () { - test('episode + seriesPoster -> grandparentThumb, fallback thumb', () { - expect( - _make(type: 'episode', thumb: 't', grandparentThumb: 'g').posterThumb(mode: EpisodePosterMode.seriesPoster), - 'g', - ); - expect(_make(type: 'episode', thumb: 't').posterThumb(mode: EpisodePosterMode.seriesPoster), 't'); - }); - - test('episode + seasonPoster -> parentThumb → grandparentThumb → thumb', () { - expect( - _make( - type: 'episode', - thumb: 't', - parentThumb: 'p', - grandparentThumb: 'g', - ).posterThumb(mode: EpisodePosterMode.seasonPoster), - 'p', - ); - expect( - _make(type: 'episode', thumb: 't', grandparentThumb: 'g').posterThumb(mode: EpisodePosterMode.seasonPoster), - 'g', - ); - expect(_make(type: 'episode', thumb: 't').posterThumb(mode: EpisodePosterMode.seasonPoster), 't'); - }); - - test('episode + episodeThumbnail -> thumb', () { - expect( - _make( - type: 'episode', - thumb: 't', - parentThumb: 'p', - grandparentThumb: 'g', - ).posterThumb(mode: EpisodePosterMode.episodeThumbnail), - 't', - ); - }); - - test('season -> grandparentThumb when available', () { - expect(_make(type: 'season', thumb: 't', grandparentThumb: 'g').posterThumb(), 'g'); - }); - - test('season without grandparent -> thumb', () { - expect(_make(type: 'season', thumb: 't').posterThumb(), 't'); - }); - - test('season + mixed hub + episodeThumbnail -> art fallback thumb', () { - expect( - _make( - type: 'season', - thumb: 't', - art: 'a', - grandparentThumb: 'g', - ).posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true), - 'a', - ); - expect( - _make( - type: 'season', - thumb: 't', - grandparentThumb: 'g', - ).posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true), - 't', - ); - }); - - test('movie/show in mixed hub + episodeThumbnail -> art, fallback thumb', () { - expect( - _make( - type: 'movie', - thumb: 't', - art: 'a', - ).posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true), - 'a', - ); - expect( - _make(type: 'show', thumb: 't').posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true), - 't', - ); - }); - - test('movie/show outside mixed hub -> thumb regardless of mode', () { - expect(_make(type: 'movie', thumb: 't', art: 'a').posterThumb(mode: EpisodePosterMode.episodeThumbnail), 't'); - }); - - test('other types default to thumb', () { - expect(_make(type: 'artist', thumb: 't').posterThumb(), 't'); - expect(_make(type: 'track', thumb: 't').posterThumb(), 't'); - }); - }); - - group('usesWideAspectRatio', () { - test('clips always wide', () { - expect(_make(type: 'clip').usesWideAspectRatio(EpisodePosterMode.seriesPoster), isTrue); - expect(_make(type: 'clip').usesWideAspectRatio(EpisodePosterMode.seasonPoster), isTrue); - }); - - test('episode + episodeThumbnail is wide', () { - expect(_make(type: 'episode').usesWideAspectRatio(EpisodePosterMode.episodeThumbnail), isTrue); - }); - - test('episode + other modes is not wide', () { - expect(_make(type: 'episode').usesWideAspectRatio(EpisodePosterMode.seriesPoster), isFalse); - expect(_make(type: 'episode').usesWideAspectRatio(EpisodePosterMode.seasonPoster), isFalse); - }); - - test('movie/show/season in mixed hub + episodeThumbnail is wide', () { - for (final t in const ['movie', 'show', 'season']) { - expect( - _make(type: t).usesWideAspectRatio(EpisodePosterMode.episodeThumbnail, mixedHubContext: true), - isTrue, - reason: 'type=$t', - ); - } - }); - - test('movie/show/season outside mixed hub is not wide', () { - expect(_make(type: 'movie').usesWideAspectRatio(EpisodePosterMode.episodeThumbnail), isFalse); - }); - }); - - group('watch-state predicates', () { - test('hasActiveProgress requires both duration and viewOffset set, with 0 < vo < dur', () { - expect(_make().hasActiveProgress, isFalse); - expect(_make(duration: 100).hasActiveProgress, isFalse); - expect(_make(viewOffset: 10).hasActiveProgress, isFalse); - expect(_make(duration: 100, viewOffset: 0).hasActiveProgress, isFalse); - expect(_make(duration: 100, viewOffset: 100).hasActiveProgress, isFalse); - expect(_make(duration: 100, viewOffset: 50).hasActiveProgress, isTrue); - expect(_make(duration: 100, viewOffset: 1).hasActiveProgress, isTrue); - expect(_make(duration: 100, viewOffset: 99).hasActiveProgress, isTrue); - }); - - test('isPartiallyWatched requires leaf counts with 0 < viewed < total', () { - expect(_make().isPartiallyWatched, isFalse); - expect(_make(leafCount: 10).isPartiallyWatched, isFalse); - expect(_make(viewedLeafCount: 3).isPartiallyWatched, isFalse); - expect(_make(leafCount: 10, viewedLeafCount: 0).isPartiallyWatched, isFalse); - expect(_make(leafCount: 10, viewedLeafCount: 10).isPartiallyWatched, isFalse); - expect(_make(leafCount: 10, viewedLeafCount: 3).isPartiallyWatched, isTrue); - }); - - test('isWatched prefers leaf counts when both present', () { - expect(_make(leafCount: 10, viewedLeafCount: 10).isWatched, isTrue); - expect(_make(leafCount: 10, viewedLeafCount: 11).isWatched, isTrue); - expect(_make(leafCount: 10, viewedLeafCount: 9).isWatched, isFalse); - }); - - test('isWatched uses viewCount when leaf counts absent', () { - expect(_make(viewCount: 1).isWatched, isTrue); - expect(_make(viewCount: 0).isWatched, isFalse); - expect(_make().isWatched, isFalse); - }); - }); - - group('heroArt', () { - test('uses backgroundSquare when container is squarer than ~1.39', () { - final m = PlexMetadata(ratingKey: '1', art: 'wide.jpg', backgroundSquare: 'square.jpg'); - expect(m.heroArt(containerAspectRatio: 1.0), 'square.jpg'); - expect(m.heroArt(containerAspectRatio: 1.38), 'square.jpg'); - }); - - test('uses art when container is wider', () { - final m = PlexMetadata(ratingKey: '1', art: 'wide.jpg', backgroundSquare: 'square.jpg'); - expect(m.heroArt(containerAspectRatio: 1.39), 'wide.jpg'); - expect(m.heroArt(containerAspectRatio: 1.78), 'wide.jpg'); - }); - - test('falls back to art when backgroundSquare is null regardless of aspect', () { - final m = PlexMetadata(ratingKey: '1', art: 'wide.jpg'); - expect(m.heroArt(containerAspectRatio: 1.0), 'wide.jpg'); - }); - }); -} diff --git a/test/models/plex_sort_test.dart b/test/models/plex_sort_test.dart deleted file mode 100644 index 7a6b4f70..00000000 --- a/test/models/plex_sort_test.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_sort.dart'; - -void main() { - group('PlexSort.getSortKey', () { - test('returns plain key for ascending', () { - final s = PlexSort(key: 'titleSort', title: 'Title'); - expect(s.getSortKey(), 'titleSort'); - expect(s.getSortKey(descending: false), 'titleSort'); - }); - - test('appends :desc when no descKey is provided', () { - final s = PlexSort(key: 'addedAt', title: 'Recently Added'); - expect(s.getSortKey(descending: true), 'addedAt:desc'); - }); - - test('uses explicit descKey when provided', () { - final s = PlexSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title'); - expect(s.getSortKey(descending: true), 'titleSort:desc'); - - final custom = PlexSort(key: 'rating', descKey: 'rating.desc.custom', title: 'Rating'); - expect(custom.getSortKey(descending: true), 'rating.desc.custom'); - }); - }); - - group('PlexSort.isDefaultDescending', () { - test('true for "desc" (case-insensitive)', () { - expect(PlexSort(key: 'k', title: 't', defaultDirection: 'desc').isDefaultDescending, isTrue); - expect(PlexSort(key: 'k', title: 't', defaultDirection: 'DESC').isDefaultDescending, isTrue); - expect(PlexSort(key: 'k', title: 't', defaultDirection: 'Desc').isDefaultDescending, isTrue); - }); - - test('false for "asc", null, or other values', () { - expect(PlexSort(key: 'k', title: 't', defaultDirection: 'asc').isDefaultDescending, isFalse); - expect(PlexSort(key: 'k', title: 't').isDefaultDescending, isFalse); - expect(PlexSort(key: 'k', title: 't', defaultDirection: '').isDefaultDescending, isFalse); - }); - }); - - group('PlexSort.fromJson', () { - test('parses all fields', () { - final s = PlexSort.fromJson({ - 'key': 'titleSort', - 'descKey': 'titleSort:desc', - 'title': 'Title', - 'defaultDirection': 'asc', - }); - expect(s.key, 'titleSort'); - expect(s.descKey, 'titleSort:desc'); - expect(s.title, 'Title'); - expect(s.defaultDirection, 'asc'); - }); - - test('tolerates missing optional fields', () { - final s = PlexSort.fromJson({'key': 'k', 'title': 't'}); - expect(s.descKey, isNull); - expect(s.defaultDirection, isNull); - }); - }); - - group('PlexSort equality & hashCode', () { - test('equality is based on key only (matches current contract)', () { - final a = PlexSort(key: 'k', descKey: 'k:desc', title: 'A', defaultDirection: 'asc'); - final b = PlexSort(key: 'k', descKey: 'other', title: 'B', defaultDirection: 'desc'); - expect(a, equals(b)); - expect(a.hashCode, b.hashCode); - }); - - test('different keys are not equal', () { - final a = PlexSort(key: 'k1', title: 'A'); - final b = PlexSort(key: 'k2', title: 'A'); - expect(a, isNot(equals(b))); - }); - - test('identity short-circuit', () { - final a = PlexSort(key: 'k', title: 't'); - expect(a == a, isTrue); - }); - }); -} diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart new file mode 100644 index 00000000..0739e8a6 --- /dev/null +++ b/test/profiles/active_profile_binder_test.dart @@ -0,0 +1,146 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.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'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + late AppDatabase db; + late ConnectionRegistry connections; + late ProfileConnectionRegistry profileConnections; + late ProfileRegistry profiles; + late PlexHomeService plexHome; + late ActiveProfileProvider activeProfile; + late MultiServerManager manager; + late MultiServerProvider multiServerProvider; + late ActiveProfileBinder binder; + late StorageService storage; + + setUp(() async { + resetSharedPreferencesForTest(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + connections = ConnectionRegistry(db); + profileConnections = ProfileConnectionRegistry(db); + profiles = ProfileRegistry(db); + storage = await StorageService.getInstance(); + plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + activeProfile = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + manager = MultiServerManager(); + multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + binder = ActiveProfileBinder( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + serverManager: manager, + multiServerProvider: multiServerProvider, + pinPrompt: (_, {String? errorMessage}) async => null, + ); + }); + + tearDown(() async { + binder.dispose(); + multiServerProvider.dispose(); + await activeProfile.resetForTesting(); + activeProfile.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + test('local profile with no connections binds successfully with empty visibility', () async { + final profile = Profile( + id: 'local-owner', + kind: ProfileKind.local, + displayName: 'Owner', + createdAt: DateTime(2026, 1, 1), + ); + await profiles.upsert(profile); + await storage.setActiveProfileId(profile.id); + await activeProfile.initialize(); + + await binder.rebindActive(); + + expect(activeProfile.lastBindingSucceeded, isTrue); + expect(binder.debugLastBoundProfileId, profile.id); + expect(multiServerProvider.serverIds, isEmpty); + }); + + test('started binder does not loop forever after empty local bind', () async { + final profile = Profile( + id: 'local-empty', + kind: ProfileKind.local, + displayName: 'Empty', + createdAt: DateTime(2026, 1, 1), + ); + await profiles.upsert(profile); + await storage.setActiveProfileId(profile.id); + await activeProfile.initialize(); + + var notifications = 0; + activeProfile.addListener(() => notifications++); + binder.start(); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(activeProfile.isBinding, isFalse); + expect(activeProfile.lastBindingSucceeded, isTrue); + expect(binder.debugLastBoundProfileId, profile.id); + expect(notifications, lessThan(8)); + }); + + group('Plex Home token cache policy', () { + test('protected cold start revalidates PIN even when profile selection is not required', () { + expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: false, plexProtected: true), isFalse); + }); + + test('protected cold start revalidates PIN when profile selection is required', () { + expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: false, plexProtected: true), isFalse); + }); + + test('preverified activation uses cache once regardless of setting', () { + expect(shouldUsePlexHomeTokenCache(preVerified: true, hasBoundOnce: false, plexProtected: true), isTrue); + }); + + test('unprotected cold start can use cached token', () { + expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: false, plexProtected: false), isTrue); + }); + + test('user-initiated switches bypass cache after first bind', () { + expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: true, plexProtected: false), isFalse); + }); + + test('preverified activation flag is consumed once per profile', () { + expect(binder.consumePlexHomePreVerified('plex-home-x'), isFalse); + binder.markPlexHomePreVerified('plex-home-x'); + expect(binder.consumePlexHomePreVerified('plex-home-x'), isTrue); + expect(binder.consumePlexHomePreVerified('plex-home-x'), isFalse); + }); + + test('preverified activation flag isolates entries per profile id', () { + binder.markPlexHomePreVerified('plex-home-a'); + binder.markPlexHomePreVerified('plex-home-b'); + expect(binder.consumePlexHomePreVerified('plex-home-b'), isTrue); + expect(binder.consumePlexHomePreVerified('plex-home-a'), isTrue); + }); + }); +} diff --git a/test/profiles/active_profile_provider_test.dart b/test/profiles/active_profile_provider_test.dart new file mode 100644 index 00000000..ffc82fee --- /dev/null +++ b/test/profiles/active_profile_provider_test.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + late AppDatabase db; + late ProfileRegistry registry; + late ConnectionRegistry connections; + late PlexHomeService plexHome; + late ActiveProfileProvider provider; + late StorageService storage; + + setUp(() async { + resetSharedPreferencesForTest(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + registry = ProfileRegistry(db); + connections = ConnectionRegistry(db); + storage = await StorageService.getInstance(); + plexHome = PlexHomeService( + connections: connections, + profileConnections: ProfileConnectionRegistry(db), + storage: storage, + // No accounts in tests, so the fetcher is never called. + plexHomeUserFetcher: (_) async => const [], + ); + provider = ActiveProfileProvider( + registry: registry, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + }); + + tearDown(() async { + await provider.resetForTesting(); + provider.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + group('ActiveProfileProvider', () { + test('initialize with no profiles leaves active null', () async { + await provider.initialize(); + expect(provider.profiles, isEmpty); + expect(provider.active, isNull); + }); + + test('concurrent initialize calls await the same in-flight load', () async { + final first = provider.initialize(); + final second = provider.initialize(); + expect(identical(first, second), isTrue); + + await second; + expect(provider.isInitialized, isTrue); + }); + + test('initialize leaves active null when no active id stored', () async { + // Fresh state: no auto-fallback to the first profile so the UI can + // force the picker. The binder skips its rebind while active is null, + // which is what avoids the surprise PIN prompt at first sign-in. + await registry.upsert( + Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)), + ); + await provider.initialize(); + expect(provider.profiles, hasLength(1)); + expect(provider.activeId, isNull); + }); + + test('initialize clears storage when stored id is stale', () async { + // A previously-active profile that was deleted should not keep + // storage-scoped settings under the removed profile id. + await registry.upsert( + Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)), + ); + await storage.setActiveProfileId('ghost-id-no-longer-exists'); + await provider.initialize(); + await Future.delayed(Duration.zero); + expect(provider.activeId, isNull); + expect(storage.getActiveProfileId(), isNull); + }); + + test('initialize resolves the stored active profile id', () async { + await registry.upsert( + Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)), + ); + await registry.upsert( + Profile(id: 'p2', kind: ProfileKind.local, displayName: 'Kids', createdAt: DateTime(2026, 1, 2)), + ); + await storage.setActiveProfileId('p2'); + await provider.initialize(); + expect(provider.activeId, 'p2'); + }); + + test('activate without PIN switches a non-protected profile', () async { + await registry.upsert( + Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)), + ); + await registry.upsert( + Profile(id: 'p2', kind: ProfileKind.local, displayName: 'Kids', createdAt: DateTime(2026, 1, 2)), + ); + await provider.initialize(); + final p2 = provider.profiles.firstWhere((p) => p.id == 'p2'); + final ok = await provider.activate(p2); + expect(ok, isTrue); + expect(provider.activeId, 'p2'); + }); + + test('clearActiveProfile clears storage and in-memory active profile', () async { + await registry.upsert( + Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)), + ); + await provider.initialize(); + await provider.activate(provider.profiles.single); + + await provider.clearActiveProfile(); + + expect(storage.getActiveProfileId(), isNull); + expect(provider.active, isNull); + }); + + test('activate rejects wrong PIN for a protected local profile', () async { + await registry.upsert( + Profile( + id: 'p1', + kind: ProfileKind.local, + displayName: 'Kids', + pinHash: computePinHash('1234'), + createdAt: DateTime(2026, 1, 1), + ), + ); + await provider.initialize(); + final p1 = provider.profiles.first; + expect(await provider.activate(p1, pin: 'wrong'), isFalse); + expect(await provider.activate(p1, pin: '1234'), isTrue); + }); + + test('hasMultipleProfiles reflects the registry size', () async { + await registry.upsert( + Profile(id: 'p1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)), + ); + await provider.initialize(); + expect(provider.hasMultipleProfiles, isFalse); + // Latch onto the next provider notification that flips the flag, + // instead of sleeping a fixed duration. The provider is a ChangeNotifier + // (not a Stream), so we use addListener + Completer here rather than + // expectLater(stream, ...) like the other profile tests. + final flipped = Completer(); + void listener() { + if (provider.hasMultipleProfiles && !flipped.isCompleted) { + flipped.complete(); + } + } + + provider.addListener(listener); + addTearDown(() => provider.removeListener(listener)); + await registry.upsert( + Profile(id: 'p2', kind: ProfileKind.local, displayName: 'Kids', createdAt: DateTime(2026, 1, 2)), + ); + await flipped.future.timeout(const Duration(seconds: 2)); + expect(provider.hasMultipleProfiles, isTrue); + }); + }); +} diff --git a/test/profiles/plex_home_service_test.dart b/test/profiles/plex_home_service_test.dart new file mode 100644 index 00000000..a63c91d5 --- /dev/null +++ b/test/profiles/plex_home_service_test.dart @@ -0,0 +1,213 @@ +import 'dart:async'; + +import 'package:drift/native.dart'; +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/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +PlexHomeUser _user(String uuid, {bool admin = false, bool protected = false, String name = 'User'}) { + return PlexHomeUser( + id: 0, + uuid: uuid, + title: name, + username: null, + email: null, + friendlyName: null, + thumb: 'https://plex.tv/users/$uuid/avatar', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: admin, + guest: false, + protected: protected, + ); +} + +PlexAccountConnection _account(String id) { + return PlexAccountConnection( + id: id, + accountToken: 'tok-$id', + clientIdentifier: 'cid-$id', + accountLabel: 'acct-$id', + createdAt: DateTime(2026, 1, 1), + ); +} + +void main() { + late AppDatabase db; + late ConnectionRegistry connections; + late ProfileConnectionRegistry profileConnections; + late StorageService storage; + late PlexHomeService service; + + setUp(() async { + resetSharedPreferencesForTest(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + connections = ConnectionRegistry(db); + profileConnections = ProfileConnectionRegistry(db); + storage = await StorageService.getInstance(); + }); + + tearDown(() async { + await service.dispose(); + await db.close(); + }); + + group('PlexHomeService', () { + test('refresh fetches and caches users for a connection', () async { + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => [_user('admin-uuid', admin: true), _user('kid-uuid', protected: true)], + ); + final acct = _account('plex.dev1'); + await connections.upsert(acct); + await service.refresh(acct); + + expect(service.current[acct.id], hasLength(2)); + expect(service.current[acct.id]!.firstWhere((u) => u.admin).uuid, 'admin-uuid'); + }); + + test('refresh persists users to SharedPreferences', () async { + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => [_user('uuid-1')], + ); + final acct = _account('plex.dev2'); + await connections.upsert(acct); + await service.refresh(acct); + + expect(storage.getPlexHomeUsersCacheJson(acct.id), isNotNull); + }); + + test('start hydrates the cache from SharedPreferences', () async { + // Pre-seed the cache. + await storage.savePlexHomeUsersCache('plex.dev3', [_user('seeded-uuid').toJson()]); + final acct = _account('plex.dev3'); + await connections.upsert(acct); + + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], // background refresh returns empty + ); + await service.start(); + + // Cache hydrated synchronously from SharedPreferences before any + // background fetch resolves. + expect(service.current[acct.id], hasLength(1)); + expect(service.current[acct.id]!.first.uuid, 'seeded-uuid'); + }); + + test('concurrent start calls await the same in-flight startup', () async { + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + + final first = service.start(); + final second = service.start(); + + expect(identical(first, second), isTrue); + await second; + }); + + test('removing a Plex connection clears its cache slot', () async { + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => [_user('uuid-1')], + ); + final acct = _account('plex.dev4'); + await connections.upsert(acct); + await service.refresh(acct); + expect(service.current[acct.id], isNotNull); + + await service.start(); + // Wait for the service's own stream to emit a snapshot without + // `acct.id` instead of a fixed-duration sleep — deterministic on slow + // CI runners and matches when the listener actually settles, not just + // 30ms after the remove() future resolves. + final cleared = expectLater( + service.stream, + emitsThrough(predicate>>((m) => !m.containsKey(acct.id))), + ); + await connections.remove(acct.id); + await cleared; + + expect(service.current[acct.id], isNull); + expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull); + }); + + test('materializeFirstPlexHome wraps the first cached account', () async { + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => [ + _user('admin-uuid', admin: true, name: 'Admin'), + _user('kid-uuid', name: 'Kid'), + ], + ); + final acct = _account('plex.dev5'); + await connections.upsert(acct); + await service.refresh(acct); + + final home = await service.materializeFirstPlexHome(); + expect(home, isNotNull); + expect(home!.users, hasLength(2)); + expect(home.adminUser?.uuid, 'admin-uuid'); + }); + + test('materializeFirstPlexHome waits for startup cache hydration', () async { + await storage.savePlexHomeUsersCache('plex.dev-cached', [_user('cached-admin', admin: true).toJson()]); + final acct = _account('plex.dev-cached'); + await connections.upsert(acct); + final refreshBlocker = Completer>(); + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) => refreshBlocker.future, + ); + addTearDown(() { + if (!refreshBlocker.isCompleted) refreshBlocker.complete(const []); + }); + + final home = await service.materializeFirstPlexHome(); + + expect(home, isNotNull); + expect(home!.adminUser?.uuid, 'cached-admin'); + }); + + test('clearAll wipes both memory and disk caches', () async { + service = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => [_user('uuid-1')], + ); + final acct = _account('plex.dev6'); + await connections.upsert(acct); + await service.refresh(acct); + + await service.clearAll(); + expect(service.current, isEmpty); + expect(storage.getPlexHomeUsersCacheJson(acct.id), isNull); + }); + }); +} diff --git a/test/profiles/profile_connection_registry_test.dart b/test/profiles/profile_connection_registry_test.dart new file mode 100644 index 00000000..43441a00 --- /dev/null +++ b/test/profiles/profile_connection_registry_test.dart @@ -0,0 +1,179 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + late AppDatabase db; + late ProfileConnectionRegistry registry; + + setUp(() async { + resetSharedPreferencesForTest(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + registry = ProfileConnectionRegistry(db); + + // Seed the parent rows that ProfileConnections now FK-references. + // Without this, every upsert below trips the FK constraint added in + // schema v17. + final now = DateTime.now().millisecondsSinceEpoch; + for (final id in ['p1', 'p2']) { + await db + .into(db.profiles) + .insert(ProfilesCompanion.insert(id: id, kind: 'local', displayName: id, configJson: '{}', createdAt: now)); + } + for (final id in ['c1', 'c2']) { + await db + .into(db.connections) + .insert(ConnectionsCompanion.insert(id: id, kind: 'plex', displayName: id, configJson: '{}', createdAt: now)); + } + }); + + tearDown(() async { + await db.close(); + }); + + group('ProfileConnectionRegistry', () { + test('listForProfile is empty initially', () async { + expect(await registry.listForProfile('p1'), isEmpty); + }); + + test('upsert inserts and round-trips', () async { + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 'tok', userIdentifier: 'uid-1'), + ); + final list = await registry.listForProfile('p1'); + expect(list, hasLength(1)); + expect(list.first.userToken, 'tok'); + expect(list.first.userIdentifier, 'uid-1'); + final raw = await db.select(db.profileConnections).getSingle(); + expect(raw.userToken, isNot('tok')); + }); + + test('first row for a profile is auto-default', () async { + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't', userIdentifier: 'u'), + ); + final pc = await registry.get('p1', 'c1'); + expect(pc!.isDefault, isTrue); + }); + + test('subsequent rows do not auto-replace the default', () async { + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1', userIdentifier: 'u1'), + ); + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't2', userIdentifier: 'u2'), + ); + final list = await registry.listForProfile('p1'); + final defaults = list.where((pc) => pc.isDefault).toList(); + expect(defaults, hasLength(1)); + expect(defaults.first.connectionId, 'c1'); + }); + + test('re-upsert preserves the existing default flag', () async { + // Regression: re-upserting a default row used to clobber its + // `isDefault` because the fast path always wrote `isFirst`. + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1', userIdentifier: 'u1'), + ); + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't2', userIdentifier: 'u2'), + ); + expect((await registry.get('p1', 'c1'))!.isDefault, isTrue); + + // Re-upsert c1 (token refresh) — the default flag must survive. + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1-refreshed', userIdentifier: 'u1'), + ); + expect((await registry.get('p1', 'c1'))!.isDefault, isTrue); + expect((await registry.get('p1', 'c2'))!.isDefault, isFalse); + }); + + test('setDefault flips the default flag exclusively', () async { + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1', userIdentifier: 'u1'), + ); + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't2', userIdentifier: 'u2'), + ); + await registry.setDefault('p1', 'c2'); + final list = await registry.listForProfile('p1'); + final c1 = list.firstWhere((pc) => pc.connectionId == 'c1'); + final c2 = list.firstWhere((pc) => pc.connectionId == 'c2'); + expect(c1.isDefault, isFalse); + expect(c2.isDefault, isTrue); + }); + + test('recordToken caches a fresh user token', () async { + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: '', userIdentifier: 'u1'), + ); + await registry.recordToken('p1', 'c1', 'fresh-token'); + final pc = await registry.get('p1', 'c1'); + expect(pc!.userToken, 'fresh-token'); + expect(pc.tokenAcquiredAt, isNotNull); + }); + + test('remove drops the row and promotes the next one as default', () async { + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't1', userIdentifier: 'u1'), + ); + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't2', userIdentifier: 'u2'), + ); + // c1 is default initially. Removing it should promote c2. + await registry.remove('p1', 'c1'); + final remaining = await registry.listForProfile('p1'); + expect(remaining, hasLength(1)); + expect(remaining.first.connectionId, 'c2'); + expect(remaining.first.isDefault, isTrue); + }); + + test('removeAllForConnection cascades across profiles', () async { + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't', userIdentifier: 'u'), + ); + await registry.upsert( + const ProfileConnection(profileId: 'p2', connectionId: 'c1', userToken: 't', userIdentifier: 'u'), + ); + final removed = await registry.removeAllForConnection('c1'); + expect(removed, 2); + expect(await registry.listForConnection('c1'), isEmpty); + }); + + test('removeAllForProfile drops every row for a profile', () async { + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't', userIdentifier: 'u'), + ); + await registry.upsert( + const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't', userIdentifier: 'u'), + ); + final removed = await registry.removeAllForProfile('p1'); + expect(removed, 2); + expect(await registry.listForProfile('p1'), isEmpty); + }); + + test('upsert succeeds for a virtual plex_home profile id with no parent row', () async { + // Regression for the v17 FK that broke Plex Home activation: virtual + // plex_home profiles are never persisted in `profiles`, so the join + // row's profile_id has no parent. v20 dropped the FK; this insert + // must round-trip without a FOREIGN KEY constraint failure. + const plexHomeId = 'plex-home-plex.acc-uuid-1234'; + await registry.upsert( + const ProfileConnection( + profileId: plexHomeId, + connectionId: 'c1', + userToken: 'home-tok', + userIdentifier: 'uuid-1234', + ), + ); + final list = await registry.listForProfile(plexHomeId); + expect(list, hasLength(1)); + expect(list.first.userToken, 'home-tok'); + expect(list.first.userIdentifier, 'uuid-1234'); + }); + }); +} diff --git a/test/profiles/profile_registry_test.dart b/test/profiles/profile_registry_test.dart new file mode 100644 index 00000000..f50bfaa6 --- /dev/null +++ b/test/profiles/profile_registry_test.dart @@ -0,0 +1,125 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_registry.dart'; + +void main() { + late AppDatabase db; + late ProfileRegistry registry; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + registry = ProfileRegistry(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('ProfileRegistry', () { + test('list is empty initially', () async { + expect(await registry.list(), isEmpty); + }); + + test('upsert + get round-trips a local profile', () async { + final profile = Profile( + id: 'local-1', + kind: ProfileKind.local, + displayName: 'Owner', + pinHash: computePinHash('1234'), + createdAt: DateTime(2026, 1, 1), + ); + await registry.upsert(profile); + + final fetched = await registry.get('local-1'); + expect(fetched, isNotNull); + expect(fetched!.kind, ProfileKind.local); + expect(fetched.displayName, 'Owner'); + expect(fetched.pinHash, profile.pinHash); + }); + + test('upsert + get round-trips a plex_home profile', () async { + final profile = Profile( + id: 'plex-home-acct-uuid', + kind: ProfileKind.plexHome, + displayName: 'Admin', + avatarThumbUrl: 'https://plex.tv/users/abc/avatar?', + parentConnectionId: 'acct', + plexAdmin: true, + plexProtected: true, + createdAt: DateTime(2026, 1, 1), + ); + await registry.upsert(profile); + + final fetched = await registry.get(profile.id); + expect(fetched, isNotNull); + expect(fetched!.kind, ProfileKind.plexHome); + expect(fetched.avatarThumbUrl, profile.avatarThumbUrl); + expect(fetched.parentConnectionId, 'acct'); + expect(fetched.plexAdmin, isTrue); + expect(fetched.plexProtected, isTrue); + }); + + test('list orders by sortOrder then createdAt', () async { + await registry.upsert( + Profile(id: 'a', kind: ProfileKind.local, displayName: 'A', sortOrder: 1, createdAt: DateTime(2026, 1, 1)), + ); + await registry.upsert( + Profile(id: 'b', kind: ProfileKind.local, displayName: 'B', sortOrder: 0, createdAt: DateTime(2026, 1, 2)), + ); + final list = await registry.list(); + expect(list.map((p) => p.id).toList(), ['b', 'a']); + }); + + test('remove deletes a profile', () async { + await registry.upsert( + Profile(id: 'p', kind: ProfileKind.local, displayName: 'P', createdAt: DateTime(2026, 1, 1)), + ); + await registry.remove('p'); + expect(await registry.get('p'), isNull); + }); + + test('markUsed updates lastUsedAt', () async { + await registry.upsert( + Profile(id: 'p', kind: ProfileKind.local, displayName: 'P', createdAt: DateTime(2026, 1, 1)), + ); + final ts = DateTime(2026, 1, 5, 12, 0); + await registry.markUsed('p', ts); + final fetched = await registry.get('p'); + expect(fetched!.lastUsedAt, ts); + }); + + test('upsert is idempotent (replaces existing row)', () async { + await registry.upsert( + Profile(id: 'p', kind: ProfileKind.local, displayName: 'Original', createdAt: DateTime(2026, 1, 1)), + ); + await registry.upsert( + Profile(id: 'p', kind: ProfileKind.local, displayName: 'Renamed', createdAt: DateTime(2026, 1, 1)), + ); + final fetched = await registry.get('p'); + expect(fetched!.displayName, 'Renamed'); + }); + + test('watchProfiles emits on insert + delete', () async { + // Drift's `.watch()` may coalesce the initial empty snapshot with the + // first mutation's emission when both happen inside the same + // microtask, so we don't pin the prefix — what matters is that + // mutations *do* propagate. `emitsThrough` skips intermediate events + // and matches the first event satisfying the predicate, then the + // second matcher takes over. Deterministic on slow CI runners. + final assertion = expectLater( + registry.watchProfiles(), + emitsInOrder([ + emitsThrough(predicate>((l) => l.length == 1 && l.first.id == 'p')), + emitsThrough(isEmpty), + ]), + ); + await registry.upsert( + Profile(id: 'p', kind: ProfileKind.local, displayName: 'P', createdAt: DateTime(2026, 1, 1)), + ); + await registry.remove('p'); + await assertion; + }); + }); +} diff --git a/test/profiles/profile_test.dart b/test/profiles/profile_test.dart new file mode 100644 index 00000000..693a8573 --- /dev/null +++ b/test/profiles/profile_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/profiles/profile.dart'; + +void main() { + group('Profile', () { + test('local profile defaults', () { + final p = Profile(id: 'local-1', kind: ProfileKind.local, displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); + expect(p.isLocal, isTrue); + expect(p.isPlexHome, isFalse); + expect(p.isPinProtected, isFalse); + expect(p.parentConnectionId, isNull); + }); + + test('local profile with PIN is pin-protected', () { + final p = Profile( + id: 'local-1', + kind: ProfileKind.local, + displayName: 'Kids', + pinHash: computePinHash('1234'), + createdAt: DateTime(2026, 1, 1), + ); + expect(p.isPinProtected, isTrue); + }); + + test('plex_home profile pin protection follows the protected flag', () { + final p = Profile( + id: 'plex-home-acct1-uuid1', + kind: ProfileKind.plexHome, + displayName: 'Sarah', + parentConnectionId: 'acct1', + plexProtected: true, + createdAt: DateTime(2026, 1, 1), + ); + expect(p.isLocal, isFalse); + expect(p.isPinProtected, isTrue); + }); + + test('local PIN hash is round-tripped via configJson', () { + final p = Profile( + id: 'local-1', + kind: ProfileKind.local, + displayName: 'Kids', + pinHash: computePinHash('1234'), + createdAt: DateTime(2026, 1, 1), + ); + final json = p.toConfigJson(); + final restored = Profile.fromRow( + id: p.id, + kind: 'local', + displayName: p.displayName, + avatarThumbUrl: null, + json: json, + sortOrder: 0, + createdAt: p.createdAt, + lastUsedAt: null, + ); + expect(restored.pinHash, p.pinHash); + expect(restored.isPinProtected, isTrue); + }); + + test('plex_home configJson round-trips with all flags', () { + final p = Profile( + id: 'plex-home-acct1-uuid1', + kind: ProfileKind.plexHome, + displayName: 'Admin', + parentConnectionId: 'acct1', + plexAdmin: true, + plexRestricted: false, + plexProtected: true, + createdAt: DateTime(2026, 1, 1), + ); + final json = p.toConfigJson(); + final restored = Profile.fromRow( + id: p.id, + kind: 'plex_home', + displayName: p.displayName, + avatarThumbUrl: null, + json: json, + sortOrder: 0, + createdAt: p.createdAt, + lastUsedAt: null, + ); + expect(restored.plexAdmin, isTrue); + expect(restored.plexRestricted, isFalse); + expect(restored.plexProtected, isTrue); + expect(restored.parentConnectionId, 'acct1'); + }); + + test('plexHomeProfileId is deterministic', () { + expect(plexHomeProfileId(accountConnectionId: 'plex.dev1', homeUserUuid: 'uuid-1'), 'plex-home-plex.dev1-uuid-1'); + }); + + test('parsePlexHomeProfileId round-trips a real hyphenated UUID', () { + // Real Plex Home UUIDs are 36-char standard UUIDs (4 internal hyphens), + // and accountConnectionId can carry hyphens too (e.g. plex.client-id). + const acct = 'plex.client-id-123'; + const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef0123456789'; + final id = plexHomeProfileId(accountConnectionId: acct, homeUserUuid: uuid); + final parsed = parsePlexHomeProfileId(id); + expect(parsed, isNotNull); + expect(parsed!.accountConnectionId, acct); + expect(parsed.homeUserUuid, uuid); + }); + + test('parsePlexHomeProfileId rejects non-Plex-Home ids', () { + expect(parsePlexHomeProfileId('local-1'), isNull); + expect(parsePlexHomeProfileId('plex-home-only'), isNull); + expect(parsePlexHomeProfileId('plex-home-acct-not-a-uuid'), isNull); + }); + }); + + group('PIN hashing', () { + test('computePinHash is deterministic for the same input', () { + expect(computePinHash('1234'), computePinHash('1234')); + }); + + test('computePinHash differs for different inputs', () { + expect(computePinHash('1234'), isNot(computePinHash('5678'))); + }); + + test('verifyPin matches its hash', () { + final h = computePinHash('4242'); + expect(verifyPin('4242', h), isTrue); + expect(verifyPin('1111', h), isFalse); + }); + }); +} diff --git a/test/profiles/profiles_view_test.dart b/test/profiles/profiles_view_test.dart new file mode 100644 index 00000000..7b7a1d1c --- /dev/null +++ b/test/profiles/profiles_view_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profiles_view.dart'; + +void main() { + group('visibleProfileConnections', () { + test('keeps all local profile connection rows', () { + final profile = Profile( + id: 'local-1', + kind: ProfileKind.local, + displayName: 'Owner', + createdAt: DateTime(2026, 1, 1), + ); + const rows = [ + ProfileConnection(profileId: 'local-1', connectionId: 'plex-1', userIdentifier: 'u1'), + ProfileConnection(profileId: 'local-1', connectionId: 'jellyfin-1', userIdentifier: 'u2'), + ]; + + expect(visibleProfileConnections(profile, rows), rows); + }); + + test('filters Plex Home parent token cache row', () { + final profile = Profile( + id: 'plex-home-plex-1-user-1', + kind: ProfileKind.plexHome, + displayName: 'Kid', + parentConnectionId: 'plex-1', + createdAt: DateTime(2026, 1, 1), + ); + const rows = [ + ProfileConnection(profileId: 'plex-home-plex-1-user-1', connectionId: 'plex-1', userIdentifier: 'user-1'), + ProfileConnection(profileId: 'plex-home-plex-1-user-1', connectionId: 'jellyfin-1', userIdentifier: 'user-2'), + ]; + + final visible = visibleProfileConnections(profile, rows); + + expect(visible, hasLength(1)); + expect(visible.single.connectionId, 'jellyfin-1'); + }); + }); +} diff --git a/test/providers/companion_remote_provider_test.dart b/test/providers/companion_remote_provider_test.dart index 4623de62..c10840ca 100644 --- a/test/providers/companion_remote_provider_test.dart +++ b/test/providers/companion_remote_provider_test.dart @@ -1,11 +1,29 @@ +import 'package:drift/native.dart'; 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/models/plex/plex_home.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/companion_remote/remote_command.dart'; import 'package:plezy/models/companion_remote/remote_session.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/companion_remote_provider.dart'; +import 'package:plezy/services/companion_remote/remote_auth_service.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUp(resetSharedPreferencesForTest); + group('CompanionRemoteProvider — initial state', () { test('starts with no session and no connected device', () { final p = CompanionRemoteProvider(); @@ -123,4 +141,364 @@ void main() { p.dispose(); }); }); + + group('CompanionRemoteProvider — crypto identity', () { + test('Jellyfin remote secret is stable across tokens for the same server user', () async { + final auth = RemoteAuthService.instance; + auth.clearCache(); + + final tokenA = await auth.deriveJellyfinSecret(serverMachineId: 'machine-a', userId: 'user-a'); + final tokenAAgain = await auth.deriveJellyfinSecret(serverMachineId: 'machine-a', userId: 'user-a'); + final tokenB = await auth.deriveJellyfinSecret(serverMachineId: 'machine-a', userId: 'user-a'); + final otherUser = await auth.deriveJellyfinSecret(serverMachineId: 'machine-a', userId: 'user-b'); + + expect(tokenAAgain, tokenA); + expect(tokenB, tokenA); + expect(otherUser, isNot(tokenA)); + }); + + test('ensureCryptoReady rebuilds when the active profile/account changes', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + addTearDown(() async { + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + final accountA = _plexAccount('plex-a', 'client-a'); + final accountB = _plexAccount('plex-b', 'client-b'); + final profileA = _localProfile('profile-a'); + final profileB = _localProfile('profile-b'); + await connections.upsert(accountB); + await profiles.upsert(profileB); + await profileConnections.upsert( + ProfileConnection(profileId: profileB.id, connectionId: accountB.id, userIdentifier: 'admin-b'), + makeDefault: true, + ); + await storage.setActiveProfileId(profileB.id); + await active.initialize(); + + final provider = CompanionRemoteProvider(); + addTearDown(provider.dispose); + await provider.initializeCrypto(home: _home('admin-a'), account: accountA, activeProfile: profileA); + expect(provider.debugCryptoConnectionId, accountA.id); + expect(provider.debugCryptoProfileId, profileA.id); + + final ok = await provider.ensureCryptoReady( + _home('admin-b'), + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + account: accountB, + ); + + expect(ok, isTrue); + expect(provider.debugCryptoConnectionId, accountB.id); + expect(provider.debugCryptoProfileId, profileB.id); + }); + + test('ensureCryptoReady uses the active local profile Plex row', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + addTearDown(() async { + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + final accountA = _plexAccount('plex-a', 'client-a'); + final accountB = _plexAccount('plex-b', 'client-b'); + final profile = _localProfile('profile-local'); + await connections.upsert(accountA); + await connections.upsert(accountB); + await profiles.upsert(profile); + await profileConnections.upsert( + ProfileConnection(profileId: profile.id, connectionId: accountB.id, userIdentifier: 'child-b', isDefault: true), + makeDefault: true, + ); + await storage.setActiveProfileId(profile.id); + await active.initialize(); + + final provider = CompanionRemoteProvider(); + addTearDown(provider.dispose); + final ok = await provider.ensureCryptoReady( + _homeWithUsers('admin-b', ['child-b']), + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + ); + + expect(ok, isTrue); + expect(provider.debugCryptoConnectionId, accountB.id); + expect(provider.debugCryptoProfileId, profile.id); + expect(provider.debugCryptoUserUuid, 'child-b'); + }); + + test('ensureCryptoReady uses the active local profile Jellyfin row', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + addTearDown(() async { + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + final jellyfin = _jellyfinConnection('jf-a'); + final profile = _localProfile('profile-jf'); + await connections.upsert(jellyfin); + await profiles.upsert(profile); + await profileConnections.upsert( + ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), + makeDefault: true, + ); + await storage.setActiveProfileId(profile.id); + await active.initialize(); + + final provider = CompanionRemoteProvider(); + addTearDown(provider.dispose); + final ok = await provider.ensureCryptoReady( + null, + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + ); + + expect(ok, isTrue); + expect(provider.debugCryptoConnectionId, jellyfin.id); + expect(provider.debugCryptoProfileId, profile.id); + expect(provider.debugCryptoUserUuid, jellyfin.userId); + }); + + test('ensureCryptoReady includes every active local profile remote identity', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + addTearDown(() async { + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + final account = _plexAccount('plex-a', 'client-a'); + final jellyfin = _jellyfinConnection('jf-a'); + final profile = _localProfile('profile-mixed'); + final home = _homeWithUsers('admin-a', ['child-a']); + await connections.upsert(account); + await connections.upsert(jellyfin); + await profiles.upsert(profile); + await profileConnections.upsert( + ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), + makeDefault: true, + ); + await profileConnections.upsert( + ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'child-a'), + ); + await storage.setActiveProfileId(profile.id); + await active.initialize(); + + final provider = CompanionRemoteProvider(); + addTearDown(provider.dispose); + final ok = await provider.ensureCryptoReady( + home, + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + plexHomeForConnection: (_) async => home, + ); + + expect(ok, isTrue); + expect(provider.debugCryptoConnectionId, jellyfin.id); + expect(provider.debugCryptoConnectionIds, [jellyfin.id, account.id]); + }); + + test('ensureCryptoReady does not fall back to an account without an active profile', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + addTearDown(() async { + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + await connections.upsert(_plexAccount('plex-a', 'client-a')); + await profiles.upsert(_localProfile('profile-a')); + await active.initialize(); + + final provider = CompanionRemoteProvider(); + addTearDown(provider.dispose); + final ok = await provider.ensureCryptoReady( + _home('admin-a'), + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + ); + + expect(ok, isFalse); + expect(provider.isCryptoReady, isFalse); + }); + + test('resetForLogout clears crypto context', () async { + final provider = CompanionRemoteProvider(); + addTearDown(provider.dispose); + await provider.initializeCrypto( + home: _home('admin-a'), + account: _plexAccount('plex-a', 'client-a'), + activeProfile: _localProfile('profile-a'), + ); + expect(provider.isCryptoReady, isTrue); + + await provider.resetForLogout(); + + expect(provider.isCryptoReady, isFalse); + expect(provider.debugCryptoConnectionId, isNull); + expect(provider.debugCryptoProfileId, isNull); + }); + }); +} + +PlexAccountConnection _plexAccount(String id, String clientIdentifier) { + return PlexAccountConnection( + id: id, + accountToken: 'token-$id', + clientIdentifier: clientIdentifier, + accountLabel: id, + createdAt: DateTime(2026, 1, 1), + ); +} + +JellyfinConnection _jellyfinConnection(String id) { + return JellyfinConnection( + id: id, + baseUrl: 'https://jellyfin.example.test', + serverName: 'Jellyfin', + serverMachineId: 'machine-$id', + userId: 'user-$id', + userName: 'User $id', + accessToken: 'token-$id', + deviceId: 'device-$id', + createdAt: DateTime(2026, 1, 1), + ); +} + +Profile _localProfile(String id) { + return Profile(id: id, kind: ProfileKind.local, displayName: id, createdAt: DateTime(2026, 1, 1)); +} + +PlexHome _home(String adminUuid) { + return PlexHome( + id: 1, + name: 'Home', + guestUserID: null, + guestUserUUID: '', + guestEnabled: false, + subscription: false, + users: [_homeUser(adminUuid, admin: true)], + ); +} + +PlexHome _homeWithUsers(String adminUuid, List userUuids) { + return PlexHome( + id: 1, + name: 'Home', + guestUserID: null, + guestUserUUID: '', + guestEnabled: false, + subscription: false, + users: [_homeUser(adminUuid, admin: true), for (final uuid in userUuids) _homeUser(uuid, admin: false)], + ); +} + +PlexHomeUser _homeUser(String uuid, {required bool admin}) { + return PlexHomeUser( + id: admin ? 1 : 2, + uuid: uuid, + title: uuid, + thumb: '', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: admin, + guest: false, + protected: false, + ); } diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index f4aecb35..1612e48a 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -1,10 +1,52 @@ +import 'dart:convert'; + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/database/download_operations.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/models/download_models.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/utils/watch_state_notifier.dart'; + +/// Implements only [fetchPlayableDescendants] (the surface queueDownload +/// reaches via [collectEpisodesForShow] / [collectEpisodesForSeason]); +/// every other call falls through to noSuchMethod and trips a NoSuchMethodError. +class _ThrowingClient implements MediaServerClient { + @override + Future> fetchPlayableDescendants(String parentId) async { + throw StateError('test: fetchPlayableDescendants intentionally fails'); + } + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ScopedTestClient implements MediaServerClient, ScopedMediaServerClient { + _ScopedTestClient({required this.serverId, required this.scopedServerId}); + + @override + final String serverId; + + @override + final String scopedServerId; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -17,6 +59,7 @@ void main() { // PlexApiCache is a singleton accessed eagerly inside DownloadManagerService's // constructor; reinitialize per test so each test sees the fresh in-memory DB. PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); downloadManager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance); // recoveryFuture is `late final` and would otherwise be unset; we never // exercise the recovery path in these tests but the field must be safe @@ -72,16 +115,18 @@ void main() { p.addListener(() => notified++); await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5); + final ruleKey = p.syncRuleKeyFor('srv', '10'); - expect(p.hasSyncRule('srv:10'), isTrue); - final rule = p.getSyncRule('srv:10'); + expect(p.hasSyncRule(ruleKey), isTrue); + final rule = p.getSyncRule(ruleKey); expect(rule, isNotNull); - expect(rule!.targetType, 'show'); + expect(rule!.profileId, 'test-profile'); + expect(rule.targetType, 'show'); expect(rule.episodeCount, 5); expect(rule.enabled, isTrue); expect(rule.downloadFilter, 'unwatched'); // default // Database state matches in-memory state. - final dbRule = await db.getSyncRule('srv:10'); + final dbRule = await db.getSyncRule(ruleKey); expect(dbRule, isNotNull); expect(dbRule!.targetType, 'show'); @@ -96,13 +141,14 @@ void main() { await p.ensureInitialized(); await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5); + final ruleKey = p.syncRuleKeyFor('srv', '10'); var notified = 0; p.addListener(() => notified++); - await p.updateSyncRuleCount('srv:10', 12); - expect(p.getSyncRule('srv:10')!.episodeCount, 12); - expect((await db.getSyncRule('srv:10'))!.episodeCount, 12); + await p.updateSyncRuleCount(ruleKey, 12); + expect(p.getSyncRule(ruleKey)!.episodeCount, 12); + expect((await db.getSyncRule(ruleKey))!.episodeCount, 12); expect(notified, 1); p.dispose(); @@ -113,12 +159,13 @@ void main() { await p.ensureInitialized(); await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'collection', episodeCount: 0); + final ruleKey = p.syncRuleKeyFor('srv', '10'); var notified = 0; p.addListener(() => notified++); - await p.updateSyncRuleFilter('srv:10', 'all'); - expect(p.getSyncRule('srv:10')!.downloadFilter, 'all'); + await p.updateSyncRuleFilter(ruleKey, 'all'); + expect(p.getSyncRule(ruleKey)!.downloadFilter, 'all'); expect(notified, 1); p.dispose(); @@ -129,14 +176,15 @@ void main() { await p.ensureInitialized(); await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5); - expect(p.getSyncRule('srv:10')!.enabled, isTrue); + final ruleKey = p.syncRuleKeyFor('srv', '10'); + expect(p.getSyncRule(ruleKey)!.enabled, isTrue); - await p.setSyncRuleEnabled('srv:10', false); - expect(p.getSyncRule('srv:10')!.enabled, isFalse); - expect((await db.getSyncRule('srv:10'))!.enabled, isFalse); + await p.setSyncRuleEnabled(ruleKey, false); + expect(p.getSyncRule(ruleKey)!.enabled, isFalse); + expect((await db.getSyncRule(ruleKey))!.enabled, isFalse); - await p.setSyncRuleEnabled('srv:10', true); - expect(p.getSyncRule('srv:10')!.enabled, isTrue); + await p.setSyncRuleEnabled(ruleKey, true); + expect(p.getSyncRule(ruleKey)!.enabled, isTrue); p.dispose(); }); @@ -147,27 +195,118 @@ void main() { await p.createSyncRule(serverId: 'srv', ratingKey: '10', targetType: 'show', episodeCount: 5); await p.createSyncRule(serverId: 'srv', ratingKey: '11', targetType: 'show', episodeCount: 5); + final ruleKey10 = p.syncRuleKeyFor('srv', '10'); + final ruleKey11 = p.syncRuleKeyFor('srv', '11'); expect(p.syncRules, hasLength(2)); var notified = 0; p.addListener(() => notified++); - await p.deleteSyncRule('srv:10'); - expect(p.hasSyncRule('srv:10'), isFalse); - expect(p.hasSyncRule('srv:11'), isTrue); + await p.deleteSyncRule(ruleKey10); + expect(p.hasSyncRule(ruleKey10), isFalse); + expect(p.hasSyncRule(ruleKey11), isTrue); expect(p.syncRules, hasLength(1)); - expect(await db.getSyncRule('srv:10'), isNull); + expect(await db.getSyncRule(ruleKey10), isNull); expect(notified, 1); p.dispose(); }); + test('deleteSyncRule releases targetMetadata when no download holds it', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + // Collection rule with stashed metadata (the "no underlying episode + // download to populate _metadata" case from createSyncRule's docs). + final target = MediaItem( + id: '20', + backend: MediaBackend.plex, + kind: MediaKind.collection, + title: 'My Collection', + serverId: 'srv', + ); + await p.createSyncRule( + serverId: 'srv', + ratingKey: '20', + targetType: 'collection', + episodeCount: 0, + targetMetadata: target, + ); + expect(p.getMetadata('srv:20'), isNotNull, reason: 'targetMetadata should be stashed'); + + await p.deleteSyncRule(p.syncRuleKeyFor('srv', '20')); + expect(p.getMetadata('srv:20'), isNull, reason: 'orphan metadata should be released'); + + p.dispose(); + }); + + test('deleteSyncRule preserves metadata still referenced by a download', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + final target = MediaItem( + id: '30', + backend: MediaBackend.plex, + kind: MediaKind.show, + title: 'A Show', + serverId: 'srv', + ); + await p.createSyncRule( + serverId: 'srv', + ratingKey: '30', + targetType: 'show', + episodeCount: 5, + targetMetadata: target, + ); + + // Simulate an active/queued download under the same key — metadata is + // still load-bearing and must not be evicted by deleteSyncRule. + p.debugSeedState( + downloads: {'srv:30': const DownloadProgress(globalKey: 'srv:30', status: DownloadStatus.queued)}, + ); + + await p.deleteSyncRule(p.syncRuleKeyFor('srv', '30')); + expect(p.getMetadata('srv:30'), isNotNull, reason: 'metadata is still in use by the download'); + + p.dispose(); + }); + + test('watch events target active-profile parent sync rules', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + final keys = p.syncRuleKeysForWatchEvent( + WatchStateEvent( + itemId: 'episode-1', + serverId: 'jf-machine', + cacheServerId: 'jf-machine/user-a', + changeType: WatchStateChangeType.watched, + parentChain: const ['season-1', 'show-1'], + mediaType: 'episode', + isNowWatched: true, + ), + ); + + expect( + keys, + containsAll({ + 'test-profile|jf-machine:episode-1', + 'test-profile|jf-machine:season-1', + 'test-profile|jf-machine:show-1', + }), + ); + expect(keys, hasLength(3)); + + p.dispose(); + }); + test('forTesting load reads pre-existing sync rules from database', () async { // Pre-seed the database with a rule before the provider exists. await db.insertSyncRule( + profileId: 'test-profile', serverId: 'srv', ratingKey: '99', - globalKey: 'srv:99', + globalKey: 'test-profile|srv:99', targetType: 'show', episodeCount: 7, ); @@ -175,8 +314,357 @@ void main() { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await p.ensureInitialized(); - expect(p.hasSyncRule('srv:99'), isTrue); - expect(p.getSyncRule('srv:99')!.episodeCount, 7); + expect(p.hasSyncRule('test-profile|srv:99'), isTrue); + expect(p.getSyncRule('test-profile|srv:99')!.episodeCount, 7); + + p.dispose(); + }); + }); + + group('DownloadProvider — profile-scoped download ownership', () { + final movie = MediaItem( + id: '1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Owned Movie', + serverId: 'srv', + ); + + test('download getters only expose active-profile owned physical rows', () async { + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:1'); + + final p = DownloadProvider.forTesting( + downloadManager: downloadManager, + database: db, + activeProfileId: 'profile-a', + ); + await p.ensureInitialized(); + p.debugSeedState( + downloads: { + 'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed), + 'srv:2': const DownloadProgress(globalKey: 'srv:2', status: DownloadStatus.completed), + }, + metadata: { + 'srv:1': movie, + 'srv:2': movie.copyWith(id: '2', title: 'Other Profile Movie'), + }, + ownedDownloadKeys: const {}, + ); + + expect(p.downloads.keys, ['srv:1']); + expect(p.getProgress('srv:1'), isNotNull); + expect(p.getProgress('srv:2'), isNull); + expect(p.downloadedMovies.map((m) => m.id), ['1']); + + p.dispose(); + }); + + test('queueDownload claims an existing physical download instead of duplicating it', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState( + downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)}, + metadata: {'srv:1': movie}, + ownedDownloadKeys: const {}, + ); + + final count = await p.queueDownload(movie, _ThrowingClient()); + + expect(count, 1); + expect(p.downloads.keys, ['srv:1']); + expect(await db.getDownloadOwnerKeysForProfile('test-profile'), {'srv:1'}); + + p.dispose(); + }); + + test('deleteDownload removes only active-profile ownership when another owner remains', () async { + await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:1'); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:1'); + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState( + downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)}, + metadata: {'srv:1': movie}, + ownedDownloadKeys: const {}, + ); + + await p.deleteDownload('srv:1'); + + expect(p.downloads, isEmpty); + expect(await db.getDownloadOwnerKeysForProfile('test-profile'), isEmpty); + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {'srv:1'}); + + p.dispose(); + }); + + test('deleteDownload is a no-op for unowned physical rows', () async { + await db.insertDownload( + serverId: 'srv', + ratingKey: '1', + globalKey: 'srv:1', + type: 'movie', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:1'); + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState( + downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)}, + metadata: {'srv:1': movie}, + ownedDownloadKeys: const {}, + ); + + await p.deleteDownload('srv:1'); + + expect(await db.getDownloadedMedia('srv:1'), isNotNull); + expect(p.getProgress('srv:1'), isNull); + + p.dispose(); + }); + + test('cancelDownload is a no-op for unowned physical rows', () async { + await db.insertDownload( + serverId: 'srv', + ratingKey: '1', + globalKey: 'srv:1', + type: 'movie', + status: DownloadStatus.queued.index, + ); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:1'); + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState( + downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.queued)}, + metadata: {'srv:1': movie}, + ownedDownloadKeys: const {}, + ); + + await p.cancelDownload('srv:1'); + + expect(await db.getDownloadedMedia('srv:1'), isNotNull); + expect(p.getProgress('srv:1'), isNull); + + p.dispose(); + }); + + test('releaseDownloadsForProfileServers removes only downloads from the removed connection', () async { + await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'srv:1'); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'srv:1'); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'other:2'); + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState( + downloads: { + 'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed), + 'other:2': const DownloadProgress(globalKey: 'other:2', status: DownloadStatus.completed), + }, + metadata: { + 'srv:1': movie, + 'other:2': movie.copyWith(id: '2', serverId: 'other'), + }, + ); + + await p.releaseDownloadsForProfileServers('test-profile', {'srv'}); + + expect(await db.getDownloadOwnerKeysForProfile('test-profile'), {'other:2'}); + expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {'srv:1'}); + expect(p.downloads.keys, ['other:2']); + + p.dispose(); + }); + }); + + group('DownloadProvider — scoped Jellyfin metadata', () { + Future insertJellyfinConnection(String userId) { + return db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: 'jf-machine/$userId', + kind: 'jellyfin', + displayName: 'Shared JF', + configJson: jsonEncode({ + 'baseUrl': 'https://jf.example.com', + 'serverName': 'Shared JF', + 'serverMachineId': 'jf-machine', + 'userId': userId, + 'userName': userId, + 'accessToken': 'token-$userId', + 'deviceId': 'device', + }), + createdAt: 0, + ), + ); + } + + Future putPinnedItem(String scopeId, String userId, String itemId, Map data) async { + await JellyfinApiCache.instance.put(scopeId, '/Users/$userId/Items/$itemId', data); + await JellyfinApiCache.instance.pinForOffline(scopeId, itemId); + } + + test('loads parent metadata from the downloaded Jellyfin user scope', () async { + await insertJellyfinConnection('user-a'); + await insertJellyfinConnection('user-b'); + + await putPinnedItem('jf-machine/user-a', 'user-a', 'show-1', { + 'Id': 'show-1', + 'Type': 'Series', + 'Name': 'Scoped Show A', + 'RecursiveItemCount': 1, + 'UserData': {'UnplayedItemCount': 1}, + }); + await putPinnedItem('jf-machine/user-a', 'user-a', 'season-1', { + 'Id': 'season-1', + 'Type': 'Season', + 'Name': 'Season A', + 'SeriesId': 'show-1', + 'SeriesName': 'Scoped Show A', + 'UserData': {'UnplayedItemCount': 1}, + }); + await putPinnedItem('jf-machine/user-a', 'user-a', 'ep-1', { + 'Id': 'ep-1', + 'Type': 'Episode', + 'Name': 'Episode A', + 'SeriesId': 'show-1', + 'SeriesName': 'Scoped Show A', + 'SeasonId': 'season-1', + 'SeasonName': 'Season A', + 'UserData': {'PlayCount': 0}, + }); + await putPinnedItem('jf-machine/user-b', 'user-b', 'show-1', { + 'Id': 'show-1', + 'Type': 'Series', + 'Name': 'Wrong User Show', + 'RecursiveItemCount': 1, + 'UserData': {'UnplayedItemCount': 0}, + }); + + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'ep-1', + globalKey: 'jf-machine:ep-1', + type: 'episode', + parentRatingKey: 'season-1', + grandparentRatingKey: 'show-1', + status: DownloadStatus.completed.index, + ); + + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState( + downloads: { + 'jf-machine:ep-1': const DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed), + }, + ); + await p.refreshMetadataFromCache(); + + expect(p.getMetadata('jf-machine:ep-1')?.title, 'Episode A'); + expect(p.getMetadata('jf-machine:show-1')?.title, 'Scoped Show A'); + expect(p.getMetadata('jf-machine:season-1')?.title, 'Season A'); + + p.dispose(); + }); + + test('refreshMetadataFromCache prefers the active Jellyfin user scope', () async { + await insertJellyfinConnection('user-a'); + await insertJellyfinConnection('user-b'); + await putPinnedItem('jf-machine/user-a', 'user-a', 'ep-1', { + 'Id': 'ep-1', + 'Type': 'Episode', + 'Name': 'Wrong User Episode', + 'SeriesId': 'show-1', + 'SeasonId': 'season-1', + 'UserData': {'PlayCount': 0}, + }); + await putPinnedItem('jf-machine/user-b', 'user-b', 'ep-1', { + 'Id': 'ep-1', + 'Type': 'Episode', + 'Name': 'Active User Episode', + 'SeriesId': 'show-1', + 'SeasonId': 'season-1', + 'UserData': {'PlayCount': 1}, + }); + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'ep-1', + globalKey: 'jf-machine:ep-1', + type: 'episode', + parentRatingKey: 'season-1', + grandparentRatingKey: 'show-1', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1'); + downloadManager.setClientResolver((serverId, {clientScopeId}) { + if (serverId == 'jf-machine') { + return _ScopedTestClient(serverId: 'jf-machine', scopedServerId: 'jf-machine/user-b'); + } + return null; + }); + + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState( + downloads: { + 'jf-machine:ep-1': const DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed), + }, + ); + await p.refreshMetadataFromCache(); + + expect(p.getMetadata('jf-machine:ep-1')?.title, 'Active User Episode'); + expect(p.getMetadata('jf-machine:ep-1')?.isWatched, isTrue); + + p.dispose(); + }); + + test('refreshMetadataFromCache applies scoped Jellyfin offline watch overlay', () async { + await insertJellyfinConnection('user-a'); + await insertJellyfinConnection('user-b'); + await putPinnedItem('jf-machine/user-b', 'user-b', 'ep-1', { + 'Id': 'ep-1', + 'Type': 'Episode', + 'Name': 'Active User Episode', + 'SeriesId': 'show-1', + 'SeasonId': 'season-1', + 'UserData': {'PlayCount': 0}, + }); + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'ep-1', + globalKey: 'jf-machine:ep-1', + type: 'episode', + parentRatingKey: 'season-1', + grandparentRatingKey: 'show-1', + status: DownloadStatus.completed.index, + ); + await db.addDownloadOwner(profileId: 'test-profile', globalKey: 'jf-machine:ep-1'); + await db.insertWatchAction( + profileId: 'test-profile', + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-b', + ratingKey: 'ep-1', + actionType: 'watched', + ); + downloadManager.setClientResolver((serverId, {clientScopeId}) { + if (serverId == 'jf-machine') { + return _ScopedTestClient(serverId: 'jf-machine', scopedServerId: 'jf-machine/user-b'); + } + return null; + }); + + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + p.debugSeedState( + downloads: { + 'jf-machine:ep-1': const DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed), + }, + ); + + await p.refreshMetadataFromCache(); + + expect(p.getMetadata('jf-machine:ep-1')?.isWatched, isTrue); p.dispose(); }); @@ -200,6 +688,138 @@ void main() { }); }); + group('DownloadProvider — cancelDownload map symmetry', () { + test('cancelDownload removes download, metadata, artwork, and episode count', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + const key = 'srv:42'; + p.debugSeedState( + downloads: {key: const DownloadProgress(globalKey: key, status: DownloadStatus.queued)}, + metadata: { + key: MediaItem( + id: '42', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Ep 42', + serverId: 'srv', + ), + }, + artwork: {key: const DownloadedArtwork(thumbPath: '/art/42.jpg')}, + episodeCounts: {key: 7}, + ); + + await p.cancelDownload(key); + + expect(p.getProgress(key), isNull); + expect(p.getMetadata(key), isNull); + expect(p.getArtworkPaths(key), isNull, reason: 'artwork path must not orphan after cancel'); + expect(p.totalEpisodeCountFor(key), isNull, reason: 'episode count must not orphan after cancel'); + + p.dispose(); + }); + + test('cancelDownload is a no-op when download is absent', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + // Seed only artwork; no download → cancelDownload should not touch it. + p.debugSeedState(artwork: {'srv:99': const DownloadedArtwork(thumbPath: '/art/99.jpg')}); + + await p.cancelDownload('srv:99'); + expect(p.getArtworkPaths('srv:99'), isNotNull); + + p.dispose(); + }); + }); + + group('DownloadProvider — refresh clears transient state', () { + test('refresh evicts stale _queueing and _deletionProgress entries', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + const queueingKey = 'srv:queueing'; + const deletingKey = 'srv:deleting'; + p.debugSeedState( + queueing: {queueingKey}, + deletionProgress: { + deletingKey: const DeletionProgress( + globalKey: deletingKey, + itemTitle: 'Deleting', + currentItem: 1, + totalItems: 5, + ), + }, + ); + expect(p.isQueueing(queueingKey), isTrue); + expect(p.getDeletionProgress(deletingKey), isNotNull); + + // refresh() calls _loadPersistedDownloads. Storage may or may not be + // initialized in this test, but the clear-block runs before any storage + // call (right after recoveryFuture resolves), so the assertions below + // hold either way. + await p.refresh(); + + expect(p.isQueueing(queueingKey), isFalse); + expect(p.getDeletionProgress(deletingKey), isNull); + + p.dispose(); + }); + }); + + group('DownloadProvider — queueDownload exception safety', () { + test('rolls back season metadata when expansion throws', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + final season = MediaItem( + id: '7', + backend: MediaBackend.plex, + kind: MediaKind.season, + title: 'Season 7', + serverId: 'srv', + ); + expect(p.getMetadata('srv:7'), isNull); + + await expectLater(p.queueDownload(season, _ThrowingClient()), throwsA(isA())); + + expect(p.getMetadata('srv:7'), isNull, reason: 'metadata stash must be rolled back when queue helper throws'); + expect(p.isQueueing('srv:7'), isFalse, reason: '_queueing must be cleared by the finally block'); + + p.dispose(); + }); + + test('preserves pre-existing metadata if queue throws (no clobber)', () async { + final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await p.ensureInitialized(); + + // Pre-existing metadata under the same key (e.g. from a prior sync rule's + // targetMetadata). The rollback must not delete it on queue failure. + final preexisting = MediaItem( + id: '7', + backend: MediaBackend.plex, + kind: MediaKind.season, + title: 'Original Title', + serverId: 'srv', + ); + p.debugSeedState(metadata: {'srv:7': preexisting}); + + final season = MediaItem( + id: '7', + backend: MediaBackend.plex, + kind: MediaKind.season, + title: 'New Title', + serverId: 'srv', + ); + + await expectLater(p.queueDownload(season, _ThrowingClient()), throwsA(isA())); + + expect(p.getMetadata('srv:7'), isNotNull, reason: 'pre-existing metadata must survive rollback'); + + p.dispose(); + }); + }); + group('DownloadProvider — dispose hygiene', () { test('dispose cancels stream subscriptions and is safe to call once', () async { final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); diff --git a/test/providers/libraries_provider_test.dart b/test/providers/libraries_provider_test.dart index 64f3d3b6..0d42b3c5 100644 --- a/test/providers/libraries_provider_test.dart +++ b/test/providers/libraries_provider_test.dart @@ -1,12 +1,19 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_library.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_library.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/services/storage_service.dart'; import '../test_helpers/prefs.dart'; -PlexLibrary _lib(String key, {String type = 'movie', String? serverId, String title = 'L'}) => - PlexLibrary(key: key, title: title, type: type, serverId: serverId); +MediaLibrary _lib(String key, {String type = 'movie', String? serverId, String title = 'L'}) => MediaLibrary( + id: key, + backend: MediaBackend.plex, + title: title, + kind: MediaKind.fromString(type), + serverId: serverId, +); void main() { setUp(resetSharedPreferencesForTest); diff --git a/test/providers/multi_server_provider_test.dart b/test/providers/multi_server_provider_test.dart index 64959452..b1d9a361 100644 --- a/test/providers/multi_server_provider_test.dart +++ b/test/providers/multi_server_provider_test.dart @@ -89,6 +89,107 @@ void main() { p.dispose(); }); + group('visibility filter', () { + test('setVisibleServerIds replaces the filter and notifies', () { + final p = MultiServerProvider(manager, aggregation); + var notified = 0; + p.addListener(() => notified++); + + // Empty set is a real value (different from null) — switching from + // null → {} should notify so consumers know the active profile has + // no servers, not "all servers". + p.setVisibleServerIds({}); + expect(notified, 1); + + p.setVisibleServerIds({'a', 'b'}); + expect(notified, 2); + + // Idempotent: same membership is a no-op. + p.setVisibleServerIds({'b', 'a'}); + expect(notified, 2); + + // Clearing back to null after a real filter is a state change. + p.setVisibleServerIds(null); + expect(notified, 3); + + p.dispose(); + }); + + test('addToVisibleServerIds initializes filter when null', () { + final p = MultiServerProvider(manager, aggregation); + var notified = 0; + p.addListener(() => notified++); + + // No prior filter — first add seeds it as a one-element set. + p.addToVisibleServerIds('srv-1'); + expect(notified, 1); + + // Build up incrementally. + p.addToVisibleServerIds('srv-2'); + expect(notified, 2); + + // Idempotent on already-present ids. + p.addToVisibleServerIds('srv-1'); + expect(notified, 2); + + p.dispose(); + }); + + test('onlineServerIds respect the visibility filter', () { + final p = MultiServerProvider(manager, aggregation); + // updateServerStatus only populates _serverStatus, not _plexServers, so + // we exercise the filter via onlineServerIds (which is keyed off + // status). The serverIds list requires actual server registration + // which goes through addPlexAccount/addJellyfinConnection — beyond + // what this unit test needs to cover. + manager.updateServerStatus('srv-1', true); + manager.updateServerStatus('srv-2', true); + manager.updateServerStatus('srv-3', false); + + // No filter — every online id passes through. + expect(p.onlineServerIds, containsAll({'srv-1', 'srv-2'})); + + p.setVisibleServerIds({'srv-1'}); + expect(p.onlineServerIds, ['srv-1']); + expect(p.isServerOnline('srv-2'), isFalse, reason: 'filtered out even when manager reports online'); + + // Empty filter blocks everything — covers the "no connections" path + // for a freshly-created profile that hasn't borrowed anything yet. + p.setVisibleServerIds({}); + expect(p.onlineServerIds, isEmpty); + + p.dispose(); + }); + + test('setVisibleServerIds immediately hides Live TV servers outside the filter', () { + final p = MultiServerProvider(manager, aggregation); + p.debugSetLiveTvServersForTesting([ + LiveTvServerInfo(serverId: 'srv-1', dvrKey: 'dvr-1'), + LiveTvServerInfo(serverId: 'srv-2', dvrKey: 'dvr-2'), + ]); + + p.setVisibleServerIds({'srv-1'}); + + expect(p.hasLiveTv, isTrue); + expect(p.liveTvServers.map((s) => s.serverId), ['srv-1']); + expect(p.liveTvServers.single.dvrKey, 'dvr-1'); + + p.dispose(); + }); + + test('setVisibleServerIds empty immediately clears stale Live TV state', () { + final p = MultiServerProvider(manager, aggregation); + p.debugSetLiveTvServersForTesting([LiveTvServerInfo(serverId: 'srv-1', dvrKey: 'dvr-1')]); + + p.setVisibleServerIds({}); + + expect(p.hasLiveTv, isFalse); + expect(p.liveTvServers, isEmpty); + + p.dispose(); + }); + }); + test('dispose runs cleanly and cancels the status subscription', () async { final p = MultiServerProvider(manager, aggregation); diff --git a/test/providers/offline_mode_provider_test.dart b/test/providers/offline_mode_provider_test.dart index 5c3a06b0..60dfde8c 100644 --- a/test/providers/offline_mode_provider_test.dart +++ b/test/providers/offline_mode_provider_test.dart @@ -14,14 +14,18 @@ void main() { setUp(resetSharedPreferencesForTest); group('OfflineModeProvider', () { - test('with empty manager reports server-side offline at construction', () { + test('with empty manager: hasServerConnection=false but isOffline stays false during warmup', () { final manager = MultiServerManager(); final p = OfflineModeProvider(manager); - // Default network=true, no servers → isOffline=true (no server connection). + // Until [MultiServerManager] emits its first status snapshot, we don't + // actually know whether the binder will connect anything — treating an + // empty manager as offline causes the cold-start UI to flash the + // offline state for the few hundred ms it takes to come up. Stay + // optimistic. expect(p.hasNetworkConnection, isTrue); expect(p.hasServerConnection, isFalse); - expect(p.isOffline, isTrue); + expect(p.isOffline, isFalse); p.dispose(); manager.dispose(); @@ -40,14 +44,19 @@ void main() { manager.dispose(); }); - test('all servers offline → hasServerConnection is false', () { + test('all servers offline at construction → still warmup-optimistic until status emits', () { + // updateServerStatus pushes to a broadcast controller — the provider + // hasn't subscribed yet, so it never sees these events. After + // construction `onlineServerIds` is empty (the same shape as a + // fresh-cold-start manager), so we stay optimistic until the + // provider's own listener catches an emission. final manager = MultiServerManager(); manager.updateServerStatus('srv-1', false); manager.updateServerStatus('srv-2', false); final p = OfflineModeProvider(manager); expect(p.hasServerConnection, isFalse); - expect(p.isOffline, isTrue); + expect(p.isOffline, isFalse); p.dispose(); manager.dispose(); @@ -87,5 +96,21 @@ void main() { p.dispose(); manager.dispose(); }); + + test('warmup skipped when manager already has an online server at construction', () { + // If the manager already has an online server when the provider is + // built, we have ground truth — no need for the warmup window. + // hasServerConnection reflects the manager's state and isOffline + // is correctly false (network up + server up). + final manager = MultiServerManager(); + manager.updateServerStatus('srv', true); + final p = OfflineModeProvider(manager); + + expect(p.hasServerConnection, isTrue); + expect(p.isOffline, isFalse); + + p.dispose(); + manager.dispose(); + }); }); } diff --git a/test/providers/offline_watch_provider_test.dart b/test/providers/offline_watch_provider_test.dart index d915d34c..d249e85f 100644 --- a/test/providers/offline_watch_provider_test.dart +++ b/test/providers/offline_watch_provider_test.dart @@ -84,7 +84,7 @@ void main() { // queueMarkWatched on the sync service notifies its listeners; the // provider's internal listener forwards via safeNotifyListeners. - await syncService.queueMarkWatched(serverId: 'srv', ratingKey: '42'); + await syncService.queueMarkWatched(serverId: 'srv', itemId: '42'); expect(notified, greaterThanOrEqualTo(1)); p.dispose(); @@ -96,7 +96,7 @@ void main() { var notified = 0; p.addListener(() => notified++); - await p.markAsWatched(serverId: 'srv', ratingKey: '50'); + await p.markAsWatched(serverId: 'srv', itemId: '50'); // The local watch status now reads as true via the sync service. expect(await p.isWatched('srv:50'), isTrue); @@ -110,7 +110,7 @@ void main() { test('markAsUnwatched queues an offline action and notifies', () async { final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider); - await p.markAsUnwatched(serverId: 'srv', ratingKey: '60'); + await p.markAsUnwatched(serverId: 'srv', itemId: '60'); expect(await p.isWatched('srv:60'), isFalse); p.dispose(); @@ -123,7 +123,7 @@ void main() { p.addListener(() => notified++); // Sanity: listener is registered - await syncService.queueMarkWatched(serverId: 'srv', ratingKey: '70'); + await syncService.queueMarkWatched(serverId: 'srv', itemId: '70'); final preDisposeNotifies = notified; expect(preDisposeNotifies, greaterThanOrEqualTo(1)); @@ -132,7 +132,7 @@ void main() { // After dispose, sync service notifications should not call our // listener (provider unsubscribed). Mutating the sync service post- // dispose must not throw on the provider side. - await syncService.queueMarkUnwatched(serverId: 'srv', ratingKey: '70'); + await syncService.queueMarkUnwatched(serverId: 'srv', itemId: '70'); expect(notified, preDisposeNotifies); }); }); diff --git a/test/providers/playback_state_provider_test.dart b/test/providers/playback_state_provider_test.dart index d90d0d97..8cbed2d4 100644 --- a/test/providers/playback_state_provider_test.dart +++ b/test/providers/playback_state_provider_test.dart @@ -1,10 +1,20 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/play_queue_response.dart'; -import 'package:plezy/models/plex_metadata.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/play_queue.dart'; +import 'package:plezy/models/plex/play_queue_response.dart'; import 'package:plezy/providers/playback_state_provider.dart'; -PlexMetadata _item(String ratingKey, int playQueueItemID) => - PlexMetadata(ratingKey: ratingKey, playQueueItemID: playQueueItemID, title: 'Episode $ratingKey'); +PlexMediaItem _item(String ratingKey, int playQueueItemID) => PlexMediaItem( + id: ratingKey, + kind: MediaKind.episode, + playQueueItemId: playQueueItemID, + title: 'Episode $ratingKey', +); + +PlexMediaItem _miItem(String id, int playQueueItemId) => + PlexMediaItem(id: id, kind: MediaKind.episode, playQueueItemId: playQueueItemId); PlayQueueResponse _queue({ int playQueueID = 1, @@ -12,7 +22,7 @@ PlayQueueResponse _queue({ bool shuffled = false, int? totalCount, int? size, - List? items, + List? items, }) { return PlayQueueResponse( playQueueID: playQueueID, @@ -110,7 +120,7 @@ void main() { // Not in queue mode → no-op var notified = 0; p.addListener(() => notified++); - p.setCurrentItem(_item('a', 5)); + p.setCurrentItem(_miItem('a', 5)); expect(p.currentPlayQueueItemID, isNull); expect(notified, 0); @@ -122,12 +132,12 @@ void main() { // setPlaybackFromPlayQueue notifies once final preNotify = notified; - p.setCurrentItem(_item('b', 2002)); + p.setCurrentItem(_miItem('b', 2002)); expect(p.currentPlayQueueItemID, 2002); expect(notified, preNotify + 1); - // Item without playQueueItemID → no update, no notify - p.setCurrentItem(PlexMetadata(ratingKey: 'd')); + // Item without playQueueItemId → no update, no notify + p.setCurrentItem(MediaItem(id: 'd', backend: MediaBackend.plex, kind: MediaKind.episode)); expect(p.currentPlayQueueItemID, 2002); p.dispose(); @@ -140,8 +150,8 @@ void main() { final next = await p.getNextEpisode('b'); expect(next, isNotNull); - expect(next!.ratingKey, 'c'); - expect(next.playQueueItemID, 1003); + expect(next!.id, 'c'); + expect((next as PlexMediaItem).playQueueItemId, 1003); // currentPlayQueueItemID is NOT updated by getNextEpisode (setCurrentItem does that). expect(p.currentPlayQueueItemID, 1002); @@ -174,8 +184,8 @@ void main() { final prev = await p.getPreviousEpisode('b'); expect(prev, isNotNull); - expect(prev!.ratingKey, 'a'); - expect(prev.playQueueItemID, 1001); + expect(prev!.id, 'a'); + expect((prev as PlexMediaItem).playQueueItemId, 1001); p.dispose(); }); @@ -204,7 +214,7 @@ void main() { _queue(playQueueID: 1, selectedItemID: 1, totalCount: 1, items: [_item('a', 1)]), null, ); - expect(() => p.loadedItems.add(_item('mutated', 999)), throwsUnsupportedError); + expect(() => p.loadedItems.add(_miItem('mutated', 999)), throwsUnsupportedError); p.dispose(); }); @@ -215,5 +225,28 @@ void main() { p.clearShuffle(); await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, totalCount: 1, items: [_item('a', 1)]), null); }); + + test('playQueueItemIdFor returns synthetic ids for Jellyfin local queue items', () { + // Anchor: VideoPlayerScreen.initState gates clearShuffle on + // `playQueueItemIdFor(meta) != null` so a Jellyfin playlist queue + // survives entry into the player. If this returns null for queue + // members, the player wipes the launcher-set queue and prev/next + // walks the show instead of the playlist. + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + + final ep1 = MediaItem(id: 'ep1', backend: MediaBackend.jellyfin, kind: MediaKind.episode); + final ep2 = MediaItem(id: 'ep2', backend: MediaBackend.jellyfin, kind: MediaKind.episode); + final outsider = MediaItem(id: 'ep-other', backend: MediaBackend.jellyfin, kind: MediaKind.episode); + + p.setPlaybackFromLocalQueue( + LocalPlayQueue(id: 'jellyfin:playlist-X', items: [ep1, ep2], currentIndex: 0, backendId: 'jellyfin'), + contextKey: 'playlist-X', + ); + + expect(p.playQueueItemIdFor(ep1), 0); + expect(p.playQueueItemIdFor(ep2), 1); + expect(p.playQueueItemIdFor(outsider), isNull); + }); }); } diff --git a/test/providers/user_profile_provider_test.dart b/test/providers/user_profile_provider_test.dart index 77e09305..03710465 100644 --- a/test/providers/user_profile_provider_test.dart +++ b/test/providers/user_profile_provider_test.dart @@ -1,190 +1,282 @@ +import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_home.dart'; -import 'package:plezy/models/plex_home_user.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/user_profile_provider.dart'; +import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/storage_service.dart'; import '../test_helpers/prefs.dart'; -PlexHomeUser _user({ - required int id, - String? uuid, - String title = 'Title', - bool admin = false, - bool protected = false, -}) { - return PlexHomeUser( - id: id, - uuid: uuid ?? 'uuid-$id', - title: title, - username: null, - email: null, - friendlyName: null, - thumb: '', - hasPassword: false, - restricted: false, - updatedAt: null, - admin: admin, - guest: false, - protected: protected, - ); -} - -PlexHome _home({List? users}) { - return PlexHome( - id: 1, - name: 'My Home', - guestUserID: null, - guestUserUUID: '', - guestEnabled: false, - subscription: false, - users: - users ?? - [_user(id: 1, uuid: 'admin-uuid', title: 'Admin', admin: true), _user(id: 2, uuid: 'kid-uuid', title: 'Kid')], - ); -} - void main() { setUp(resetSharedPreferencesForTest); - group('UserProfileProvider', () { + group('UserProfileProvider (settings-only)', () { test('starts with all-null state and no error', () { final p = UserProfileProvider(); - expect(p.home, isNull); - expect(p.currentUser, isNull); expect(p.profileSettings, isNull); expect(p.isLoading, isFalse); expect(p.error, isNull); - expect(p.hasMultipleUsers, isFalse); - expect(p.needsInitialProfileSelection, isFalse); p.dispose(); }); - test('initialize loads cached home users from SharedPreferences', () async { - // Pre-seed home users cache directly. - final storage = await StorageService.getInstance(); - await storage.saveHomeUsersCache(_home().toJson()); - + test('refreshProfileSettings without a stored token is a no-op', () async { final p = UserProfileProvider(); var notified = 0; p.addListener(() => notified++); - - await p.initialize(); - - expect(p.home, isNotNull); - expect(p.home!.users, hasLength(2)); - expect(p.home!.name, 'My Home'); - expect(p.hasMultipleUsers, isTrue); - // No current user UUID stored, so currentUser is null and selection is needed. - expect(p.currentUser, isNull); - expect(p.needsInitialProfileSelection, isTrue); - // _loadCachedData notifies once. - expect(notified, greaterThanOrEqualTo(1)); - + await p.refreshProfileSettings(); + // No token → no API call → no notify, no error. + expect(notified, 0); + expect(p.profileSettings, isNull); p.dispose(); }); - test('initialize resolves currentUser from stored UUID', () async { - final storage = await StorageService.getInstance(); - await storage.saveHomeUsersCache(_home().toJson()); - await storage.saveCurrentUserUUID('kid-uuid'); - + test('logout without initialization is safe', () async { final p = UserProfileProvider(); - await p.initialize(); - - expect(p.currentUser, isNotNull); - expect(p.currentUser!.uuid, 'kid-uuid'); - expect(p.currentUser!.title, 'Kid'); - // Once a user is selected, no initial selection needed. - expect(p.needsInitialProfileSelection, isFalse); - - p.dispose(); - }); - - test('hasMultipleUsers reflects the home', () async { - final storage = await StorageService.getInstance(); - await storage.saveHomeUsersCache(_home(users: [_user(id: 1, admin: true)]).toJson()); - - final p = UserProfileProvider(); - await p.initialize(); - expect(p.home!.users, hasLength(1)); - expect(p.hasMultipleUsers, isFalse); - - p.dispose(); - }); - - test('needsInitialProfileSelection is false when no home loaded', () async { - final p = UserProfileProvider(); - await p.initialize(); // No cache, no token → home stays null. - expect(p.home, isNull); - expect(p.needsInitialProfileSelection, isFalse); - p.dispose(); - }); - - test('logout with no services initialized is a no-op', () async { - final p = UserProfileProvider(); - // Without initialize, _storageService is null → logout returns early. await p.logout(); - expect(p.home, isNull); - expect(p.currentUser, isNull); - expect(p.error, isNull); - p.dispose(); - }); - - test('logout clears all state and notifies', () async { - final storage = await StorageService.getInstance(); - await storage.saveHomeUsersCache(_home().toJson()); - await storage.saveCurrentUserUUID('admin-uuid'); - - final p = UserProfileProvider(); - await p.initialize(); - expect(p.home, isNotNull); - expect(p.currentUser, isNotNull); - - var notified = 0; - p.addListener(() => notified++); - - await p.logout(); - expect(p.home, isNull); - expect(p.currentUser, isNull); expect(p.profileSettings, isNull); expect(p.error, isNull); - // Setting loading true/false + clearing state fires notifications. - expect(notified, greaterThanOrEqualTo(1)); - - p.dispose(); - }); - - test('refreshCurrentUser is a no-op when currentUser is null', () async { - final p = UserProfileProvider(); - // No initialize, no current user → method short-circuits without touching network. - var notified = 0; - p.addListener(() => notified++); - await p.refreshCurrentUser(); - expect(notified, 0); - expect(p.currentUser, isNull); - p.dispose(); - }); - - test('setDataInvalidationCallback stores the callback without side effects', () { - final p = UserProfileProvider(); - // Should not throw and should not notify. - var notified = 0; - p.addListener(() => notified++); - p.setDataInvalidationCallback((_) async {}); - expect(notified, 0); - // Clearing it should also be safe. - p.setDataInvalidationCallback(null); - expect(notified, 0); p.dispose(); }); test('safeNotifyListeners after dispose does not throw', () async { final p = UserProfileProvider(); p.dispose(); - // logout uses safeNotifyListeners — must not throw post-dispose. - // Without initialized services it short-circuits, so no failure either. await p.logout(); }); + + test('settings connection follows the profile default row', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + final profile = Profile( + id: 'local-owner', + kind: ProfileKind.local, + displayName: 'Owner', + createdAt: DateTime(2026, 1, 1), + ); + final plex = PlexAccountConnection( + id: 'plex-a', + accountToken: 'plex-token', + clientIdentifier: 'client-a', + accountLabel: 'Plex', + createdAt: DateTime(2026, 1, 1), + ); + final jellyfin = JellyfinConnection( + id: 'jf-machine/user-a', + baseUrl: 'https://jf.example.com', + serverName: 'Jellyfin', + serverMachineId: 'jf-machine', + userId: 'user-a', + userName: 'User A', + accessToken: 'jf-token', + deviceId: 'device-a', + createdAt: DateTime(2026, 1, 1), + ); + await profiles.upsert(profile); + await connections.upsert(plex); + await connections.upsert(jellyfin); + await profileConnections.upsert( + ProfileConnection( + profileId: profile.id, + connectionId: plex.id, + userToken: 'plex-user-token', + userIdentifier: 'plex-user', + isDefault: true, + ), + makeDefault: true, + ); + await profileConnections.upsert( + ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), + ); + await storage.setActiveProfileId(profile.id); + await active.initialize(); + + final p = UserProfileProvider() + ..attach( + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + serverManager: manager, + ); + addTearDown(p.dispose); + + expect(await p.debugResolveActiveSettingsConnectionForTesting(), isA()); + + await profileConnections.setDefault(profile.id, jellyfin.id); + + expect(await p.debugResolveActiveSettingsConnectionForTesting(), isA()); + }); + + test('watches Plex Home profile connection rows', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => [_homeUser(uuid: 'home-user-a', title: 'Home User')], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + final account = PlexAccountConnection( + id: 'plex-a', + accountToken: 'account-token-a', + clientIdentifier: 'client-a', + accountLabel: 'Plex A', + createdAt: DateTime(2026, 1, 1), + ); + await connections.upsert(account); + await plexHome.refresh(account); + await storage.setActiveProfileId(plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a')); + await active.initialize(); + + final p = UserProfileProvider() + ..attach( + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + serverManager: manager, + ); + addTearDown(p.dispose); + + expect(p.debugWatchedProfileConnectionProfileId, active.activeId); + }); + + test('Plex token fallback uses the selected local profile account', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final profiles = ProfileRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => const [], + ); + final active = ActiveProfileProvider( + registry: profiles, + plexHome: plexHome, + connections: connections, + storage: storage, + ); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + await db.close(); + }); + + final profile = Profile( + id: 'local-owner', + kind: ProfileKind.local, + displayName: 'Owner', + createdAt: DateTime(2026, 1, 1), + ); + final accountA = PlexAccountConnection( + id: 'plex-a', + accountToken: 'wrong-owner-token', + clientIdentifier: 'client-a', + accountLabel: 'Plex A', + createdAt: DateTime(2026, 1, 1), + ); + final accountB = PlexAccountConnection( + id: 'plex-b', + accountToken: 'selected-owner-token', + clientIdentifier: 'client-b', + accountLabel: 'Plex B', + createdAt: DateTime(2026, 1, 1), + ); + await profiles.upsert(profile); + await connections.upsert(accountA); + await connections.upsert(accountB); + await profileConnections.upsert( + ProfileConnection( + profileId: profile.id, + connectionId: accountB.id, + userIdentifier: 'home-user-b', + isDefault: true, + ), + makeDefault: true, + ); + await storage.setActiveProfileId(profile.id); + await active.initialize(); + + final p = UserProfileProvider() + ..attach( + connections: connections, + activeProfile: active, + profileConnections: profileConnections, + serverManager: manager, + ); + addTearDown(p.dispose); + + expect(await p.debugResolveActivePlexUserTokenForTesting(), 'selected-owner-token'); + }); }); } + +PlexHomeUser _homeUser({required String uuid, required String title}) { + return PlexHomeUser( + id: 1, + uuid: uuid, + title: title, + thumb: '', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: true, + guest: false, + protected: false, + ); +} diff --git a/test/screens/auth_screen_test.dart b/test/screens/auth_screen_test.dart new file mode 100644 index 00000000..f6ec74ce --- /dev/null +++ b/test/screens/auth_screen_test.dart @@ -0,0 +1,64 @@ +import 'package:drift/native.dart'; +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/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/screens/auth_screen.dart'; +import 'package:plezy/services/storage_service.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + setUp(resetSharedPreferencesForTest); + + test('initial profile is built from the refreshed Plex Home cache', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final storage = await StorageService.getInstance(); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => [ + PlexHomeUser( + id: 1, + uuid: 'home-user-a', + title: 'Home User', + thumb: '', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: true, + guest: false, + protected: false, + ), + ], + ); + addTearDown(() async { + await plexHome.dispose(); + await db.close(); + }); + + final account = PlexAccountConnection( + id: 'plex-account-a', + accountToken: 'account-token', + clientIdentifier: 'client-a', + accountLabel: 'Plex', + createdAt: DateTime(2026, 1, 1), + ); + await connections.upsert(account); + await plexHome.refresh(account); + + final profile = initialPlexHomeProfileFromCache(plexHome, account); + + expect(profile, isNotNull); + expect(profile!.id, plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a')); + expect(profile.parentConnectionId, account.id); + expect(profile.displayName, 'Home User'); + }); +} diff --git a/test/screens/downloads/sync_rules_screen_test.dart b/test/screens/downloads/sync_rules_screen_test.dart new file mode 100644 index 00000000..16772bc2 --- /dev/null +++ b/test/screens/downloads/sync_rules_screen_test.dart @@ -0,0 +1,195 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +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/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.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/providers/download_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/downloads/sync_rules_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/download_manager_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_auth_service.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/prefs.dart'; + +PlexConnection _plexConnection() { + return PlexConnection( + protocol: 'http', + address: '127.0.0.1', + port: 32400, + uri: 'http://127.0.0.1:32400', + local: true, + relay: false, + ipv6: false, + ); +} + +PlexServer _plexServer(String id, String name) { + return PlexServer( + name: name, + clientIdentifier: id, + accessToken: 'token-$id', + connections: [_plexConnection()], + owned: true, + ); +} + +JellyfinConnection _jellyfinConnection({ + required String machineId, + required String userId, + required String serverName, +}) { + return JellyfinConnection( + id: '$machineId/$userId', + baseUrl: 'https://jf.example.com', + serverName: serverName, + serverMachineId: machineId, + userId: userId, + userName: userId, + accessToken: 'token-$userId', + deviceId: 'device', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); +} + +JellyfinClient _jellyfinClient(JellyfinConnection connection) { + return JellyfinClient.forTesting( + connection: connection, + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); +} + +MediaItem _show(String serverId, String ratingKey, String title) { + return MediaItem(id: ratingKey, backend: MediaBackend.plex, kind: MediaKind.show, title: title, serverId: serverId); +} + +class _FakeConnectionRegistry extends ConnectionRegistry { + _FakeConnectionRegistry(super.db, this.connections); + + final List connections; + + @override + Stream> watchConnections() => Stream.value(connections); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late DownloadProvider downloadProvider; + late DownloadManagerService downloadManager; + late MultiServerManager serverManager; + MultiServerProvider? multiServerProvider; + late ConnectionRegistry connectionRegistry; + late List connections; + + setUp(() async { + resetSharedPreferencesForTest(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + downloadManager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance); + downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await downloadProvider.ensureInitialized(); + serverManager = MultiServerManager(); + connections = []; + connectionRegistry = _FakeConnectionRegistry(db, connections); + }); + + tearDown(() async { + downloadProvider.dispose(); + multiServerProvider?.dispose(); + await db.close(); + }); + + Future insertRule(String serverId, String ratingKey) { + return downloadProvider.createSyncRule( + serverId: serverId, + ratingKey: ratingKey, + targetType: 'show', + episodeCount: 5, + ); + } + + Future pumpScreen(WidgetTester tester) async { + downloadProvider.debugSeedState( + metadata: { + 'plex-srv:show-1': _show('plex-srv', 'show-1', 'Plex Show'), + 'jf-machine:show-2': _show('jf-machine', 'show-2', 'Jellyfin Show'), + 'auth-jf:show-3': _show('auth-jf', 'show-3', 'Auth Show'), + 'unknown-srv:show-4': _show('unknown-srv', 'show-4', 'Unknown Show'), + }, + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: connectionRegistry), + ChangeNotifierProvider.value(value: downloadProvider), + ChangeNotifierProvider.value(value: multiServerProvider!), + ], + child: const MaterialApp(home: SyncRulesScreen()), + ), + ); + await tester.pump(); + } + + testWidgets('shows server context and active-profile availability for device sync rules', (tester) async { + connections.add( + PlexAccountConnection( + id: 'plex-account', + accountToken: 'account-token', + clientIdentifier: 'client-id', + accountLabel: 'Plex Account', + servers: [_plexServer('plex-srv', 'Living Room Plex')], + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + + final availableJellyfin = _jellyfinConnection( + machineId: 'jf-machine', + userId: 'user-a', + serverName: 'Shared Jellyfin', + ); + connections.add(availableJellyfin); + final availableClient = _jellyfinClient(availableJellyfin); + addTearDown(availableClient.close); + serverManager.debugRegisterJellyfinClientForTesting(availableClient); + + final authJellyfin = _jellyfinConnection(machineId: 'auth-jf', userId: 'user-b', serverName: 'Auth Jellyfin'); + connections.add(authJellyfin); + final authClient = _jellyfinClient(authJellyfin); + addTearDown(authClient.close); + serverManager.debugRegisterJellyfinClientForTesting(authClient, online: false); + serverManager.debugMarkAuthErrorForTesting('auth-jf'); + multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + + await insertRule('plex-srv', 'show-1'); + await insertRule('jf-machine', 'show-2'); + await insertRule('auth-jf', 'show-3'); + await insertRule('unknown-srv', 'show-4'); + + await pumpScreen(tester); + + expect(find.text('Plex Show'), findsOneWidget); + expect(find.text('Server: Living Room Plex • Not available for current profile'), findsOneWidget); + expect(find.text('Jellyfin Show'), findsOneWidget); + expect(find.text('Server: Shared Jellyfin • Available'), findsOneWidget); + expect(find.text('Auth Show'), findsOneWidget); + expect(find.text('Server: Auth Jellyfin • Sign in required'), findsOneWidget); + expect(find.text('Unknown Show'), findsOneWidget); + expect(find.text('Server: unknown-srv • Unknown server'), findsOneWidget); + }); +} diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart new file mode 100644 index 00000000..b1098fa1 --- /dev/null +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/screens/settings/add_jellyfin_screen.dart'; + +Profile _profile(String id) => Profile( + id: id, + kind: ProfileKind.local, + displayName: id, + sortOrder: 0, + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +void main() { + group('Jellyfin profile binding decisions', () { + test('creates a local profile only on true first-run with no profiles', () { + expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue); + expect( + shouldPromptForJellyfinProfileSelection(targetProfile: null, activeProfile: null, hasProfiles: false), + isFalse, + ); + }); + + test('uses existing active profile without prompting or creating', () { + final active = _profile('active'); + expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: active, hasProfiles: true), isFalse); + expect( + shouldPromptForJellyfinProfileSelection(targetProfile: null, activeProfile: active, hasProfiles: true), + isFalse, + ); + }); + + test('prompts when profiles exist but no profile is active', () { + expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: true), isFalse); + expect( + shouldPromptForJellyfinProfileSelection(targetProfile: null, activeProfile: null, hasProfiles: true), + isTrue, + ); + }); + + test('explicit target profile never creates or prompts', () { + final target = _profile('target'); + expect(shouldCreateLocalJellyfinProfile(targetProfile: target, activeProfile: null, hasProfiles: true), isFalse); + expect( + shouldPromptForJellyfinProfileSelection(targetProfile: target, activeProfile: null, hasProfiles: true), + isFalse, + ); + }); + }); +} diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart new file mode 100644 index 00000000..d16979fc --- /dev/null +++ b/test/services/data_aggregation_bridge_test.dart @@ -0,0 +1,38 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; + +/// Smoke tests for the surviving cross-server aggregation surface on +/// [DataAggregationService]. Single-server passthroughs were removed in +/// favour of `context.tryGetMediaClientForServer(...).()`; what's +/// left here is the multi-client fan-out, which is testable without a +/// real backend by simply asserting the empty-state behaviour. +void main() { + late AppDatabase db; + late MultiServerManager manager; + late DataAggregationService service; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + manager = MultiServerManager(); + service = DataAggregationService(manager); + }); + + tearDown(() async { + manager.dispose(); + await db.close(); + }); + + group('DataAggregationService cross-server aggregation', () { + test('getMediaLibrariesFromAllServers returns empty when no clients connected', () async { + expect(await service.getMediaLibrariesFromAllServers(), isEmpty); + }); + + test('searchAcrossServers and getOnDeckFromAllServers return empty when no clients', () async { + expect(await service.searchAcrossServers('hello'), isEmpty); + expect(await service.getOnDeckFromAllServers(), isEmpty); + }); + }); +} diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart new file mode 100644 index 00000000..88eee734 --- /dev/null +++ b/test/services/download_manager_service_test.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.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/models/download_models.dart'; +import 'package:plezy/services/download_artwork_helpers.dart'; +import 'package:plezy/services/download_manager_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/plex_api_cache.dart'; + +void main() { + group('downloadExtensionFromUrl', () { + test('uses path extension when present', () { + expect(downloadExtensionFromUrl('https://example.com/movie.mkv?Container=mp4'), 'mkv'); + }); + + test('uses Jellyfin Container query parameter when path has no extension', () { + expect(downloadExtensionFromUrl('https://example.com/Videos/item/stream?Static=true&Container=mkv'), 'mkv'); + }); + + test('normalizes and sanitizes container extensions', () { + expect(downloadExtensionFromUrl('https://example.com/Videos/item/stream?Container=MKV,MP4'), 'mkv'); + expect(downloadExtensionFromUrl('https://example.com/Videos/item/stream?Container=../bad'), isNull); + }); + }); + + group('artworkStorageKey', () { + test('removes Jellyfin api_key from persisted artwork keys', () { + final url = 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=secret-token'; + + expect(artworkStorageKey(url), 'https://jf.example/Items/item-1/Images/Primary?tag=abc'); + expect(buildArtworkSpecs(_movie(thumbPath: url), (path) => path).single.localKey, isNot(contains('api_key'))); + }); + }); + + group('lookupMetadata', () { + test('falls back from active Jellyfin scope to the download row scope', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: 'jf-machine/user-a', + kind: 'jellyfin', + displayName: 'User A · Jellyfin', + configJson: jsonEncode({ + 'baseUrl': 'https://jf.example', + 'serverName': 'Jellyfin', + 'serverMachineId': 'jf-machine', + 'userId': 'user-a', + 'userName': 'User A', + 'accessToken': 'token-a', + 'deviceId': 'device-a', + }), + createdAt: DateTime.now().millisecondsSinceEpoch, + ), + ); + await db + .into(db.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: 'jf-machine', + clientScopeId: const Value('jf-machine/user-a'), + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: DownloadStatus.completed.index, + ), + ); + await db + .into(db.apiCache) + .insert( + ApiCacheCompanion.insert( + cacheKey: 'jf-machine/user-a:/Users/user-a/Items/item-1', + data: jsonEncode({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Cached for User A'}), + pinned: const Value(true), + ), + ); + + final manager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance) + ..setClientResolver((serverId, {clientScopeId}) { + return _ScopedJellyfinClient(serverId: serverId, scopedServerId: clientScopeId ?? 'jf-machine/user-b'); + }); + + final item = await manager.lookupMetadata('jf-machine', 'item-1', preferActiveScope: true); + + expect(item?.title, 'Cached for User A'); + expect(item?.serverId, 'jf-machine'); + }); + + test('SAF recovery resolves show year from cached show metadata', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + + await PlexApiCache.instance.put('srv-1', '/library/metadata/show-1', { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'show-1', 'type': 'show', 'title': 'The Show', 'year': 2008}, + ], + }, + }); + + final manager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance); + final year = await manager.debugResolveSafRecoveryShowYear( + MediaItem( + id: 'ep-1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + serverId: 'srv-1', + title: 'Episode from 2010', + year: 2010, + grandparentId: 'show-1', + grandparentTitle: 'The Show', + parentIndex: 1, + index: 1, + ), + ); + + expect(year, 2008); + }); + }); +} + +MediaItem _movie({String? thumbPath}) { + return MediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'jf-machine', + thumbPath: thumbPath, + ); +} + +class _ScopedJellyfinClient implements MediaServerClient, ScopedMediaServerClient { + _ScopedJellyfinClient({required this.serverId, required this.scopedServerId}); + + @override + final String serverId; + + @override + final String scopedServerId; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/services/download_storage_service_test.dart b/test/services/download_storage_service_test.dart index c7b13e18..8b71bd7f 100644 --- a/test/services/download_storage_service_test.dart +++ b/test/services/download_storage_service_test.dart @@ -3,7 +3,9 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -import 'package:plezy/models/plex_metadata.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/services/download_storage_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; @@ -579,21 +581,34 @@ void main() { } // ============================================================ -// PlexMetadata fixtures (only the fields the SUT actually reads) +// MediaItem fixtures (only the fields the SUT actually reads) // ============================================================ -PlexMetadata _movie({required String title, int? year}) { - return PlexMetadata(ratingKey: 'm-${title.hashCode}', type: 'movie', title: title, year: year); +MediaItem _movie({required String title, int? year}) { + return MediaItem( + id: 'm-${title.hashCode}', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: title, + year: year, + ); } -PlexMetadata _show({required String title, int? year}) { - return PlexMetadata(ratingKey: 's-${title.hashCode}', type: 'show', title: title, year: year); +MediaItem _show({required String title, int? year}) { + return MediaItem( + id: 's-${title.hashCode}', + backend: MediaBackend.plex, + kind: MediaKind.show, + title: title, + year: year, + ); } -PlexMetadata _season({required String showTitle, int? showYear, required int seasonNumber}) { - return PlexMetadata( - ratingKey: 'season-$showTitle-$seasonNumber', - type: 'season', +MediaItem _season({required String showTitle, int? showYear, required int seasonNumber}) { + return MediaItem( + id: 'season-$showTitle-$seasonNumber', + backend: MediaBackend.plex, + kind: MediaKind.season, title: 'Season $seasonNumber', grandparentTitle: showTitle, year: showYear, @@ -601,16 +616,17 @@ PlexMetadata _season({required String showTitle, int? showYear, required int sea ); } -PlexMetadata _episode({ +MediaItem _episode({ required String showTitle, int? showYear, required int seasonNumber, required int episodeNumber, required String episodeTitle, }) { - return PlexMetadata( - ratingKey: 'ep-$showTitle-$seasonNumber-$episodeNumber', - type: 'episode', + return MediaItem( + id: 'ep-$showTitle-$seasonNumber-$episodeNumber', + backend: MediaBackend.plex, + kind: MediaKind.episode, title: episodeTitle, grandparentTitle: showTitle, year: showYear, diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index 5cf80a9b..d0481b8a 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -1,8 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_metadata.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/media/play_queue.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/episode_navigation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; import 'package:provider/provider.dart'; // NOTE on coverage scope: @@ -22,13 +29,53 @@ import 'package:provider/provider.dart'; // We also cover the [AdjacentEpisodes] data class invariants since that's // the public surface callers depend on. -PlexMetadata _meta(String ratingKey, {String? title}) => - PlexMetadata(ratingKey: ratingKey, title: title ?? 'Episode $ratingKey'); +MediaItem _meta(String id, {String? title}) => + MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id'); + +MediaItem _jfEpisode(String id, {required String seriesId, String serverId = 'srv-jf'}) => MediaItem( + id: id, + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode $id', + serverId: serverId, + grandparentId: seriesId, +); + +/// MultiServerManager subclass that returns a pre-supplied client without +/// going through the production add-connection flow. The base class doesn't +/// expose a way to inject clients into its private `_clients` map, so we +/// override the lookup directly. +class _StubManager extends MultiServerManager { + _StubManager(this._client); + final MediaServerClient? _client; + @override + MediaServerClient? getClient(String _) => _client; +} + +/// Recording client whose `fetchClientSideEpisodeQueue` is observable — +/// callers can assert it was (or wasn't) hit. +class _RecordingClient implements MediaServerClient { + _RecordingClient({required this.seriesEpisodes}); + final List seriesEpisodes; + final List seriesQueueCalls = []; + + @override + Future?> fetchClientSideEpisodeQueue(String seriesId) async { + seriesQueueCalls.add(seriesId); + return seriesEpisodes; + } + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} class _ProbeWidget extends StatefulWidget { const _ProbeWidget({required this.metadata, required this.onResult}); - final PlexMetadata metadata; + final MediaItem metadata; final void Function(AdjacentEpisodes) onResult; @override @@ -72,8 +119,8 @@ void main() { final ae = AdjacentEpisodes(next: _meta('n'), previous: _meta('p')); expect(ae.hasNext, isTrue); expect(ae.hasPrevious, isTrue); - expect(ae.next!.ratingKey, 'n'); - expect(ae.previous!.ratingKey, 'p'); + expect(ae.next!.id, 'n'); + expect(ae.previous!.id, 'p'); }); test('only-next variant', () { @@ -128,5 +175,65 @@ void main() { expect(result!.hasNext, isFalse); expect(result!.hasPrevious, isFalse); }); + + testWidgets('preserves an active playlist/collection queue against series rebuild', (tester) async { + // Reproduces the bug where playing an episode from a Jellyfin playlist + // had next/prev walking the show's episodes instead of the playlist — + // [_ensureLocalEpisodeQueue] used to overwrite the launcher-set queue + // unconditionally. The guard now bails out when contextKey is set to + // anything other than the seriesId. + final ep1 = _jfEpisode('ep1', seriesId: 'series-A'); + final ep2 = _jfEpisode('ep2', seriesId: 'series-B'); + final ep3 = _jfEpisode('ep3', seriesId: 'series-A'); + + final playback = PlaybackStateProvider(); + addTearDown(playback.dispose); + playback.setPlaybackFromLocalQueue( + LocalPlayQueue( + id: 'jellyfin:playlist-X', + items: [ep1, ep2, ep3], + currentIndex: 1, + backendId: MediaBackend.jellyfin.id, + ), + contextKey: 'playlist-X', + ); + + // Stub client returns fake series episodes that *include* ep2 — without + // the guard, the service would replace the playlist queue with this + // list and prev/next would point at sibling-X / sibling-Y. + final client = _RecordingClient( + seriesEpisodes: [ + _jfEpisode('sibling-X', seriesId: 'series-B'), + ep2, + _jfEpisode('sibling-Y', seriesId: 'series-B'), + ], + ); + final manager = _StubManager(client); + final aggregation = DataAggregationService(manager); + final serverProvider = MultiServerProvider(manager, aggregation); + addTearDown(serverProvider.dispose); + + AdjacentEpisodes? result; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: playback), + ChangeNotifierProvider.value(value: serverProvider), + ], + child: _ProbeWidget(metadata: ep2, onResult: (r) => result = r), + ), + ); + await tester.pump(); + await tester.pump(); + + // Guard short-circuits before the wire fetch. + expect(client.seriesQueueCalls, isEmpty); + // Queue items unchanged — still the playlist's three episodes. + expect(playback.loadedItems.map((e) => e.id), ['ep1', 'ep2', 'ep3']); + // Prev/next walk the playlist, not the series. + expect(result, isNotNull); + expect(result!.next?.id, 'ep3'); + expect(result!.previous?.id, 'ep1'); + }); }); } diff --git a/test/services/favorite_channels_repository_test.dart b/test/services/favorite_channels_repository_test.dart new file mode 100644 index 00000000..5a792f0d --- /dev/null +++ b/test/services/favorite_channels_repository_test.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/livetv_channel.dart'; +import 'package:plezy/services/favorite_channels_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../test_helpers/prefs.dart'; + +/// Direct tests of the SharedPreferences-backed favorite channels store. +/// `_JellyfinLiveTvSupport` doesn't run in these — the repo is the +/// boundary, so a test here exercises both the legacy-key migration path +/// and the JSON round-trip without spinning up an HTTP layer. +FavoriteChannel _channel(String id, {String? title, String source = 'server://abc/jellyfin'}) => + FavoriteChannel(source: source, id: id, title: title); + +const _key = 'jellyfin_fav_channels:abc/user-1'; +const _legacyKey = 'jellyfin_fav_channels:abc'; + +void main() { + setUp(resetSharedPreferencesForTest); + + group('SharedPreferencesFavoriteChannelsRepository', () { + const repo = SharedPreferencesFavoriteChannelsRepository(); + + test('read returns empty list when neither key is set', () async { + final result = await repo.read(key: _key, legacyKey: _legacyKey); + expect(result, isEmpty); + }); + + test('read parses an existing list at key', () async { + SharedPreferences.setMockInitialValues({ + _key: jsonEncode([ + {'source': 'server://abc/jellyfin', 'id': 'ch-1', 'title': 'Channel 1'}, + {'source': 'server://abc/jellyfin', 'id': 'ch-2', 'title': 'Channel 2'}, + ]), + }); + final result = await repo.read(key: _key, legacyKey: _legacyKey); + expect(result.map((c) => c.id), ['ch-1', 'ch-2']); + expect(result.first.title, 'Channel 1'); + }); + + test('read migrates from the legacy key when primary is absent', () async { + SharedPreferences.setMockInitialValues({ + _legacyKey: jsonEncode([ + {'source': 'server://abc/jellyfin', 'id': 'ch-legacy'}, + ]), + }); + final result = await repo.read(key: _key, legacyKey: _legacyKey); + expect(result, hasLength(1)); + expect(result.first.id, 'ch-legacy'); + + // The migrated value should now live under [_key], and the legacy + // slot should be cleared so a second user reading the same instance + // doesn't inherit it. + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(_key), isNotNull); + expect(prefs.getString(_legacyKey), isNull); + }); + + test('read does not migrate when primary already has a value', () async { + SharedPreferences.setMockInitialValues({ + _key: jsonEncode([ + {'source': 'server://abc/jellyfin', 'id': 'ch-existing'}, + ]), + _legacyKey: jsonEncode([ + {'source': 'server://abc/jellyfin', 'id': 'ch-legacy'}, + ]), + }); + final result = await repo.read(key: _key, legacyKey: _legacyKey); + expect(result.first.id, 'ch-existing'); + // Legacy slot is left intact when not consumed. + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(_legacyKey), isNotNull); + }); + + test('read returns empty list when stored value is malformed', () async { + SharedPreferences.setMockInitialValues({_key: 'not valid json'}); + // jsonDecode will throw — repo's contract is to NOT swallow that. + // (Caller logs and degrades.) Verify the throw happens here. + await expectLater(repo.read(key: _key, legacyKey: _legacyKey), throwsA(isA())); + }); + + test('read returns empty list when stored value is a non-list JSON', () async { + SharedPreferences.setMockInitialValues({_key: '{"not": "a list"}'}); + final result = await repo.read(key: _key, legacyKey: _legacyKey); + expect(result, isEmpty); + }); + + test('write persists the list as JSON under [key]', () async { + await repo.write(_key, [_channel('ch-a', title: 'A'), _channel('ch-b', title: 'B')]); + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + expect(raw, isNotNull); + final decoded = jsonDecode(raw!) as List; + expect(decoded, hasLength(2)); + expect((decoded.first as Map)['id'], 'ch-a'); + }); + + test('write then read round-trips the list verbatim', () async { + final input = [_channel('ch-1', title: 'One'), _channel('ch-2', title: 'Two')]; + await repo.write(_key, input); + final out = await repo.read(key: _key, legacyKey: _legacyKey); + expect(out, hasLength(2)); + expect(out[0].id, 'ch-1'); + expect(out[0].title, 'One'); + expect(out[1].id, 'ch-2'); + expect(out[1].title, 'Two'); + }); + + test('write([]) replaces an existing list with an empty one', () async { + await repo.write(_key, [_channel('ch-1')]); + await repo.write(_key, const []); + final out = await repo.read(key: _key, legacyKey: _legacyKey); + expect(out, isEmpty); + }); + }); +} diff --git a/test/services/file_info_parser_test.dart b/test/services/file_info_parser_test.dart new file mode 100644 index 00000000..11477523 --- /dev/null +++ b/test/services/file_info_parser_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/file_info_parser.dart'; + +/// Unit tests for the backend-agnostic stream walker. Each backend is +/// represented by its own [FileInfoStreamReader] implementation, so the +/// tests fix two things: +/// 1. The walker's accounting (single video pointer, every audio + sub +/// tracked, frame rate captured once). +/// 2. Each reader's mapping from raw JSON to the neutral track classes. +void main() { + group('walkStreams (Plex reader)', () { + const reader = PlexFileInfoStreamReader(); + + test('captures the first video stream and accumulates audio + subs', () { + final streams = [ + // streamType 1=video, 2=audio, 3=subtitle + {'streamType': 1, 'id': 100, 'frameRate': 23.976, 'colorSpace': 'bt709'}, + { + 'streamType': 2, + 'id': 101, + 'index': 1, + 'codec': 'eac3', + 'language': 'English', + 'channels': 6, + 'selected': true, + 'displayTitle': 'English (EAC3 5.1)', + }, + { + 'streamType': 2, + 'id': 102, + 'index': 2, + 'codec': 'aac', + 'language': 'French', + 'channels': 2, + 'selected': false, + }, + { + 'streamType': 3, + 'id': 200, + 'index': 3, + 'codec': 'srt', + 'language': 'English', + 'forced': false, + 'selected': false, + 'key': '/library/streams/200', + }, + ]; + + final out = walkStreams(streams, reader); + + expect(out.videoStream?['id'], 100); + expect(out.audioStream?['id'], 101); + expect(out.frameRate, closeTo(23.976, 1e-6)); + expect(out.audioTracks.map((t) => t.id), [101, 102]); + expect(out.audioTracks[0].channels, 6); + expect(out.audioTracks[0].selected, isTrue); + expect(out.audioTracks[1].selected, isFalse); + expect(out.subtitleTracks, hasLength(1)); + expect(out.subtitleTracks.first.key, '/library/streams/200'); + }); + + test('null and empty inputs short-circuit to FileInfoStreams.empty', () { + expect(identical(walkStreams(null, reader), FileInfoStreams.empty), isTrue); + expect(identical(walkStreams(const [], reader), FileInfoStreams.empty), isTrue); + }); + + test('skips entries with unknown streamType', () { + final streams = [ + {'streamType': 99, 'id': 1}, // unknown + {'streamType': 'audio', 'id': 2}, // wrong type + {'streamType': 1, 'id': 3, 'frameRate': 24}, + ]; + final out = walkStreams(streams, reader); + expect(out.audioTracks, isEmpty); + expect(out.subtitleTracks, isEmpty); + expect(out.videoStream?['id'], 3); + expect(out.frameRate, 24.0); + }); + + test('skips non-Map entries gracefully', () { + final streams = ['not a map', 42, null]; + final out = walkStreams(streams, reader); + expect(out.videoStream, isNull); + expect(out.audioStream, isNull); + expect(out.audioTracks, isEmpty); + expect(out.subtitleTracks, isEmpty); + expect(out.frameRate, isNull); + }); + }); + + group('walkStreams (Jellyfin reader)', () { + const reader = JellyfinFileInfoStreamReader(); + + test('captures the first video stream and accumulates audio + subs', () { + final streams = [ + {'Type': 'Video', 'Index': 0, 'RealFrameRate': 23.976, 'ColorSpace': 'bt709'}, + { + 'Type': 'Audio', + 'Index': 1, + 'Codec': 'eac3', + 'Language': 'eng', + 'Channels': 6, + 'IsDefault': true, + 'DisplayTitle': 'English (EAC3 5.1)', + }, + {'Type': 'Audio', 'Index': 2, 'Codec': 'aac', 'Language': 'fre', 'Channels': 2, 'IsDefault': false}, + {'Type': 'Subtitle', 'Index': 3, 'Codec': 'srt', 'Language': 'eng', 'IsDefault': false, 'IsForced': false}, + ]; + + final out = walkStreams(streams, reader); + + expect(out.videoStream?['Index'], 0); + expect(out.audioStream?['Index'], 1); + expect(out.frameRate, closeTo(23.976, 1e-6)); + expect(out.audioTracks.map((t) => t.id), [1, 2]); + expect(out.audioTracks[0].selected, isTrue); + expect(out.audioTracks[0].languageCode, 'eng'); + expect(out.subtitleTracks, hasLength(1)); + expect(out.subtitleTracks.first.id, 3); + }); + + test('falls back to autoIndex when Index is null', () { + final streams = [ + {'Type': 'Audio', 'Codec': 'aac'}, // no Index + {'Type': 'Audio', 'Index': 7, 'Codec': 'eac3'}, + {'Type': 'Audio', 'Codec': 'opus'}, // no Index + ]; + final out = walkStreams(streams, reader); + // autoIndex is 1-based and increments per audio entry: 1, 2 (overridden by 7), 3. + expect(out.audioTracks.map((t) => t.id), [1, 7, 3]); + }); + + test('frameRateOf falls back to AverageFrameRate when RealFrameRate is null', () { + final streams = [ + {'Type': 'Video', 'AverageFrameRate': 25.0}, + ]; + final out = walkStreams(streams, reader); + expect(out.frameRate, 25.0); + }); + + test('skips streams with unknown Type', () { + final streams = [ + {'Type': 'EmbeddedImage', 'Index': 0}, // unsupported + {'Type': 'audio', 'Codec': 'aac'}, // case-insensitive + ]; + final out = walkStreams(streams, reader); + expect(out.videoStream, isNull); + expect(out.audioTracks, hasLength(1)); + }); + }); + + group('cross-backend equivalence', () { + test('both readers produce parallel track structures from analogous JSON', () { + const plexReader = PlexFileInfoStreamReader(); + const jfReader = JellyfinFileInfoStreamReader(); + + final plexStreams = [ + {'streamType': 1, 'id': 1, 'frameRate': 24.0}, + {'streamType': 2, 'id': 2, 'codec': 'aac', 'language': 'English', 'channels': 2, 'selected': true}, + {'streamType': 3, 'id': 3, 'codec': 'srt', 'language': 'English', 'selected': false, 'forced': false}, + ]; + final jfStreams = [ + {'Type': 'Video', 'Index': 0, 'RealFrameRate': 24.0}, + {'Type': 'Audio', 'Index': 1, 'Codec': 'aac', 'Language': 'eng', 'Channels': 2, 'IsDefault': true}, + {'Type': 'Subtitle', 'Index': 2, 'Codec': 'srt', 'Language': 'eng', 'IsDefault': false, 'IsForced': false}, + ]; + + final plex = walkStreams(plexStreams, plexReader); + final jf = walkStreams(jfStreams, jfReader); + + expect(plex.audioTracks, hasLength(1)); + expect(jf.audioTracks, hasLength(1)); + expect(plex.subtitleTracks, hasLength(1)); + expect(jf.subtitleTracks, hasLength(1)); + expect(plex.frameRate, jf.frameRate); + expect(plex.audioTracks.first.codec, jf.audioTracks.first.codec); + expect(plex.audioTracks.first.channels, jf.audioTracks.first.channels); + expect(plex.audioTracks.first.selected, jf.audioTracks.first.selected); + }); + }); +} diff --git a/test/services/jellyfin_api_cache_test.dart b/test/services/jellyfin_api_cache_test.dart new file mode 100644 index 00000000..abe0f910 --- /dev/null +++ b/test/services/jellyfin_api_cache_test.dart @@ -0,0 +1,317 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/services/credential_vault.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + late AppDatabase db; + late JellyfinApiCache cache; + + setUp(() { + resetSharedPreferencesForTest(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + cache = JellyfinApiCache.instance; + }); + + tearDown(() async { + await db.close(); + }); + + // Minimal Jellyfin BaseItemDto-shaped payload. The mapper only needs Id + + // Type + Name to produce a valid MediaItem; the rest is pass-through. + Map jellyfinItem({String id = 'item-1', String name = 'Hello', String type = 'Movie'}) { + return {'Id': id, 'Type': type, 'Name': name}; + } + + // Insert a Jellyfin connection row with the production-shape id + // (`${machineId}/$userId`) and configJson containing the bare serverName. + Future insertJellyfinConnection({ + required String machineId, + required String userId, + required String serverName, + String accessToken = 'token', + }) async { + await db + .into(db.connections) + .insert( + ConnectionsCompanion.insert( + id: '$machineId/$userId', + kind: 'jellyfin', + displayName: 'someone · $serverName', + configJson: jsonEncode({ + 'baseUrl': 'http://example.lan', + 'serverName': serverName, + 'serverMachineId': machineId, + 'userId': userId, + 'userName': 'someone', + 'accessToken': accessToken, + 'deviceId': 'device', + }), + createdAt: DateTime.now().millisecondsSinceEpoch, + ), + ); + } + + // Cache rows are written by [JellyfinClient] under + // `serverId:/Users/{userId}/Items/{itemId}` — mirror the shape exactly so + // we exercise the same lookup pattern. + Future putItemRow({ + required String serverId, + required String userId, + required String itemId, + Map? data, + bool pinned = false, + }) async { + final payload = data ?? jellyfinItem(id: itemId); + await db + .into(db.apiCache) + .insert( + ApiCacheCompanion.insert( + cacheKey: '$serverId:/Users/$userId/Items/$itemId', + data: jsonEncode(payload), + pinned: Value(pinned), + ), + ); + } + + group('getMetadata', () { + test('resolves serverName via the Jellyfin compound connection id (machineId/userId)', () async { + // Production stores connection rows under `${machineId}/$userId` while + // [JellyfinClient.serverId] returns the bare machineId. The cache + // lookup must reconcile the mismatch — otherwise downloaded Jellyfin + // items lose their metadata after an app reload. + const machineId = 'jf-machine'; + const userId = 'jf-user'; + await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin'); + await putItemRow( + serverId: machineId, + userId: userId, + itemId: 'item-1', + data: jellyfinItem(id: 'item-1', name: 'A Movie'), + ); + + final meta = await cache.getMetadata(machineId, 'item-1'); + expect(meta, isNotNull, reason: 'cache lookup must succeed despite id-format mismatch'); + expect(meta!.title, 'A Movie'); + expect(meta.serverId, machineId); + expect(meta.serverName, 'My Jellyfin', reason: 'serverName is the bare value, not the compound displayName'); + }); + + test('returns null when the connection row is missing', () async { + // Cache row exists but no Connections row → lookup can't resolve serverName. + await putItemRow(serverId: 'orphan', userId: 'u', itemId: 'item-1'); + expect(await cache.getMetadata('orphan', 'item-1'), isNull); + }); + + test('absolutizes image paths against the connection baseUrl + accessToken', () async { + // Regression: cached items used to skip absolutization, leaking raw + // `/Items/.../Images/Primary?tag=...` paths into the download manager + // → Cronet rejected with net::ERR_INVALID_URL. + const machineId = 'jf-machine'; + const userId = 'jf-user'; + await insertJellyfinConnection(machineId: machineId, userId: userId, serverName: 'My Jellyfin'); + await putItemRow( + serverId: machineId, + userId: userId, + itemId: 'item-1', + data: { + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'A Movie', + 'ImageTags': {'Primary': 'tag-abc', 'Logo': 'tag-logo'}, + }, + ); + + final meta = await cache.getMetadata(machineId, 'item-1'); + expect(meta, isNotNull); + expect(meta!.thumbPath, 'http://example.lan/Items/item-1/Images/Primary?tag=tag-abc&api_key=token'); + expect(meta.clearLogoPath, 'http://example.lan/Items/item-1/Images/Logo?tag=tag-logo&api_key=token'); + }); + + test('absolutizes image paths with decrypted accessToken', () async { + const machineId = 'jf-machine'; + const userId = 'jf-user'; + await insertJellyfinConnection( + machineId: machineId, + userId: userId, + serverName: 'My Jellyfin', + accessToken: await CredentialVault.protect('secret-token'), + ); + await putItemRow( + serverId: machineId, + userId: userId, + itemId: 'item-1', + data: { + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'A Movie', + 'ImageTags': {'Primary': 'tag-abc'}, + }, + ); + + final meta = await cache.getMetadata(machineId, 'item-1'); + expect(meta, isNotNull); + expect(meta!.thumbPath, contains('api_key=secret-token')); + expect(meta.thumbPath, isNot(contains('enc:v1:'))); + }); + + test('scopes UserData by Jellyfin compound connection id', () async { + const machineId = 'jf-machine'; + await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); + await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); + await putItemRow( + serverId: '$machineId/user-a', + userId: 'user-a', + itemId: 'item-1', + data: { + ...jellyfinItem(id: 'item-1', name: 'For A'), + 'UserData': {'Played': false, 'PlayCount': 0}, + }, + ); + await putItemRow( + serverId: '$machineId/user-b', + userId: 'user-b', + itemId: 'item-1', + data: { + ...jellyfinItem(id: 'item-1', name: 'For B'), + 'UserData': {'Played': true, 'PlayCount': 1}, + }, + ); + + final a = await cache.getMetadata('$machineId/user-a', 'item-1'); + final b = await cache.getMetadata('$machineId/user-b', 'item-1'); + + expect(a, isNotNull); + expect(b, isNotNull); + expect(a!.serverId, machineId); + expect(a.isWatched, isFalse); + expect(a.title, 'For A'); + expect(b!.serverId, machineId); + expect(b.isWatched, isTrue); + expect(b.title, 'For B'); + }); + }); + + group('getAllPinnedMetadata', () { + test('aggregates pinned items keyed by globalKey across multiple users on the same server', () async { + // Same machineId, two users → two connection rows. Both pin items. + // Both should resolve via the prefix match. + const machineId = 'jf-machine'; + await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); + await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); + + await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-1', pinned: true); + await putItemRow(serverId: machineId, userId: 'user-b', itemId: 'item-2', pinned: true); + // Unpinned row is filtered out. + await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-3'); + + final pinned = await cache.getAllPinnedMetadata(); + expect(pinned.keys.toSet(), {'$machineId:item-1', '$machineId:item-2'}); + expect(pinned['$machineId:item-1']!.serverName, 'Shared JF'); + }); + + test('skips pinned rows whose serverId has no matching connection', () async { + await putItemRow(serverId: 'orphan-machine', userId: 'u', itemId: 'lost', pinned: true); + expect(await cache.getAllPinnedMetadata(), isEmpty); + }); + + test('keeps same-server Jellyfin users addressable by compound pinned keys', () async { + const machineId = 'jf-machine'; + await insertJellyfinConnection(machineId: machineId, userId: 'user-a', serverName: 'Shared JF'); + await insertJellyfinConnection(machineId: machineId, userId: 'user-b', serverName: 'Shared JF'); + await putItemRow( + serverId: '$machineId/user-a', + userId: 'user-a', + itemId: 'item-1', + data: { + ...jellyfinItem(id: 'item-1', name: 'For A'), + 'UserData': {'Played': false, 'PlayCount': 0}, + }, + pinned: true, + ); + await putItemRow( + serverId: '$machineId/user-b', + userId: 'user-b', + itemId: 'item-1', + data: { + ...jellyfinItem(id: 'item-1', name: 'For B'), + 'UserData': {'Played': true, 'PlayCount': 1}, + }, + pinned: true, + ); + + final pinned = await cache.getAllPinnedMetadata(); + expect(pinned['$machineId:item-1'], isNull); + expect(pinned['$machineId/user-a:item-1']!.title, 'For A'); + expect(pinned['$machineId/user-b:item-1']!.title, 'For B'); + expect(pinned['$machineId/user-a:item-1']!.serverId, machineId); + expect(pinned['$machineId/user-b:item-1']!.serverId, machineId); + }); + }); + + group('pinForOffline', () { + test('pins by user-segment wildcard so a single call covers any user', () async { + const machineId = 'jf-machine'; + await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-1'); + await putItemRow(serverId: machineId, userId: 'user-b', itemId: 'item-1'); + await putItemRow(serverId: machineId, userId: 'user-a', itemId: 'item-2'); + + await cache.pinForOffline(machineId, 'item-1'); + + // Both per-user rows for item-1 get pinned, item-2 stays unpinned. + final rows = await db.select(db.apiCache).get(); + final pinnedKeys = rows.where((r) => r.pinned).map((r) => r.cacheKey).toSet(); + expect(pinnedKeys, {'$machineId:/Users/user-a/Items/item-1', '$machineId:/Users/user-b/Items/item-1'}); + }); + + test('pins only the requested compound Jellyfin user scope', () async { + const machineId = 'jf-machine'; + await putItemRow(serverId: '$machineId/user-a', userId: 'user-a', itemId: 'item-1'); + await putItemRow(serverId: '$machineId/user-b', userId: 'user-b', itemId: 'item-1'); + + await cache.pinForOffline('$machineId/user-a', 'item-1'); + + final rows = await db.select(db.apiCache).get(); + final pinnedKeys = rows.where((r) => r.pinned).map((r) => r.cacheKey).toSet(); + expect(pinnedKeys, {'$machineId/user-a:/Users/user-a/Items/item-1'}); + }); + }); + + group('applyWatchState', () { + test('mutates only the requested compound Jellyfin user scope', () async { + const machineId = 'jf-machine'; + await putItemRow( + serverId: '$machineId/user-a', + userId: 'user-a', + itemId: 'item-1', + data: { + ...jellyfinItem(id: 'item-1'), + 'UserData': {'Played': false, 'PlayCount': 0}, + }, + ); + await putItemRow( + serverId: '$machineId/user-b', + userId: 'user-b', + itemId: 'item-1', + data: { + ...jellyfinItem(id: 'item-1'), + 'UserData': {'Played': false, 'PlayCount': 0}, + }, + ); + + await cache.applyWatchState(serverId: '$machineId/user-a', itemId: 'item-1', isWatched: true); + + final rows = await db.select(db.apiCache).get(); + final byKey = {for (final row in rows) row.cacheKey: jsonDecode(row.data) as Map}; + expect((byKey['$machineId/user-a:/Users/user-a/Items/item-1']!['UserData'] as Map)['Played'], isTrue); + expect((byKey['$machineId/user-b:/Users/user-b/Items/item-1']!['UserData'] as Map)['Played'], isFalse); + }); + }); +} diff --git a/test/services/jellyfin_auth_service_test.dart b/test/services/jellyfin_auth_service_test.dart new file mode 100644 index 00000000..558a98d6 --- /dev/null +++ b/test/services/jellyfin_auth_service_test.dart @@ -0,0 +1,553 @@ +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/exceptions/media_server_exceptions.dart'; +import 'package:plezy/services/jellyfin_auth_service.dart'; +import 'package:plezy/utils/log_redaction_manager.dart'; + +/// Helpers for stubbing http responses keyed by request path. +typedef _Handler = http.Response Function(http.BaseRequest req); + +http.Response _ok(Object json) => http.Response(jsonEncode(json), 200, headers: {'content-type': 'application/json'}); +http.Response _bareOk(String body) => http.Response(body, 200, headers: {'content-type': 'application/json'}); +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'}) => JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jf.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: accessToken, + deviceId: 'dev-xyz', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +JellyfinConnectionAuthService _service({required _Handler handler}) { + return JellyfinConnectionAuthService( + clientName: 'Plezy', + clientVersion: 'test', + deviceName: 'TestDevice', + testHttpClientFactory: () => MockClient((req) async => handler(req)), + ); +} + +void main() { + setUp(LogRedactionManager.clearTrackedValues); + tearDown(LogRedactionManager.clearTrackedValues); + + group('JellyfinConnectionAuthService.probe', () { + test('returns server info on a well-formed /System/Info/Public response', () async { + final svc = _service( + handler: (req) { + expect(req.url.path, '/System/Info/Public'); + expect(req.method, 'GET'); + return _ok({'Id': 'srv-1', 'ServerName': 'Home', 'Version': '10.9.0'}); + }, + ); + + final info = await svc.probe('https://jf.example.com/'); + expect(info.serverName, 'Home'); + expect(info.machineId, 'srv-1'); + expect(info.version, '10.9.0'); + }); + + test('falls back to LocalAddress when ServerName is absent', () async { + final svc = _service(handler: (_) => _ok({'Id': 'srv-1', 'LocalAddress': 'http://192.168.1.10:8096'})); + final info = await svc.probe('https://jf.example.com'); + expect(info.serverName, 'http://192.168.1.10:8096'); + }); + + test('throws MediaServerUrlException when payload is not JSON', () async { + final svc = _service(handler: (_) => http.Response('plain text', 200)); + await expectLater(svc.probe('https://jf.example.com'), throwsA(isA())); + }); + + test('throws MediaServerUrlException when payload is missing Id/ServerName', () async { + final svc = _service(handler: (_) => _ok({'Version': '10.9.0'})); + await expectLater(svc.probe('https://jf.example.com'), throwsA(isA())); + }); + + test('throws MediaServerUrlException on transport HTTP error', () async { + final svc = _service(handler: (_) => _status(500, {'error': 'oops'})); + await expectLater(svc.probe('https://jf.example.com'), throwsA(isA())); + }); + + test('registers base URL redaction before the first probe request', () async { + final svc = _service( + handler: (req) { + final redacted = LogRedactionManager.redact(req.url.toString()); + expect(redacted, isNot(contains('private-jellyfin.example.com'))); + return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + }, + ); + + await svc.probe('https://private-jellyfin.example.com'); + }); + }); + + group('JellyfinConnectionAuthService.authenticateByName', () { + test('returns a JellyfinConnection on success', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') { + return _ok({'Id': 'srv-1', 'ServerName': 'Home', 'Version': '10.9.0'}); + } + if (req.url.path == '/Users/AuthenticateByName') { + expect(req.method, 'POST'); + return _ok({ + 'AccessToken': 'tok-new', + 'User': {'Id': 'user-7', 'Name': 'edde'}, + }); + } + return _status(404); + }, + ); + + final conn = await svc.authenticateByName( + baseUrl: 'https://jf.example.com', + username: 'edde', + password: 'pw', + deviceId: 'dev-xyz', + ); + expect(conn.accessToken, 'tok-new'); + expect(conn.userId, 'user-7'); + expect(conn.userName, 'edde'); + expect(conn.serverMachineId, 'srv-1'); + // Composite id keeps multi-user-per-server unambiguous. + expect(conn.id, 'srv-1/user-7'); + }); + + test('throws MediaServerAuthException on 401', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') { + return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + } + return _status(401); + }, + ); + + await expectLater( + svc.authenticateByName( + baseUrl: 'https://jf.example.com', + username: 'edde', + password: 'wrong', + deviceId: 'dev-xyz', + ), + throwsA(isA()), + ); + }); + + test('throws MediaServerAuthException on malformed JSON 401', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') { + return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + } + return http.Response('{bad json', 401, headers: {'content-type': 'application/json'}); + }, + ); + + await expectLater( + svc.authenticateByName( + baseUrl: 'https://jf.example.com', + username: 'edde', + password: 'wrong', + deviceId: 'dev-xyz', + ), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 401)), + ); + }); + + test('throws MediaServerAuthException when AccessToken is missing', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') { + return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + } + return _ok({ + 'User': {'Id': 'user-7', 'Name': 'edde'}, + }); + }, + ); + + await expectLater( + svc.authenticateByName( + baseUrl: 'https://jf.example.com', + username: 'edde', + password: 'pw', + deviceId: 'dev-xyz', + ), + throwsA(isA()), + ); + }); + }); + + group('JellyfinConnectionAuthService.isQuickConnectEnabled', () { + test('returns true when the server replies with bare `true`', () async { + final svc = _service( + handler: (req) { + expect(req.url.path, '/QuickConnect/Enabled'); + return _bareOk('true'); + }, + ); + expect(await svc.isQuickConnectEnabled('https://jf.example.com'), isTrue); + }); + + test('returns false when the server replies with bare `false`', () async { + final svc = _service(handler: (_) => _bareOk('false')); + expect(await svc.isQuickConnectEnabled('https://jf.example.com'), isFalse); + }); + + test('returns false on 404 (Jellyfin <10.7)', () async { + final svc = _service(handler: (_) => _status(404)); + expect(await svc.isQuickConnectEnabled('https://jf.example.com'), isFalse); + }); + + test('returns false on transport error', () async { + final svc = JellyfinConnectionAuthService( + clientName: 'Plezy', + clientVersion: 'test', + deviceName: 'TestDevice', + testHttpClientFactory: () => MockClient((_) async => throw http.ClientException('network down')), + ); + expect(await svc.isQuickConnectEnabled('https://jf.example.com'), isFalse); + }); + }); + + group('JellyfinConnectionAuthService.initiateQuickConnect', () { + test('returns code/secret on a successful GET', () async { + final svc = _service( + handler: (req) { + expect(req.url.path, '/QuickConnect/Initiate'); + expect(req.method, 'GET'); + return _ok({'Code': 'ABCDE', 'Secret': 'sec-xyz'}); + }, + ); + + final qc = await svc.initiateQuickConnect(baseUrl: 'https://jf.example.com', deviceId: 'dev-xyz'); + expect(qc.code, 'ABCDE'); + expect(qc.secret, 'sec-xyz'); + }); + + test('falls back to POST on 405', () async { + var sawGet = false; + final svc = _service( + handler: (req) { + expect(req.url.path, '/QuickConnect/Initiate'); + if (req.method == 'GET') { + sawGet = true; + return _status(405); + } + expect(req.method, 'POST'); + return _ok({'Code': 'ABCDE', 'Secret': 'sec-xyz'}); + }, + ); + + final qc = await svc.initiateQuickConnect(baseUrl: 'https://jf.example.com', deviceId: 'dev-xyz'); + expect(sawGet, isTrue); + expect(qc.code, 'ABCDE'); + }); + + test('throws MediaServerAuthException on 401/403', () async { + final svc = _service(handler: (_) => _status(403)); + await expectLater( + svc.initiateQuickConnect(baseUrl: 'https://jf.example.com', deviceId: 'dev-xyz'), + throwsA(isA()), + ); + }); + + test('throws MediaServerAuthException on malformed JSON 401', () async { + final svc = _service( + handler: (_) => http.Response('{bad json', 401, headers: {'content-type': 'application/json'}), + ); + + await expectLater( + svc.initiateQuickConnect(baseUrl: 'https://jf.example.com', deviceId: 'dev-xyz'), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 401)), + ); + }); + }); + + group('JellyfinConnectionAuthService.authenticateByQuickConnect', () { + test('returns a JellyfinConnection after the user approves', () async { + var pollCount = 0; + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') { + return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + } + if (req.url.path == '/QuickConnect/Connect') { + pollCount++; + // Return Authenticated=true on second poll. + return _ok({'Authenticated': pollCount >= 2}); + } + if (req.url.path == '/Users/AuthenticateWithQuickConnect') { + return _ok({ + 'AccessToken': 'tok-qc', + 'User': {'Id': 'user-9', 'Name': 'edde'}, + }); + } + return _status(404); + }, + ); + + final conn = await svc.authenticateByQuickConnect( + baseUrl: 'https://jf.example.com', + secret: 'sec', + deviceId: 'dev-xyz', + timeout: const Duration(seconds: 30), + ); + expect(conn, isNotNull); + expect(conn!.accessToken, 'tok-qc'); + expect(conn.userId, 'user-9'); + }); + + test('returns null when secret expires server-side (404 mid-poll)', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + if (req.url.path == '/QuickConnect/Connect') return _status(404); + return _status(500); + }, + ); + + final conn = await svc.authenticateByQuickConnect( + baseUrl: 'https://jf.example.com', + secret: 'sec', + deviceId: 'dev-xyz', + timeout: const Duration(seconds: 5), + ); + expect(conn, isNull); + }); + + test('returns null when malformed JSON 404 happens mid-poll', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + if (req.url.path == '/QuickConnect/Connect') { + return http.Response('{bad json', 404, headers: {'content-type': 'application/json'}); + } + return _status(500); + }, + ); + + final conn = await svc.authenticateByQuickConnect( + baseUrl: 'https://jf.example.com', + secret: 'sec', + deviceId: 'dev-xyz', + timeout: const Duration(seconds: 5), + ); + expect(conn, isNull); + }); + + test('returns null when shouldCancel becomes true', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + return _ok({'Authenticated': false}); + }, + ); + + final conn = await svc.authenticateByQuickConnect( + baseUrl: 'https://jf.example.com', + secret: 'sec', + deviceId: 'dev-xyz', + timeout: const Duration(seconds: 30), + shouldCancel: () => true, + ); + expect(conn, isNull); + }); + + test('throws MediaServerAuthException when poll returns 401', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + return _status(401); + }, + ); + + await expectLater( + svc.authenticateByQuickConnect( + baseUrl: 'https://jf.example.com', + secret: 'sec', + deviceId: 'dev-xyz', + timeout: const Duration(seconds: 5), + ), + throwsA(isA()), + ); + }); + + test('throws MediaServerAuthException when malformed JSON poll returns 401', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + return http.Response('{bad json', 401, headers: {'content-type': 'application/json'}); + }, + ); + + await expectLater( + svc.authenticateByQuickConnect( + baseUrl: 'https://jf.example.com', + secret: 'sec', + deviceId: 'dev-xyz', + timeout: const Duration(seconds: 5), + ), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 401)), + ); + }); + + test('throws MediaServerAuthException when exchange returns 400', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + if (req.url.path == '/QuickConnect/Connect') return _ok({'Authenticated': true}); + if (req.url.path == '/Users/AuthenticateWithQuickConnect') return _status(400); + return _status(500); + }, + ); + + await expectLater( + svc.authenticateByQuickConnect( + baseUrl: 'https://jf.example.com', + secret: 'sec', + deviceId: 'dev-xyz', + timeout: const Duration(seconds: 5), + ), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 400)), + ); + }); + + test('throws MediaServerAuthException when malformed JSON exchange returns 400', () async { + final svc = _service( + handler: (req) { + if (req.url.path == '/System/Info/Public') return _ok({'Id': 'srv-1', 'ServerName': 'Home'}); + if (req.url.path == '/QuickConnect/Connect') return _ok({'Authenticated': true}); + if (req.url.path == '/Users/AuthenticateWithQuickConnect') { + return http.Response('{bad json', 400, headers: {'content-type': 'application/json'}); + } + return _status(500); + }, + ); + + await expectLater( + svc.authenticateByQuickConnect( + baseUrl: 'https://jf.example.com', + secret: 'sec', + deviceId: 'dev-xyz', + timeout: const Duration(seconds: 5), + ), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 400)), + ); + }); + }); + + group('JellyfinConnectionAuthService.validate', () { + test('returns true when /Users/Me responds 200', () async { + final svc = _service( + handler: (req) { + expect(req.url.path, '/Users/Me'); + return _ok({'Id': 'user-1'}); + }, + ); + + expect(await svc.validate(_existingConn()), isTrue); + }); + + test('returns false on 401/403', () async { + final svc = _service(handler: (_) => _status(401)); + expect(await svc.validate(_existingConn()), isFalse); + }); + + test('returns false for non-Jellyfin connections', () async { + final svc = _service(handler: (_) => _ok({})); + // Use a Plex connection placeholder (any non-Jellyfin Connection works). + final notJellyfin = PlexAccountConnection( + id: 'plex-1', + accountToken: 'tok', + clientIdentifier: 'cid', + accountLabel: 'Plex', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + expect(await svc.validate(notJellyfin), isFalse); + }); + }); + + group('JellyfinConnectionAuthService.refresh', () { + test('returns connection with online status + lastAuthenticatedAt on success', () async { + final svc = _service(handler: (_) => _ok({'Id': 'user-1'})); + final refreshed = await svc.refresh(_existingConn()); + expect(refreshed, isA()); + expect((refreshed as JellyfinConnection).status, ConnectionStatus.online); + expect(refreshed.lastAuthenticatedAt, isNotNull); + }); + + test('returns connection with authError status when validate fails', () async { + final svc = _service(handler: (_) => _status(401)); + final refreshed = await svc.refresh(_existingConn()); + expect((refreshed as JellyfinConnection).status, ConnectionStatus.authError); + }); + + test('returns the same Connection unchanged for non-Jellyfin', () async { + final svc = _service(handler: (_) => _ok({})); + final plex = PlexAccountConnection( + id: 'plex-1', + accountToken: 'tok', + clientIdentifier: 'cid', + accountLabel: 'Plex', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + expect(await svc.refresh(plex), same(plex)); + }); + }); + + group('JellyfinConnectionAuthService.signOut', () { + test('fires POST /Sessions/Logout against the right base URL', () async { + var sawLogout = false; + final svc = _service( + handler: (req) { + if (req.url.path == '/Sessions/Logout') { + sawLogout = true; + expect(req.method, 'POST'); + return _ok({}); + } + return _status(404); + }, + ); + + await svc.signOut(_existingConn()); + expect(sawLogout, isTrue); + }); + + test('does not throw when the server fails (best-effort)', () async { + final svc = _service(handler: (_) => _status(500)); + await svc.signOut(_existingConn()); // expect: no throw + }); + + test('is a no-op for non-Jellyfin connections', () async { + var fired = false; + final svc = _service( + handler: (_) { + fired = true; + return _ok({}); + }, + ); + + final plex = PlexAccountConnection( + id: 'plex-1', + accountToken: 'tok', + clientIdentifier: 'cid', + accountLabel: 'Plex', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + await svc.signOut(plex); + expect(fired, isFalse); + }); + }); +} diff --git a/test/services/jellyfin_client_failures_test.dart b/test/services/jellyfin_client_failures_test.dart new file mode 100644 index 00000000..1b04bda6 --- /dev/null +++ b/test/services/jellyfin_client_failures_test.dart @@ -0,0 +1,139 @@ +import 'package:drift/native.dart'; +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/database/app_database.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/jellyfin_client.dart'; + +JellyfinConnection _conn() => JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jf.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: 'tok-abc', + deviceId: 'dev-xyz', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +JellyfinClient _withMock(MockClient mock) => JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + +/// Failure-path coverage for the Jellyfin HTTP layer. +/// +/// The original test suite covered the 200-OK happy paths and a single 404 +/// (handled inside `fetchItem`). Anything else — auth rejection, server +/// errors, malformed JSON — was untested. These cases are the exact shapes +/// that surface in the field when a Jellyfin server is mid-update or the +/// access token has been revoked, so they're worth pinning. +void main() { + // fetchChildren writes through `JellyfinApiCache.instance` on a + // successful 200, so the singleton needs to exist for tests that exercise + // that path. fetchItem's failure paths short-circuit before any cache + // write but we initialise unconditionally for symmetry. + late AppDatabase db; + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + }); + tearDown(() async { + await db.close(); + }); + + group('JellyfinClient.fetchItem failure modes', () { + test('404 returns null (item not on server)', () async { + final client = _withMock(MockClient((_) async => http.Response('', 404))); + expect(await client.fetchItem('missing'), isNull); + client.close(); + }); + + // Auth and server errors must throw — silently returning null on a + // revoked token would let the UI render stale cached state and report + // "no metadata" instead of "you're signed out". 404 is the only + // non-2xx that's still allowed to collapse to null (item genuinely + // doesn't exist on the server). + test('401 throws MediaServerHttpException', () async { + final client = _withMock(MockClient((_) async => http.Response('Unauthorized', 401))); + await expectLater(client.fetchItem('any'), throwsA(isA())); + client.close(); + }); + + test('403 throws MediaServerHttpException', () async { + final client = _withMock(MockClient((_) async => http.Response('Forbidden', 403))); + await expectLater(client.fetchItem('any'), throwsA(isA())); + client.close(); + }); + + test('500 throws MediaServerHttpException', () async { + final client = _withMock(MockClient((_) async => http.Response('Internal error', 500))); + await expectLater(client.fetchItem('any'), throwsA(isA())); + client.close(); + }); + + test('200 with malformed JSON returns null without throwing', () async { + // The HTTP wrapper falls back to raw text when JSON decoding fails; + // `fetchItem` then sees a non-Map payload and returns null. Confirms + // the parser doesn't blow up the caller on a server that suddenly + // returns HTML (e.g. a reverse proxy 200 page). + final client = _withMock( + MockClient((_) async => http.Response('oops', 200, headers: {'content-type': 'text/html'})), + ); + expect(await client.fetchItem('any'), isNull); + client.close(); + }); + + test('200 with empty body returns null', () async { + final client = _withMock(MockClient((_) async => http.Response('', 200))); + expect(await client.fetchItem('any'), isNull); + client.close(); + }); + }); + + group('JellyfinClient.fetchChildren failure modes', () { + test('any /Seasons failure (incl. 500) falls through to /Items, which propagates', () async { + // The current implementation catches *every* MediaServerHttpException + // from /Shows/{id}/Seasons and falls through to /Items. The /Items + // call's failure is what the caller sees. This pins that contract: + // both endpoints are reached, and the error from /Items wins. + var seasonsHit = false; + var itemsHit = false; + final client = _withMock( + MockClient((req) async { + if (req.url.path.endsWith('/Seasons')) { + seasonsHit = true; + return http.Response('boom', 500); + } + if (req.url.path == '/Items') { + itemsHit = true; + return http.Response('boom', 500); + } + return http.Response('unexpected', 500); + }), + ); + await expectLater(client.fetchChildren('parent'), throwsA(isA())); + expect(seasonsHit, isTrue); + expect(itemsHit, isTrue); + client.close(); + }); + + test('404 on /Seasons falls through to /Items (non-series item)', () async { + var seenItems = false; + final client = _withMock( + MockClient((req) async { + if (req.url.path.endsWith('/Seasons')) { + return http.Response('not found', 404); + } + seenItems = req.url.path == '/Items'; + return http.Response('{"Items": []}', 200, headers: {'content-type': 'application/json'}); + }), + ); + final children = await client.fetchChildren('parent'); + expect(children, isEmpty); + expect(seenItems, isTrue); + client.close(); + }); + }); +} diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart new file mode 100644 index 00000000..da9849fa --- /dev/null +++ b/test/services/jellyfin_client_urls_test.dart @@ -0,0 +1,892 @@ +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/models/transcode_quality_preset.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/playback_initialization_types.dart'; + +JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) => + JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: baseUrl, + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: accessToken, + deviceId: 'dev-xyz', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + +/// URL-builder smoke tests. We can't unit-test a network round-trip without +/// spinning up a Jellyfin server, but the URL shape is a clear unit-of-work: +/// query parameters must include the right keys and the auth token. These +/// tests pin the contract so the next iteration of the player (Task 8 wiring) +/// has something to point at. +void main() { + group('JellyfinClient URL builders', () { + late JellyfinClient client; + + setUp(() async { + client = await JellyfinClient.create(_conn()); + }); + + tearDown(() { + client.close(); + }); + + test('buildDirectStreamUrl includes static flag, api_key, and device id', () { + final url = client.buildDirectStreamUrl('item-99'); + final uri = Uri.parse(url); + + expect(uri.scheme, 'https'); + expect(uri.host, 'jf.example.com'); + expect(uri.path, '/Videos/item-99/stream'); + expect(uri.queryParameters['Static'], 'true'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + expect(uri.queryParameters['DeviceId'], 'dev-xyz'); + expect(uri.queryParameters.containsKey('Container'), isFalse); + }); + + test('buildDirectStreamUrl appends Container when provided', () { + final url = client.buildDirectStreamUrl('item-99', container: 'mp4'); + expect(Uri.parse(url).queryParameters['Container'], 'mp4'); + }); + + test('buildDirectStreamUrl appends MediaSourceId when provided', () { + // Items with multiple `MediaSources` need this param to disambiguate; + // without it Jellyfin defaults to the primary source even if the URL's + // {itemId} matches a non-primary. + final url = client.buildDirectStreamUrl('item-99', mediaSourceId: 'src-2'); + expect(Uri.parse(url).queryParameters['MediaSourceId'], 'src-2'); + }); + + test('buildDirectStreamUrl omits MediaSourceId by default', () { + final url = client.buildDirectStreamUrl('item-99'); + expect(Uri.parse(url).queryParameters.containsKey('MediaSourceId'), isFalse); + }); + + test('buildDirectStreamUrl path-encodes reserved item id characters', () { + final url = client.buildDirectStreamUrl('folder/item #1?x'); + expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/stream'); + }); + + test('buildHlsStreamUrl wires required + optional params', () { + final url = client.buildHlsStreamUrl( + 'item-42', + mediaSourceId: 'src-1', + videoBitrate: 5000000, + audioStreamIndex: 1, + subtitleStreamIndex: 2, + playSessionId: 'sess-01', + ); + final uri = Uri.parse(url); + + expect(uri.path, '/Videos/item-42/master.m3u8'); + expect(uri.queryParameters['MediaSourceId'], 'src-1'); + expect(uri.queryParameters['VideoBitrate'], '5000000'); + expect(uri.queryParameters['AudioStreamIndex'], '1'); + expect(uri.queryParameters['SubtitleStreamIndex'], '2'); + expect(uri.queryParameters['PlaySessionId'], 'sess-01'); + expect(uri.queryParameters['DeviceId'], 'dev-xyz'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('buildHlsStreamUrl omits null optional params', () { + final url = client.buildHlsStreamUrl('item-42', mediaSourceId: 'src-1'); + final uri = Uri.parse(url); + + expect(uri.queryParameters['MediaSourceId'], 'src-1'); + expect(uri.queryParameters.containsKey('VideoBitrate'), isFalse); + expect(uri.queryParameters.containsKey('AudioStreamIndex'), isFalse); + expect(uri.queryParameters.containsKey('SubtitleStreamIndex'), isFalse); + expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse); + }); + + test('buildHlsStreamUrl path-encodes reserved item id characters', () { + final url = client.buildHlsStreamUrl('folder/item #1?x', mediaSourceId: 'src-1'); + expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/master.m3u8'); + }); + + test('reportPlaybackProgress sends media source and stream indexes', () async { + Uri? capturedUri; + String? capturedBody; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + capturedUri = request.url; + capturedBody = request.body; + return http.Response('', 204); + }), + ); + addTearDown(scoped.close); + + await scoped.reportPlaybackProgress( + itemId: 'item-1', + position: const Duration(seconds: 12), + duration: const Duration(seconds: 100), + isPaused: true, + playSessionId: 'play-1', + playMethod: 'Transcode', + mediaSourceId: 'source-1', + audioStreamIndex: 2, + subtitleStreamIndex: -1, + ); + + expect(capturedUri!.path, '/Sessions/Playing/Progress'); + final body = jsonDecode(capturedBody!) as Map; + expect(body['ItemId'], 'item-1'); + expect(body['MediaSourceId'], 'source-1'); + expect(body['AudioStreamIndex'], 2); + expect(body['SubtitleStreamIndex'], -1); + expect(body['PlaySessionId'], 'play-1'); + expect(body['PlayMethod'], 'Transcode'); + expect(body['IsPaused'], isTrue); + }); + + test('resolveDownload pins direct stream URL to selected media source', () async { + final requests = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requests.add(request.url); + if (request.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + {'Id': 'src-2', 'Container': 'mkv', 'MediaStreams': []}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'src-1', 'MediaStreams': []}, + {'Id': 'src-2', 'MediaStreams': []}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final resolution = await scoped.resolveDownload( + MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + mediaIndex: 1, + ); + + final uri = Uri.parse(resolution.videoUrl!); + expect(uri.queryParameters['MediaSourceId'], 'src-2'); + expect(uri.queryParameters['Container'], 'mkv'); + expect(requests.map((u) => u.path), contains('/Items/item-1/PlaybackInfo')); + }); + + test('getPlaybackInitialization preserves PlaySessionId from TranscodingUrl', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'MediaSources': [ + { + 'Id': 'src-1', + 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1', + 'MediaStreams': [], + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + expect(result.isTranscoding, isTrue); + expect(result.playMethod, 'Transcode'); + expect(result.playSessionId, 'play-session-1'); + final uri = Uri.parse(result.videoUrl!); + expect(uri.queryParameters['PlaySessionId'], 'play-session-1'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('getPlaybackInitialization uses negotiated DirectStreamUrl when transcode URL is absent', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + expect(result.isTranscoding, isFalse); + expect(result.playMethod, 'DirectStream'); + expect(result.fallbackReason, isNull); + expect(result.playSessionId, 'play-session-direct'); + final uri = Uri.parse(result.videoUrl!); + expect(uri.path, '/Videos/item-1/stream'); + expect(uri.queryParameters['PlaySessionId'], 'play-session-direct'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('getPlaybackInfo path-encodes reserved item id characters', () async { + Uri? capturedUri; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}); + }), + ); + addTearDown(scoped.close); + + await scoped.getPlaybackInfo('folder/item #1?x'); + + expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo')); + }); + + test('path-encodes reserved ids for browse and watch-state endpoints', () async { + final captured = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + captured.add(request.url); + return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + }), + ); + addTearDown(scoped.close); + + final item = MediaItem( + id: 'folder/item #1?x', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv-1', + ); + + try { + await scoped.fetchChildren('folder/show #1?x'); + } catch (_) { + // This URL-only test does not initialize JellyfinApiCache; fetchChildren + // may fail after the request when it tries to cache the mock response. + } + await scoped.fetchClientSideEpisodeQueue('folder/show #1?x'); + await scoped.markWatched(item); + await scoped.markUnwatched(item); + await scoped.removeFromContinueWatching(item); + await scoped.rate(item, 7); + await scoped.rate(item, -1); + + final paths = captured.map((u) => u.path).toList(); + expect(paths, contains('/Shows/folder%2Fshow%20%231%3Fx/Seasons')); + expect(paths, contains('/Shows/folder%2Fshow%20%231%3Fx/Episodes')); + expect(paths, contains('/UserPlayedItems/folder%2Fitem%20%231%3Fx')); + expect(paths.where((p) => p == '/UserPlayedItems/folder%2Fitem%20%231%3Fx'), hasLength(2)); + expect(paths, contains('/UserItems/folder%2Fitem%20%231%3Fx/HideFromResume')); + expect(paths.where((p) => p == '/UserItems/folder%2Fitem%20%231%3Fx/Rating'), hasLength(2)); + }); + + test('getPlaybackInitialization URL-encodes appended api_key', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(accessToken: 'tok+with spaces/?&'), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/Items/item-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'MediaSources': [ + {'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + expect(result.videoUrl, contains('api_key=tok%2Bwith+spaces%2F%3F%26')); + expect(Uri.parse(result.videoUrl!).queryParameters['api_key'], 'tok+with spaces/?&'); + }); + + test('getPlaybackInitialization builds fallback URL for external subtitle without DeliveryUrl', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsExternal': true}, + ], + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 0, + ), + ); + + expect(result.externalSubtitles, hasLength(1)); + expect(result.playMethod, 'DirectPlay'); + final uri = Uri.parse(result.externalSubtitles.single.uri!); + expect(uri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('live TV stream resolution negotiates PlaybackInfo and preserves PlaySessionId', () async { + final requests = []; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requests.add(request.url); + if (request.url.path == '/Items/channel-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'PlaySessionId': 'live-session-1', + 'MediaSources': [ + {'Id': 'source-1', 'TranscodingUrl': '/Videos/channel-1/master.m3u8?PlaySessionId=live-session-1'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final resolution = await scoped.liveTv.resolveStreamUrl('channel-1'); + + expect(requests.single.path, '/Items/channel-1/PlaybackInfo'); + expect(resolution, isNotNull); + expect(resolution!.playSessionId, 'live-session-1'); + final uri = Uri.parse(resolution.url); + expect(uri.path, '/Videos/channel-1/master.m3u8'); + expect(uri.queryParameters['PlaySessionId'], 'live-session-1'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () { + final url = client.buildTrickplayTileUrl('item-99', 320, 4); + final uri = Uri.parse(url); + + expect(uri.scheme, 'https'); + expect(uri.host, 'jf.example.com'); + expect(uri.path, '/Videos/item-99/Trickplay/320/4.jpg'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + expect(uri.queryParameters['DeviceId'], 'dev-xyz'); + expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse); + }); + + test('buildTrickplayTileUrl appends MediaSourceId when provided', () { + // Multi-source items need the param; without it Jellyfin returns the + // primary source's tiles even if the user picked a non-default version. + final url = client.buildTrickplayTileUrl('item-99', 320, 0, mediaSourceId: 'src-2'); + expect(Uri.parse(url).queryParameters['MediaSourceId'], 'src-2'); + }); + + test('buildTrickplayTileUrl URL-encodes special chars in itemId', () { + final url = client.buildTrickplayTileUrl('item with spaces & chars', 160, 1); + // Path segments are encoded once; the `+` form for spaces is also + // valid per RFC 3986 — Uri.parse normalizes back to the original. + expect(url, contains('/Videos/item%20with%20spaces%20%26%20chars/Trickplay/160/1.jpg')); + }); + + test('thumbnailUrl resolves a relative path against baseUrl with api_key', () { + final url = client.thumbnailUrl('/Items/item-99/Images/Primary?tag=abc'); + final uri = Uri.parse(url); + expect(uri.scheme, 'https'); + expect(uri.host, 'jf.example.com'); + expect(uri.path, '/Items/item-99/Images/Primary'); + expect(uri.queryParameters['tag'], 'abc'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('thumbnailUrl preserves reverse-proxy subpaths for relative artwork paths', () { + final proxied = JellyfinClient.forTesting( + connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + addTearDown(proxied.close); + + final url = proxied.thumbnailUrl('/Items/item-99/Images/Primary?tag=abc'); + final uri = Uri.parse(url); + + expect(uri.path, '/jellyfin/Items/item-99/Images/Primary'); + expect(uri.queryParameters['tag'], 'abc'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('negotiated bare relative DirectStreamUrl preserves reverse-proxy subpaths', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), + httpClient: MockClient((request) async { + if (request.url.path == '/jellyfin/Users/user-1/Items/item-1') { + return http.Response( + jsonEncode({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/jellyfin/Items/item-1/PlaybackInfo') { + return http.Response( + jsonEncode({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'DirectStreamUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('{}', 404); + }), + ); + addTearDown(scoped.close); + + final result = await scoped.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.p720_2mbps, + ), + ); + + final uri = Uri.parse(result.videoUrl!); + expect(uri.path, '/jellyfin/Videos/item-1/stream'); + expect(uri.queryParameters['PlaySessionId'], 'play-session-direct'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('thumbnailUrl honours width/height hints', () { + final url = client.thumbnailUrl('/Items/x/Images/Primary', width: 200, height: 300); + final uri = Uri.parse(url); + expect(uri.queryParameters['maxWidth'], '200'); + expect(uri.queryParameters['maxHeight'], '300'); + }); + + test('thumbnailUrl does not prefix already absolute artwork URLs', () { + final url = client.thumbnailUrl('https://jf.example.com/Items/x/Images/Primary?tag=abc', width: 200); + final uri = Uri.parse(url); + expect(uri.scheme, 'https'); + expect(uri.host, 'jf.example.com'); + expect(uri.path, '/Items/x/Images/Primary'); + expect(url, isNot(contains('https://jf.example.comhttps://jf.example.com'))); + expect(uri.queryParameters['tag'], 'abc'); + expect(uri.queryParameters['maxWidth'], '200'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('thumbnailUrl preserves existing auth and size parameters', () { + final url = client.thumbnailUrl( + 'https://other.example/Items/x/Images/Primary?api_key=existing&maxWidth=100', + width: 200, + height: 300, + ); + final uri = Uri.parse(url); + expect(uri.host, 'other.example'); + expect(uri.queryParameters['api_key'], 'existing'); + expect(uri.queryParameters['maxWidth'], '100'); + expect(uri.queryParameters['maxHeight'], '300'); + }); + + test('thumbnailUrl returns empty string for null/empty path', () { + expect(client.thumbnailUrl(null), ''); + expect(client.thumbnailUrl(''), ''); + }); + + test('every request carries the SDK-style MediaBrowser Authorization header', () { + // Findroid + the official Jellyfin SDK send this exact header shape. + // Some setups (Jellyfin 10.9+ behind reverse proxies) reject requests + // that only carry the legacy X-Emby-Token header, returning a 404 from + // the proxy/routing layer instead of a 401. We send both. + final headers = client.defaultHeadersForTesting; + + final auth = headers['Authorization']; + expect(auth, isNotNull); + expect(auth, startsWith('MediaBrowser ')); + expect(auth, contains('Client="Plezy"')); + expect(auth, contains('Device="Plezy"')); + expect(auth, contains('DeviceId="dev-xyz"')); + expect(auth, contains(RegExp(r'Version="[^"]+"'))); + expect(auth, contains('Token="tok-abc"')); + + // Belt-and-suspenders: legacy Emby token header is still present for + // older servers that prefer it. + expect(headers['X-Emby-Token'], 'tok-abc'); + expect(headers['Accept'], 'application/json'); + }); + + test('fetchClientSideEpisodeQueue pages past the first 200 episodes', () async { + final starts = []; + final pagedClient = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + starts.add(req.url.queryParameters['StartIndex']); + final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); + const total = 250; + final end = (start + 200).clamp(0, total); + final items = [ + for (var i = start; i < end; i++) + { + 'Id': 'ep-$i', + 'Type': 'Episode', + 'Name': 'Episode $i', + 'SeriesId': 'show-1', + 'UserData': {'PlayCount': 0}, + }, + ]; + return http.Response( + jsonEncode({'Items': items, 'TotalRecordCount': total}), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(pagedClient.close); + + final result = await pagedClient.fetchClientSideEpisodeQueue('show-1'); + + expect(result, hasLength(250)); + expect(starts, ['0', '200']); + }); + }); + + group('JellyfinClient.fetchMoreHubItems URL builders', () { + Uri? captured; + + JellyfinClient buildClient() { + captured = null; + final mock = MockClient((req) async { + captured = req.url; + return http.Response('[]', 200, headers: {'content-type': 'application/json'}); + }); + return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + } + + test('global "home.recent" hits /Users/{userId}/Items/Latest with provided limit', () async { + final client = buildClient(); + await client.fetchMoreHubItems('home.recent', limit: 80); + + expect(captured, isNotNull); + expect(captured!.path, '/Users/user-1/Items/Latest'); + expect(captured!.queryParameters['Limit'], '80'); + expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode'); + expect(captured!.queryParameters.containsKey('ParentId'), isFalse); + client.close(); + }); + + test('global "home.continue" hits /UserItems/Resume with userId', () async { + final client = buildClient(); + await client.fetchMoreHubItems('home.continue'); + + expect(captured, isNotNull); + expect(captured!.path, '/UserItems/Resume'); + expect(captured!.queryParameters['userId'], 'user-1'); + expect(captured!.queryParameters['Limit'], '50'); + expect(captured!.queryParameters['MediaTypes'], 'Video'); + expect(captured!.queryParameters.containsKey('ParentId'), isFalse); + client.close(); + }); + + test('global "home.nextup" hits /Shows/NextUp with userId', () async { + final client = buildClient(); + await client.fetchMoreHubItems('home.nextup', limit: 25); + + expect(captured, isNotNull); + expect(captured!.path, '/Shows/NextUp'); + expect(captured!.queryParameters['userId'], 'user-1'); + expect(captured!.queryParameters['Limit'], '25'); + expect(captured!.queryParameters.containsKey('ParentId'), isFalse); + client.close(); + }); + + test('library-scoped "library.{id}.recent" forwards ParentId to Latest', () async { + final client = buildClient(); + await client.fetchMoreHubItems('library.lib-99.recent', limit: 30); + + expect(captured, isNotNull); + expect(captured!.path, '/Users/user-1/Items/Latest'); + expect(captured!.queryParameters['ParentId'], 'lib-99'); + expect(captured!.queryParameters['Limit'], '30'); + // ParentId-scoped Latest should NOT also pin IncludeItemTypes (the + // library already constrains the kinds returned). + expect(captured!.queryParameters.containsKey('IncludeItemTypes'), isFalse); + client.close(); + }); + + test('library-scoped "library.{id}.continue" forwards ParentId to Resume', () async { + final client = buildClient(); + await client.fetchMoreHubItems('library.lib-99.continue'); + + expect(captured, isNotNull); + expect(captured!.path, '/UserItems/Resume'); + expect(captured!.queryParameters['ParentId'], 'lib-99'); + expect(captured!.queryParameters['userId'], 'user-1'); + client.close(); + }); + + test('library-scoped "library.{id}.nextup" forwards ParentId to NextUp', () async { + final client = buildClient(); + await client.fetchMoreHubItems('library.lib-99.nextup'); + + expect(captured, isNotNull); + expect(captured!.path, '/Shows/NextUp'); + expect(captured!.queryParameters['ParentId'], 'lib-99'); + expect(captured!.queryParameters['userId'], 'user-1'); + client.close(); + }); + + test('unknown identifier returns empty without hitting the network', () async { + final client = buildClient(); + final items = await client.fetchMoreHubItems('totally.unknown'); + + expect(items, isEmpty); + expect(captured, isNull); + client.close(); + }); + }); + + group('JellyfinClient.fetchLibraries view filtering', () { + test('drops boxsets and playlists views — they surface as per-library tabs instead', () async { + // Jellyfin's `/Users/{userId}/Views` returns the user's collection + // (BoxSet) and playlist roots as top-level "library" views. Surfacing + // them in the library list duplicates content that's already exposed as + // tabs on each real library, matching the Plex shape. + final mock = MockClient((req) async { + if (req.url.path == '/Users/user-1/Views') { + return http.Response( + ''' + { + "Items": [ + {"Id": "lib-movies", "Name": "Movies", "CollectionType": "movies", "Type": "CollectionFolder"}, + {"Id": "lib-shows", "Name": "TV Shows", "CollectionType": "tvshows", "Type": "CollectionFolder"}, + {"Id": "lib-music", "Name": "Music", "CollectionType": "music", "Type": "CollectionFolder"}, + {"Id": "lib-coll", "Name": "Collections", "CollectionType": "boxsets", "Type": "CollectionFolder"}, + {"Id": "lib-pl", "Name": "Playlists", "CollectionType": "playlists", "Type": "ManualPlaylistsFolder"} + ] + } + ''', + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + + final libraries = await client.fetchLibraries(); + + expect(libraries.map((l) => l.id), ['lib-movies', 'lib-shows', 'lib-music']); + client.close(); + }); + }); + + group('JellyfinClient.fetchPlaylists filtering', () { + JellyfinClient buildClient() { + final mock = MockClient((req) async { + if (req.url.path == '/Items') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'video-1', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'}, + {'Id': 'audio-1', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'}, + {'Id': 'photo-1', 'Name': 'Photo Playlist', 'Type': 'Playlist', 'MediaType': 'Photo'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + } + + test('returns only requested playlist media type', () async { + final client = buildClient(); + + final playlists = await client.fetchPlaylists(playlistType: 'video'); + + expect(playlists.map((p) => p.id), ['video-1']); + client.close(); + }); + + test('absolutizes playlist thumbnail artwork with reverse-proxy subpath', () async { + final mock = MockClient((req) async { + if (req.url.path == '/jellyfin/Items') { + return http.Response( + jsonEncode({ + 'Items': [ + { + 'Id': 'video-1', + 'Name': 'Video Playlist', + 'Type': 'Playlist', + 'MediaType': 'Video', + 'ImageTags': {'Primary': 'tag 1'}, + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + final client = JellyfinClient.forTesting( + connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), + httpClient: mock, + ); + addTearDown(client.close); + + final playlists = await client.fetchPlaylists(playlistType: 'video'); + final uri = Uri.parse(playlists.single.thumbPath!); + + expect(uri.path, '/jellyfin/Items/video-1/Images/Primary'); + expect(uri.queryParameters['tag'], 'tag 1'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('smart=true returns empty because Jellyfin playlists are normal playlists', () async { + final client = buildClient(); + + final playlists = await client.fetchPlaylists(playlistType: 'video', smart: true); + + expect(playlists, isEmpty); + client.close(); + }); + }); +} diff --git a/test/services/jellyfin_favorites_isolation_test.dart b/test/services/jellyfin_favorites_isolation_test.dart new file mode 100644 index 00000000..febcae4a --- /dev/null +++ b/test/services/jellyfin_favorites_isolation_test.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/models/livetv_channel.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../test_helpers/prefs.dart'; + +JellyfinConnection _conn({required String userId}) => JellyfinConnection( + id: 'srv-shared/$userId', + baseUrl: 'https://jf.example.com', + serverName: 'Shared JF', + serverMachineId: 'srv-shared', + userId: userId, + userName: 'user-$userId', + accessToken: 'tok-$userId', + deviceId: 'dev-$userId', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +JellyfinClient _client(JellyfinConnection conn) => JellyfinClient.forTesting( + connection: conn, + // Favorites read path is local-only; an http stub that always 500s is + // fine since fetchFavoriteChannels never hits it. + httpClient: MockClient((_) async => throw StateError('no HTTP expected')), +); + +String _favKey(JellyfinConnection conn) => 'jellyfin_fav_channels:${conn.id}'; +String _legacyFavKey(JellyfinConnection conn) => 'jellyfin_fav_channels:${conn.serverMachineId}'; + +String _encodeFavorites(List favs) => jsonEncode(favs.map((f) => f.toJson()).toList()); + +FavoriteChannel _ch(String id, {String? title}) => + FavoriteChannel(id: id, title: title ?? id, source: 'server://srv-shared/jellyfin'); + +void main() { + setUp(resetSharedPreferencesForTest); + + group('Jellyfin favorites isolation across users on the same server', () { + test('two users on the same server have independent favorite lists', () async { + final connA = _conn(userId: 'user-a'); + final connB = _conn(userId: 'user-b'); + + // Pre-seed user A's favorites under the compound key, leave user B empty. + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_favKey(connA), _encodeFavorites([_ch('chan-a1'), _ch('chan-a2')])); + + final aFavs = await _client(connA).liveTv.fetchFavoriteChannels(); + final bFavs = await _client(connB).liveTv.fetchFavoriteChannels(); + + expect(aFavs.map((f) => f.id).toList(), ['chan-a1', 'chan-a2']); + expect(bFavs, isEmpty, reason: 'user B must not see user A\'s favorites'); + }); + + test('legacy bare-machineId key migrates into the first user\'s compound slot', () async { + final connA = _conn(userId: 'user-a'); + + // Pre-seed legacy entry only. + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_legacyFavKey(connA), _encodeFavorites([_ch('legacy-1')])); + + final aFavs = await _client(connA).liveTv.fetchFavoriteChannels(); + expect(aFavs.map((f) => f.id).toList(), ['legacy-1']); + + // Migration: legacy key gone, compound key written. + expect(prefs.getString(_legacyFavKey(connA)), isNull); + expect(prefs.getString(_favKey(connA)), isNotNull); + }); + }); +} diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart new file mode 100644 index 00000000..b353fdf4 --- /dev/null +++ b/test/services/jellyfin_mappers_test.dart @@ -0,0 +1,403 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_stream.dart'; +import 'package:plezy/services/jellyfin_mappers.dart'; + +const _serverId = 'jf-machine-1'; + +void main() { + group('JellyfinMappers.mediaItem', () { + test('maps a movie with watch state, ratings, genres, and people', () { + final json = { + 'Id': 'abc123', + 'Name': 'Inception', + 'OriginalTitle': 'Inception', + 'Type': 'Movie', + 'Overview': 'Dream within a dream.', + 'Taglines': ['Your mind is the scene of the crime.'], + 'ProductionYear': 2010, + 'PremiereDate': '2010-07-16T00:00:00.0000000Z', + 'OfficialRating': 'PG-13', + 'CommunityRating': 8.8, + 'Genres': ['Action', 'Sci-Fi'], + 'People': [ + {'Type': 'Actor', 'Name': 'Leo', 'Id': 'p1', 'PrimaryImageTag': 'tag1', 'Role': 'Cobb'}, + {'Type': 'Director', 'Name': 'Christopher Nolan'}, + ], + 'Studios': [ + {'Name': 'Warner Bros'}, + ], + 'ProductionLocations': ['United States'], + 'RunTimeTicks': 88800000000, // 8880 sec * 10_000_000 + 'UserData': { + 'PlayCount': 1, + 'PlaybackPositionTicks': 30000000000, // 3000 sec + 'Played': true, + 'LastPlayedDate': '2026-04-25T20:00:00.0000000Z', + }, + 'DateCreated': '2025-01-15T10:00:00.0000000Z', + 'DateLastSaved': '2026-03-01T10:00:00.0000000Z', + 'ImageTags': {'Primary': 'thumbtag', 'Logo': 'logotag'}, + 'BackdropImageTags': ['backtag'], + }; + + final item = JellyfinMappers.mediaItem(json, serverId: _serverId, serverName: 'Home', absolutizer: null)!; + + expect(item.id, 'abc123'); + expect(item.backend, MediaBackend.jellyfin); + expect(item.kind, MediaKind.movie); + expect(item.title, 'Inception'); + expect(item.summary, 'Dream within a dream.'); + expect(item.tagline, 'Your mind is the scene of the crime.'); + expect(item.year, 2010); + expect(item.originallyAvailableAt, '2010-07-16'); + expect(item.contentRating, 'PG-13'); + expect(item.studio, 'Warner Bros'); + expect(item.rating, 8.8); + expect(item.genres, ['Action', 'Sci-Fi']); + expect(item.directors, ['Christopher Nolan']); + expect(item.countries, ['United States']); + expect(item.roles, isNotNull); + expect(item.roles!.length, 1); + expect(item.roles![0].tag, 'Leo'); + expect(item.roles![0].role, 'Cobb'); + expect(item.roles![0].thumbPath, '/Items/p1/Images/Primary?tag=tag1'); + + // Tick conversion: 100ns ticks → ms. + expect(item.durationMs, 8880000); // 8880s in ms + expect(item.viewOffsetMs, 3000000); // 3000s in ms + expect(item.viewCount, 1); + + // Image paths. + expect(item.thumbPath, '/Items/abc123/Images/Primary?tag=thumbtag'); + expect(item.artPath, '/Items/abc123/Images/Backdrop/0?tag=backtag'); + expect(item.clearLogoPath, '/Items/abc123/Images/Logo?tag=logotag'); + + // Multi-server fields. + expect(item.serverId, _serverId); + expect(item.serverName, 'Home'); + }); + + test('episode preserves series/season hierarchy', () { + final json = { + 'Id': 'ep1', + 'Name': 'Pilot', + 'Type': 'Episode', + 'IndexNumber': 1, + 'ParentIndexNumber': 1, + 'SeriesId': 'series-1', + 'SeriesName': 'Breaking Bad', + 'SeriesPrimaryImageTag': 'seriesPrimary', + 'SeasonId': 'season-1', + 'SeasonName': 'Season 1', + 'SeasonPrimaryImageTag': 'seasonPrimary', + 'UserData': {'UnplayedItemCount': 0}, + }; + + final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + + expect(item.kind, MediaKind.episode); + expect(item.index, 1); + expect(item.parentIndex, 1); + expect(item.parentId, 'season-1'); + expect(item.parentTitle, 'Season 1'); + expect(item.parentThumbPath, '/Items/season-1/Images/Primary?tag=seasonPrimary'); + expect(item.grandparentId, 'series-1'); + expect(item.grandparentTitle, 'Breaking Bad'); + expect(item.grandparentThumbPath, '/Items/series-1/Images/Primary?tag=seriesPrimary'); + expect(item.grandparentArtPath, '/Items/series-1/Images/Backdrop/0'); + }); + + test('series viewedLeafCount derived from total - UnplayedItemCount', () { + final json = { + 'Id': 's1', + 'Name': 'Show', + 'Type': 'Series', + 'ChildCount': 12, + 'RecursiveItemCount': 12, + 'UserData': {'UnplayedItemCount': 4}, + }; + + final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + + expect(item.leafCount, 12); + expect(item.viewedLeafCount, 8); + expect(item.isPartiallyWatched, isTrue); + expect(item.isWatched, isFalse); + }); + + test('path-encodes image ids and tag query values', () { + final item = JellyfinMappers.mediaItem( + { + 'Id': 'folder/item #1?x', + 'Type': 'Episode', + 'Name': 'Reserved IDs', + 'SeriesId': 'series/id #1?x', + 'SeriesPrimaryImageTag': 'series/tag ?x', + 'ParentLogoItemId': 'logo/id #1?x', + 'ParentLogoImageTag': 'logo/tag ?x', + 'ImageTags': {'Primary': 'primary/tag ?x'}, + 'People': [ + {'Type': 'Actor', 'Name': 'Actor', 'Id': 'person/id #1?x', 'PrimaryImageTag': 'person/tag ?x'}, + ], + }, + serverId: _serverId, + absolutizer: null, + )!; + + expect(item.thumbPath, '/Items/folder%2Fitem%20%231%3Fx/Images/Primary?tag=primary%2Ftag%20%3Fx'); + expect(item.grandparentThumbPath, '/Items/series%2Fid%20%231%3Fx/Images/Primary?tag=series%2Ftag%20%3Fx'); + expect(item.clearLogoPath, '/Items/logo%2Fid%20%231%3Fx/Images/Logo?tag=logo%2Ftag%20%3Fx'); + expect(item.roles!.single.thumbPath, '/Items/person%2Fid%20%231%3Fx/Images/Primary?tag=person%2Ftag%20%3Fx'); + }); + + test('series leafCount uses RecursiveItemCount over ChildCount', () { + // Realistic Jellyfin shape for a Series: ChildCount = season count, + // RecursiveItemCount = total episode count. Plex `leafCount` semantics + // are leaves (episodes), so we must prefer the recursive total or the + // unwatched badge ends up showing seasons instead of episodes. + final json = { + 'Id': 's2', + 'Name': 'Show with seasons', + 'Type': 'Series', + 'ChildCount': 4, // 4 seasons + 'RecursiveItemCount': 50, // 50 episodes + 'UserData': {'UnplayedItemCount': 7}, + }; + + final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + + expect(item.leafCount, 50); + expect(item.viewedLeafCount, 43); + }); + + test('media versions map MediaSources + MediaStreams faithfully', () { + final json = { + 'Id': 'movie-1', + 'Name': 'Movie', + 'Type': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'Bitrate': 8000000, + 'Size': 10737418240, + 'RunTimeTicks': 60000000000, + 'MediaStreams': [ + { + 'Index': 0, + 'Type': 'Video', + 'Codec': 'h264', + 'IsDefault': true, + 'RealFrameRate': 23.976, + 'Width': 1920, + 'Height': 1080, + }, + { + 'Index': 1, + 'Type': 'Audio', + 'Codec': 'eac3', + 'Language': 'eng', + 'DisplayLanguage': 'English', + 'Channels': 6, + 'IsDefault': true, + }, + { + 'Index': 2, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'IsExternal': true, + 'IsForced': true, + 'DeliveryUrl': '/Videos/movie-1/movie-1/Subtitles/2/Stream.srt', + }, + ], + }, + ], + }; + + final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + expect(item.mediaVersions, isNotNull); + final v = item.mediaVersions!.single; + expect(v.id, 'src-1'); + expect(v.width, 1920); + expect(v.height, 1080); + expect(v.videoResolution, '1080'); + expect(v.videoCodec, 'h264'); + // Jellyfin's `Bitrate` (8 Mbps in bps) is converted to kbps to match + // MediaVersion.bitrate's contract (and Plex's encoding). + expect(v.bitrate, 8000); + expect(v.container, 'mkv'); + + final part = v.parts.single; + expect(part.id, 'src-1'); + expect(part.streamPath, '/Videos/src-1/stream'); + expect(part.sizeBytes, 10737418240); + expect(part.durationMs, 6000000); // 6000s + + final video = part.streams.firstWhere((s) => s.kind == MediaStreamKind.video); + expect(video.codec, 'h264'); + expect(video.frameRate, closeTo(23.976, 0.001)); + expect(video.selected, isTrue); + + final audio = part.streams.firstWhere((s) => s.kind == MediaStreamKind.audio); + expect(audio.codec, 'eac3'); + expect(audio.channels, 6); + expect(audio.languageCode, 'eng'); + + final subtitle = part.streams.firstWhere((s) => s.kind == MediaStreamKind.subtitle); + expect(subtitle.forced, isTrue); + expect(subtitle.isExternal, isTrue); + expect(subtitle.sidecarPath, '/Videos/movie-1/movie-1/Subtitles/2/Stream.srt'); + }); + }); + + group('JellyfinMappers.library', () { + test('translates Jellyfin CollectionType to neutral MediaKind', () { + final cases = { + 'movies': MediaKind.movie, + 'tvshows': MediaKind.show, + 'music': MediaKind.artist, + 'photos': MediaKind.photo, + 'boxsets': MediaKind.collection, + }; + for (final entry in cases.entries) { + final lib = JellyfinMappers.library({ + 'Id': 'view-${entry.key}', + 'Name': 'Library', + 'CollectionType': entry.key, + }, serverId: _serverId)!; + expect(lib.kind, entry.value, reason: 'CollectionType ${entry.key}'); + expect(lib.backend, MediaBackend.jellyfin); + } + }); + + test('falls back to MediaKind.unknown for unrecognised collections', () { + final lib = JellyfinMappers.library({ + 'Id': 'view-x', + 'Name': 'Mixed', + 'CollectionType': 'mixed', + }, serverId: _serverId)!; + expect(lib.kind, MediaKind.unknown); + }); + }); + + // Past regression: a Jellyfin server can omit any of these fields when + // the item is freshly created or the user has restricted permissions. + // Confirms the mapper degrades gracefully — none of these inputs should + // throw and every output field should have a sane fallback. + group('JellyfinMappers.mediaItem null-tolerance', () { + test('minimal payload (just Id + Type) yields a MediaItem with sane defaults', () { + final item = JellyfinMappers.mediaItem( + {'Id': 'bare-1', 'Type': 'Movie'}, + serverId: _serverId, + serverName: 'Home', + absolutizer: null, + )!; + expect(item.id, 'bare-1'); + expect(item.kind, MediaKind.movie); + expect(item.summary, isNull); + expect(item.year, isNull); + expect(item.isWatched, isFalse); + // Optional list fields can be null OR empty — both are sane. + expect(item.genres, anyOf(isNull, isEmpty)); + expect(item.directors, anyOf(isNull, isEmpty)); + }); + + test('missing UserData leaves watch state nullable without throwing', () { + final item = JellyfinMappers.mediaItem( + {'Id': 'i', 'Type': 'Movie', 'Name': 'X'}, + serverId: _serverId, + absolutizer: null, + )!; + // Either 0 or null is acceptable as long as we don't crash. + expect(item.viewCount, anyOf(isNull, 0)); + expect(item.viewOffsetMs, isNull); + expect(item.lastViewedAt, isNull); + }); + + test('null People array does not crash', () { + final item = JellyfinMappers.mediaItem( + {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'People': null}, + serverId: _serverId, + absolutizer: null, + )!; + expect(item.directors, anyOf(isNull, isEmpty)); + expect(item.writers, anyOf(isNull, isEmpty)); + expect(item.roles, anyOf(isNull, isEmpty)); + }); + + test('null Genres / Studios / ProductionLocations degrade gracefully', () { + final item = JellyfinMappers.mediaItem( + {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'Genres': null, 'Studios': null, 'ProductionLocations': null}, + serverId: _serverId, + absolutizer: null, + )!; + expect(item.genres, anyOf(isNull, isEmpty)); + expect(item.studio, isNull); + expect(item.countries, anyOf(isNull, isEmpty)); + }); + + test('malformed RunTimeTicks does not throw — duration left null', () { + final item = JellyfinMappers.mediaItem( + {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'RunTimeTicks': 'not-a-number'}, + serverId: _serverId, + absolutizer: null, + )!; + expect(item.durationMs, isNull); + }); + + test('null MediaSources does not crash', () { + final item = JellyfinMappers.mediaItem( + {'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'MediaSources': null}, + serverId: _serverId, + absolutizer: null, + )!; + expect(item.mediaVersions, anyOf(isNull, isEmpty)); + }); + }); + + group('JellyfinMappers.mediaItem missing-Id rejection', () { + test('returns null when Id is absent', () { + expect( + JellyfinMappers.mediaItem({'Type': 'Movie', 'Name': 'noId'}, serverId: _serverId, absolutizer: null), + isNull, + ); + }); + + test('returns null when Id is empty string', () { + expect( + JellyfinMappers.mediaItem( + {'Id': '', 'Type': 'Movie', 'Name': 'emptyId'}, + serverId: _serverId, + absolutizer: null, + ), + isNull, + ); + }); + + test('drops MediaSources entries with missing Id', () { + final item = JellyfinMappers.mediaItem( + { + 'Id': 'movie-x', + 'Type': 'Movie', + 'MediaSources': [ + {'Container': 'mkv', 'Bitrate': 8000000, 'MediaStreams': []}, + {'Id': 'src-ok', 'Container': 'mp4', 'Bitrate': 4000000, 'MediaStreams': []}, + ], + }, + serverId: _serverId, + absolutizer: null, + )!; + expect(item.mediaVersions!.length, 1); + expect(item.mediaVersions!.single.id, 'src-ok'); + }); + }); + + group('JellyfinMappers.library missing-Id rejection', () { + test('returns null when Id is absent', () { + expect(JellyfinMappers.library({'Name': 'Library', 'CollectionType': 'movies'}, serverId: _serverId), isNull); + }); + }); +} diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart new file mode 100644 index 00000000..b833211a --- /dev/null +++ b/test/services/jellyfin_media_info_test.dart @@ -0,0 +1,376 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/jellyfin_media_info_mapper.dart'; + +/// Field-mapping pin for the Jellyfin → Plex `MediaInfo` translator. The +/// player's track picker and auto-track-selection both consume the +/// resulting [MediaAudioTrack] / [MediaSubtitleTrack] lists, so the field +/// shape needs to be stable across iterations. +void main() { + group('jellyfinMediaSourceToMediaSourceInfo', () { + test('maps audio + subtitle streams and preserves Jellyfin default selection hints', () { + final source = { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'RealFrameRate': 23.976}, + { + 'Index': 1, + 'Type': 'Audio', + 'Codec': 'eac3', + 'Language': 'eng', + 'DisplayLanguage': 'English', + 'Title': 'Surround 5.1', + 'DisplayTitle': 'English (EAC3 5.1)', + 'Channels': 6, + 'IsDefault': true, + }, + { + 'Index': 2, + 'Type': 'Audio', + 'Codec': 'aac', + 'Language': 'jpn', + 'DisplayLanguage': 'Japanese', + 'Channels': 2, + 'IsDefault': false, + }, + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayLanguage': 'English', + 'IsDefault': false, + 'IsForced': true, + 'IsExternal': true, + 'DeliveryUrl': '/Videos/src-1/Subtitles/3/Stream.srt', + }, + ], + }; + + final info = jellyfinMediaSourceToMediaSourceInfo(source); + + expect(info.audioTracks.length, 2); + expect(info.subtitleTracks.length, 1); + expect(info.frameRate, closeTo(23.976, 0.001)); + // Plex partId is null on Jellyfin because Jellyfin persists selected + // stream indexes through playback progress reports instead. + expect(info.getPartId(), isNull); + + // Jellyfin exposes the default server choice through IsDefault. + final eng = info.audioTracks[0]; + expect(eng.id, 1); + expect(eng.index, 1); + expect(eng.codec, 'eac3'); + expect(eng.language, 'English'); + expect(eng.languageCode, 'eng'); + expect(eng.title, 'Surround 5.1'); + expect(eng.displayTitle, 'English (EAC3 5.1)'); + expect(eng.channels, 6); + expect(eng.selected, isTrue); + + // Non-default audio + final jpn = info.audioTracks[1]; + expect(jpn.id, 2); + expect(jpn.languageCode, 'jpn'); + expect(jpn.selected, isFalse); + + // Subtitle, external + forced + final sub = info.subtitleTracks.single; + expect(sub.id, 3); + expect(sub.codec, 'srt'); + expect(sub.languageCode, 'eng'); + expect(sub.forced, isTrue); + expect(sub.selected, isTrue); + expect(sub.isExternal, isTrue); + expect(sub.key, '/Videos/src-1/Subtitles/3/Stream.srt'); + }); + + test('handles missing MediaStreams gracefully', () { + final info = jellyfinMediaSourceToMediaSourceInfo({'Id': 'x'}); + expect(info.audioTracks, isEmpty); + expect(info.subtitleTracks, isEmpty); + expect(info.frameRate, isNull); + }); + + test('uses Jellyfin default stream indexes over per-stream default flags', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'Id': 'src-1', + 'DefaultAudioStreamIndex': 2, + 'DefaultSubtitleStreamIndex': -1, + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + {'Index': 1, 'Type': 'Audio', 'Language': 'eng', 'IsDefault': true}, + {'Index': 2, 'Type': 'Audio', 'Language': 'jpn', 'IsDefault': false}, + {'Index': 3, 'Type': 'Subtitle', 'Language': 'eng', 'IsDefault': true}, + ], + }); + + expect(info.audioTracks.map((t) => t.selected), [false, true]); + expect(info.subtitleTracks.single.selected, isFalse); + expect(info.defaultAudioStreamIndex, 2); + expect(info.defaultSubtitleStreamIndex, -1); + }); + + test('selects subtitle matching Jellyfin default stream index', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'Id': 'src-1', + 'DefaultSubtitleStreamIndex': 4, + 'MediaStreams': [ + {'Index': 3, 'Type': 'Subtitle', 'Language': 'eng', 'IsDefault': true}, + {'Index': 4, 'Type': 'Subtitle', 'Language': 'jpn', 'IsDefault': false}, + ], + }); + + expect(info.subtitleTracks.map((track) => track.selected), [false, true]); + expect(info.defaultSubtitleStreamIndex, 4); + }); + + test('falls back to Language when DisplayLanguage absent', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'MediaStreams': [ + {'Index': 0, 'Type': 'Audio', 'Language': 'fra'}, + ], + }); + expect(info.audioTracks.single.language, 'fra'); + expect(info.audioTracks.single.languageCode, 'fra'); + }); + + test('embedded subtitle (IsExternal=false) leaves key null', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'MediaStreams': [ + {'Index': 0, 'Type': 'Subtitle', 'IsExternal': false, 'DeliveryUrl': '/should-be-ignored'}, + ], + }); + expect(info.subtitleTracks.single.key, isNull); + expect(info.subtitleTracks.single.isExternal, isFalse); + }); + + test('external subtitle without DeliveryUrl remains external for URL fallback', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'MediaStreams': [ + {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'IsExternal': true}, + ], + }); + final sub = info.subtitleTracks.single; + expect(sub.key, isNull); + expect(sub.isExternal, isTrue); + }); + + test('captures mediaSourceId from source Id field', () { + final info = jellyfinMediaSourceToMediaSourceInfo({'Id': 'src-abc', 'MediaStreams': []}); + expect(info.mediaSourceId, 'src-abc'); + }); + + test('parses flat trickplay manifest (per OpenAPI shape)', () { + // BaseItemDto.Trickplay shape per Jellyfin OpenAPI: keys are resolution + // widths (often strings in the JSON), values are TrickplayInfoDto. + final info = jellyfinMediaSourceToMediaSourceInfo( + {'Id': 'src-1', 'MediaStreams': []}, + trickplay: { + '320': { + 'Width': 320, + 'Height': 180, + 'TileWidth': 10, + 'TileHeight': 10, + 'ThumbnailCount': 250, + 'Interval': 10000, + 'Bandwidth': 500000, + }, + }, + ); + + final t = info.trickplayByWidth?[320]; + expect(t, isNotNull); + expect(t!.width, 320); + expect(t.height, 180); + expect(t.tileWidth, 10); + expect(t.tileHeight, 10); + expect(t.thumbnailCount, 250); + expect(t.interval, 10000); + expect(t.bandwidth, 500000); + }); + + test('parses nested-by-source trickplay manifest (Streamyfin shape)', () { + // Some Jellyfin variants serialise Trickplay as + // `{: {: TrickplayInfoDto}}`. The mapper picks + // the inner map matching the chosen source. + final info = jellyfinMediaSourceToMediaSourceInfo( + {'Id': 'src-2', 'MediaStreams': []}, + trickplay: { + 'src-1': {'160': _info(width: 160, height: 90, tw: 8, th: 8, count: 64, interval: 10000)}, + 'src-2': {'320': _info(width: 320, height: 180, tw: 8, th: 8, count: 64, interval: 10000)}, + }, + ); + + expect(info.trickplayByWidth?.keys.toList(), [320]); + expect(info.trickplayByWidth![320]!.width, 320); + }); + + test('falls back to first nested entry when source id not present as key', () { + final info = jellyfinMediaSourceToMediaSourceInfo( + {'Id': 'unknown', 'MediaStreams': []}, + trickplay: { + 'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)}, + }, + ); + expect(info.trickplayByWidth?.keys.single, 160); + }); + + test('returns null trickplayByWidth when manifest missing', () { + final info = jellyfinMediaSourceToMediaSourceInfo({'Id': 'x', 'MediaStreams': []}); + expect(info.trickplayByWidth, isNull); + }); + + test('skips malformed trickplay entries (missing required ints)', () { + final info = jellyfinMediaSourceToMediaSourceInfo( + {'Id': 'x', 'MediaStreams': []}, + trickplay: { + '320': { + // missing Height + 'Width': 320, + 'TileWidth': 10, + 'TileHeight': 10, + 'ThumbnailCount': 50, + 'Interval': 10000, + }, + '160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000), + }, + ); + expect(info.trickplayByWidth?.keys.toList(), [160]); + }); + + test('coerces numeric-string resolution keys', () { + final info = jellyfinMediaSourceToMediaSourceInfo( + {'Id': 'x', 'MediaStreams': []}, + trickplay: {'320': _info(width: 320, height: 180, tw: 4, th: 4, count: 16, interval: 10000)}, + ); + expect(info.trickplayByWidth?.containsKey(320), isTrue); + }); + }); + + group('jellyfinSourcesToVersions', () { + test('reads resolution + codec from the video stream when source omits them', () { + // The list endpoint returns Width/Height as null on MediaSource; the + // detail endpoint may include them on the stream only. Either way the + // version label needs a real number. + final versions = jellyfinSourcesToVersions([ + { + 'Id': 'src-1', + 'Name': 'Movie (2024)', + 'Container': 'mkv', + 'Bitrate': 5000000, + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'hevc', 'Width': 3840, 'Height': 2160}, + {'Type': 'Audio', 'Codec': 'eac3'}, + ], + }, + ]); + expect(versions, hasLength(1)); + expect(versions.single.parts.single.streamPath, 'src-1'); + expect(versions.single.videoResolution, '4k'); + expect(versions.single.videoCodec, 'hevc'); + expect(versions.single.container, 'mkv'); + expect(versions.single.width, 3840); + expect(versions.single.height, 2160); + // Single-source items have Name == item title; suppress to avoid + // redundant prefixes in the picker. + expect(versions.single.name, isNull); + }); + + test('forwards distinct Names so the picker can disambiguate equal-spec versions', () { + final versions = jellyfinSourcesToVersions([ + { + 'Id': 'src-theatrical', + 'Name': 'Theatrical Cut', + 'Container': 'mkv', + 'Bitrate': 8000000, + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-directors', + 'Name': "Director's Cut", + 'Container': 'mkv', + 'Bitrate': 12000000, + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + ]); + expect(versions, hasLength(2)); + expect(versions[0].name, 'Theatrical Cut'); + expect(versions[1].name, "Director's Cut"); + expect(versions[0].parts.single.streamPath, 'src-theatrical'); + expect(versions[1].parts.single.streamPath, 'src-directors'); + // displayLabel prefixes the name for disambiguation. + expect(versions[0].displayLabel, contains('Theatrical Cut')); + expect(versions[0].displayLabel, contains('1080')); + }); + + test('drops Name when all sources share it (typical single-version case)', () { + final versions = jellyfinSourcesToVersions([ + { + 'Id': 'a', + 'Name': 'Movie (2024)', + 'Container': 'mkv', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'h264', 'Height': 720, 'Width': 1280}, + ], + }, + { + 'Id': 'b', + 'Name': 'Movie (2024)', + 'Container': 'mp4', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'hevc', 'Height': 1080, 'Width': 1920}, + ], + }, + ]); + expect(versions[0].name, isNull); + expect(versions[1].name, isNull); + expect(versions[0].videoResolution, '720'); + expect(versions[1].videoResolution, '1080'); + }); + + test('handles missing MediaStreams + missing Height gracefully', () { + final versions = jellyfinSourcesToVersions([ + {'Id': 'x', 'Name': 'X', 'Container': 'mkv'}, + ]); + expect(versions, hasLength(1)); + expect(versions.single.videoResolution, isNull); + expect(versions.single.videoCodec, isNull); + expect(versions.single.height, isNull); + }); + }); + + group('jellyfinPlaybackExtrasFromRaw', () { + test('path-encodes chapter thumbnail item id and image tag', () { + final extras = jellyfinPlaybackExtrasFromRaw({ + 'Chapters': [ + {'StartPositionTicks': 0, 'ImageTag': 'chapter/tag ?x'}, + ], + }, 'folder/item #1?x'); + + expect(extras.chapters.single.thumb, '/Items/folder%2Fitem%20%231%3Fx/Images/Chapter/0?tag=chapter%2Ftag%20%3Fx'); + }); + }); +} + +/// Build a Jellyfin TrickplayInfoDto-shaped JSON map for a fixture. +Map _info({ + required int width, + required int height, + required int tw, + required int th, + required int count, + required int interval, +}) => { + 'Width': width, + 'Height': height, + 'TileWidth': tw, + 'TileHeight': th, + 'ThumbnailCount': count, + 'Interval': interval, + 'Bandwidth': 0, +}; diff --git a/test/services/jellyfin_playback_bundle_test.dart b/test/services/jellyfin_playback_bundle_test.dart new file mode 100644 index 00000000..370f4977 --- /dev/null +++ b/test/services/jellyfin_playback_bundle_test.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +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/database/app_database.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/plex_api_cache.dart'; + +JellyfinConnection _conn() => JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jf.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: 'tok-abc', + deviceId: 'dev-xyz', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +/// Pin the playback bundle accessor for [PlaybackInitializationService]. +/// The bundle replaces the previous pattern of reaching into +/// `MediaItem.raw['MediaSources']` / `raw['Chapters']` from outside the +/// client, so any regression here would silently re-leak that abstraction. +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + // JellyfinClient routes its `cache` getter through JellyfinApiCache, so + // both backend caches need to be registered (PlexApiCache for any Plex + // code paths the test setup happens to touch; JellyfinApiCache for the + // shared MediaServerCacheMixin used by JellyfinClient.fetchItem). + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + JellyfinClient buildClient(String body) { + final mock = MockClient((req) async { + return http.Response(body, 200, headers: {'content-type': 'application/json'}); + }); + return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + } + + group('JellyfinClient.fetchPlaybackBundle', () { + test('returns null when item has no MediaSources', () async { + final client = buildClient(jsonEncode({'Id': 'item-1', 'Type': 'Movie'})); + final bundle = await client.fetchPlaybackBundle('item-1'); + expect(bundle, isNull); + client.close(); + }); + + test('returns null when item is missing MediaSources field entirely', () async { + final client = buildClient(jsonEncode({'Id': 'item-1', 'Name': 'X', 'Type': 'Movie', 'MediaSources': []})); + final bundle = await client.fetchPlaybackBundle('item-1'); + // Empty MediaSources also means there's nothing to play. + expect(bundle, isNull); + client.close(); + }); + + test('parses single source with chapters and forwards container/sourceId', () async { + final body = jsonEncode({ + 'Id': 'item-1', + 'Name': 'Example', + 'Type': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'Bitrate': 5000000, + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'h264', 'Width': 1920, 'Height': 1080}, + {'Type': 'Audio', 'Codec': 'eac3', 'Language': 'eng', 'IsDefault': true}, + ], + }, + ], + 'Chapters': [ + {'Name': 'Cold Open', 'StartPositionTicks': 0}, + {'Name': 'Act 1', 'StartPositionTicks': 6000000000}, + ], + }); + final client = buildClient(body); + final bundle = await client.fetchPlaybackBundle('item-1'); + expect(bundle, isNotNull); + expect(bundle!.availableVersions, hasLength(1)); + expect(bundle.container, 'mkv'); + expect(bundle.selectedSourceId, 'src-1'); + expect(bundle.selectedSource['Id'], 'src-1'); + expect(bundle.chapters, hasLength(2)); + client.close(); + }); + + test('clamps out-of-range sourceIndex to 0', () async { + final body = jsonEncode({ + 'Id': 'item-2', + 'Type': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-A', + 'Container': 'mkv', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'h264', 'Height': 720, 'Width': 1280}, + ], + }, + { + 'Id': 'src-B', + 'Container': 'mp4', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'hevc', 'Height': 1080, 'Width': 1920}, + ], + }, + ], + }); + final client = buildClient(body); + + // Negative index → clamps to 0. + final lo = await client.fetchPlaybackBundle('item-2', sourceIndex: -3); + expect(lo!.selectedSourceId, 'src-A'); + + // Out-of-range high → clamps to 0 (mirrors Plex's + // parseVideoPlaybackDataFromJson behaviour). + final hi = await client.fetchPlaybackBundle('item-2', sourceIndex: 7); + expect(hi!.selectedSourceId, 'src-A'); + + // In-range picks the requested source. + final mid = await client.fetchPlaybackBundle('item-2', sourceIndex: 1); + expect(mid!.selectedSourceId, 'src-B'); + expect(mid.container, 'mp4'); + client.close(); + }); + + test('chapters defaults to empty list when item has no Chapters field', () async { + final body = jsonEncode({ + 'Id': 'item-3', + 'Type': 'Movie', + 'MediaSources': [ + { + 'Id': 'src', + 'Container': 'mkv', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'h264'}, + ], + }, + ], + }); + final client = buildClient(body); + final bundle = await client.fetchPlaybackBundle('item-3'); + expect(bundle!.chapters, isEmpty); + client.close(); + }); + }); +} diff --git a/test/services/jellyfin_sequential_launcher_test.dart b/test/services/jellyfin_sequential_launcher_test.dart new file mode 100644 index 00000000..70f7ea0a --- /dev/null +++ b/test/services/jellyfin_sequential_launcher_test.dart @@ -0,0 +1,534 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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_playlist.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/jellyfin_sequential_launcher.dart'; +import 'package:plezy/services/media_list_playback_launcher.dart'; + +/// Recording fake that satisfies [JellyfinClient] via `implements` + +/// `noSuchMethod`. The launcher only needs the +/// [MediaServerClient.fetchPlayableDescendants] / +/// [MediaServerClient.fetchClientSideEpisodeQueue] surface, but we +/// `implements JellyfinClient` so existing tests stay backend-tagged. +class _RecordingJellyfinClient implements JellyfinClient { + final List playableDescendantsResponse; + final List seriesEpisodesResponse; + final List playlistItemsResponse; + final List fetchPlayableDescendantsCalls = []; + final List fetchSeriesEpisodesCalls = []; + final List<({String id, int offset, int limit})> fetchPlaylistItemsCalls = []; + + _RecordingJellyfinClient({ + this.playableDescendantsResponse = const [], + this.seriesEpisodesResponse = const [], + this.playlistItemsResponse = const [], + }); + + @override + Future> fetchPlayableDescendants(String parentId) async { + fetchPlayableDescendantsCalls.add(parentId); + return playableDescendantsResponse; + } + + @override + Future?> fetchClientSideEpisodeQueue(String seriesId) async { + fetchSeriesEpisodesCalls.add(seriesId); + return seriesEpisodesResponse; + } + + @override + Future> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async { + fetchPlaylistItemsCalls.add((id: id, offset: offset, limit: limit)); + if (offset >= playlistItemsResponse.length) return const []; + final end = (offset + limit).clamp(0, playlistItemsResponse.length); + return playlistItemsResponse.sublist(offset, end); + } + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +MediaItem _ep(String id, {String? serverId = 'srv-jf'}) => MediaItem( + id: id, + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode $id', + serverId: serverId, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future pumpContext(WidgetTester tester) async { + late BuildContext capturedContext; + // Wrap in MaterialApp + Scaffold so ScaffoldMessenger is available + // for the error-path snackbars the launcher emits. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + return capturedContext; + } + + group('JellyfinSequentialLauncher', () { + testWidgets('input guard rejects strings (neither MediaItem nor MediaPlaylist)', (tester) async { + final ctx = await pumpContext(tester); + final launcher = JellyfinSequentialLauncher(context: ctx); + + final result = await launcher.launchFromCollectionOrPlaylist(item: 'not-an-item', shuffle: false); + + expect(result, isA()); + final error = (result as PlayQueueError).error; + expect(error.toString(), contains('collection or playlist')); + }); + + testWidgets('input guard rejects items without serverId', (tester) async { + final ctx = await pumpContext(tester); + final launcher = JellyfinSequentialLauncher(context: ctx); + + final orphan = MediaItem( + id: 'col-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.collection, + // no serverId + ); + + final result = await launcher.launchFromCollectionOrPlaylist(item: orphan, shuffle: false); + + expect(result, isA()); + expect((result as PlayQueueError).error.toString(), contains('serverId')); + }); + + testWidgets('collection path expands to playable items in order', (tester) async { + final ctx = await pumpContext(tester); + final fetched = [_ep('e1'), _ep('e2'), _ep('e3')]; + final fakeClient = _RecordingJellyfinClient(playableDescendantsResponse: fetched); + final playback = PlaybackStateProvider(); + final navigated = []; + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (m) async => navigated.add(m), + ); + + final collection = MediaItem( + id: 'col-99', + backend: MediaBackend.jellyfin, + kind: MediaKind.collection, + serverId: 'srv-jf', + ); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: collection, + shuffle: false, + showLoadingIndicator: false, + ); + + expect(result, isA()); + expect(fakeClient.fetchPlayableDescendantsCalls, ['col-99']); + // Queue is seeded in original order, current is items[0]. + expect(playback.loadedItems.map((m) => m.id).toList(), ['e1', 'e2', 'e3']); + expect(playback.isQueueActive, isTrue); + expect(playback.isShuffleActive, isFalse); + // First item is what the player navigates to. + expect(navigated.single.id, 'e1'); + }); + + testWidgets('playlist path uses /Playlists/{id}/Items endpoint', (tester) async { + final ctx = await pumpContext(tester); + final fetched = [_ep('a'), _ep('b')]; + final fakeClient = _RecordingJellyfinClient(playlistItemsResponse: fetched); + final playback = PlaybackStateProvider(); + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async {}, + ); + + final playlist = const MediaPlaylist( + id: 'pl-7', + backend: MediaBackend.jellyfin, + title: 'Mix', + playlistType: 'video', + serverId: 'srv-jf', + ); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: playlist, + shuffle: false, + showLoadingIndicator: false, + ); + + expect(result, isA()); + // Playlist-defined order via the dedicated endpoint — no recursive + // descendant expansion (which doesn't preserve playlist order). + expect(fakeClient.fetchPlayableDescendantsCalls, isEmpty); + expect(fakeClient.fetchPlaylistItemsCalls.map((c) => c.id).toList(), ['pl-7']); + expect(fakeClient.fetchPlaylistItemsCalls.first.offset, 0); + expect(playback.loadedItems.map((m) => m.id).toList(), ['a', 'b']); + }); + + testWidgets('playlist path pages through every item', (tester) async { + final ctx = await pumpContext(tester); + // 150 items across 2 pages of 100 — the loop must keep paging until + // the server returns a short page. + final fetched = List.generate(150, (i) => _ep('p$i')); + final fakeClient = _RecordingJellyfinClient(playlistItemsResponse: fetched); + final playback = PlaybackStateProvider(); + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async {}, + ); + + final playlist = const MediaPlaylist( + id: 'pl-big', + backend: MediaBackend.jellyfin, + title: 'Big', + playlistType: 'video', + serverId: 'srv-jf', + ); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: playlist, + shuffle: false, + showLoadingIndicator: false, + ); + + expect(result, isA()); + expect(playback.loadedItems.length, 150); + expect(fakeClient.fetchPlaylistItemsCalls, hasLength(2)); + expect(fakeClient.fetchPlaylistItemsCalls[0].offset, 0); + expect(fakeClient.fetchPlaylistItemsCalls[1].offset, 100); + }); + + testWidgets('collection containing a Series entry only seeds playable descendants', (tester) async { + // Anchor: the recursive expansion is what skips the unplayable Series + // container. If a future change reverts to fetchChildren the test + // fails because a Series row would leak into the queue. + final ctx = await pumpContext(tester); + final movie = MediaItem(id: 'movie-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-jf'); + final ep1 = _ep('series-A-ep1'); + final ep2 = _ep('series-A-ep2'); + final fakeClient = _RecordingJellyfinClient(playableDescendantsResponse: [movie, ep1, ep2]); + final playback = PlaybackStateProvider(); + final navigated = []; + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (m) async => navigated.add(m), + ); + + final collection = MediaItem( + id: 'col-mixed', + backend: MediaBackend.jellyfin, + kind: MediaKind.collection, + serverId: 'srv-jf', + ); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: collection, + shuffle: false, + showLoadingIndicator: false, + ); + + expect(result, isA()); + expect(playback.loadedItems.map((m) => m.id).toList(), ['movie-1', 'series-A-ep1', 'series-A-ep2']); + // No Series rows leaked into the queue. + expect(playback.loadedItems.any((m) => m.kind == MediaKind.show), isFalse); + expect(navigated.single.id, 'movie-1'); + }); + + testWidgets('shuffle=true reorders the queue (seed-stable assertion)', (tester) async { + final ctx = await pumpContext(tester); + // Use enough items that a coincidence-preserved order is statistically + // unlikely (1 / 50! ~ 0). + final originalIds = List.generate(50, (i) => 'e$i'); + final fetched = originalIds.map(_ep).toList(); + final fakeClient = _RecordingJellyfinClient(playableDescendantsResponse: fetched); + final playback = PlaybackStateProvider(); + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async {}, + ); + + final collection = MediaItem( + id: 'col-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.collection, + serverId: 'srv-jf', + ); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: collection, + shuffle: true, + showLoadingIndicator: false, + ); + + expect(result, isA()); + // Same set of ids, just reordered. + final shuffledIds = playback.loadedItems.map((m) => m.id).toList(); + expect(shuffledIds.toSet(), originalIds.toSet()); + expect(shuffledIds.length, originalIds.length); + expect(playback.isShuffleActive, isTrue); + // The shuffle should not preserve the original order. + expect(shuffledIds, isNot(equals(originalIds))); + }); + + testWidgets('startItem positions playback at the matching index', (tester) async { + final ctx = await pumpContext(tester); + final fetched = [_ep('a'), _ep('b'), _ep('c'), _ep('d')]; + final fakeClient = _RecordingJellyfinClient(playableDescendantsResponse: fetched); + final playback = PlaybackStateProvider(); + final navigated = []; + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (m) async => navigated.add(m), + ); + + final collection = MediaItem( + id: 'col-start', + backend: MediaBackend.jellyfin, + kind: MediaKind.collection, + serverId: 'srv-jf', + ); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: collection, + shuffle: false, + startItem: fetched[2], // 'c' + showLoadingIndicator: false, + ); + + expect(result, isA()); + // Queue keeps original order; player navigates to the chosen item. + expect(playback.loadedItems.map((m) => m.id).toList(), ['a', 'b', 'c', 'd']); + expect(navigated.single.id, 'c'); + }); + + testWidgets('startItem with no match falls back to head of queue', (tester) async { + final ctx = await pumpContext(tester); + final fetched = [_ep('a'), _ep('b')]; + final fakeClient = _RecordingJellyfinClient(playableDescendantsResponse: fetched); + final playback = PlaybackStateProvider(); + final navigated = []; + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (m) async => navigated.add(m), + ); + + final collection = MediaItem( + id: 'col', + backend: MediaBackend.jellyfin, + kind: MediaKind.collection, + serverId: 'srv-jf', + ); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: collection, + shuffle: false, + startItem: _ep('not-in-list'), + showLoadingIndicator: false, + ); + + expect(result, isA()); + expect(navigated.single.id, 'a'); + }); + + testWidgets('launchShuffledShow rejects non-show/season kinds', (tester) async { + final ctx = await pumpContext(tester); + final launcher = JellyfinSequentialLauncher(context: ctx); + + final movie = MediaItem(id: 'm1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-jf'); + + final result = await launcher.launchShuffledShow(metadata: movie, showLoadingIndicator: false); + + expect(result, isA()); + expect((result as PlayQueueError).error.toString(), contains('shows and seasons')); + }); + + testWidgets('launchShuffledShow rejects season missing parentId', (tester) async { + final ctx = await pumpContext(tester); + final launcher = JellyfinSequentialLauncher(context: ctx); + + final season = MediaItem(id: 's1', backend: MediaBackend.jellyfin, kind: MediaKind.season, serverId: 'srv-jf'); + + final result = await launcher.launchShuffledShow(metadata: season, showLoadingIndicator: false); + + expect(result, isA()); + expect((result as PlayQueueError).error.toString(), contains('parentId')); + }); + + testWidgets('launchShuffledShow rejects items missing serverId', (tester) async { + final ctx = await pumpContext(tester); + final launcher = JellyfinSequentialLauncher(context: ctx); + + final orphan = MediaItem(id: 'show-orphan', backend: MediaBackend.jellyfin, kind: MediaKind.show); + + final result = await launcher.launchShuffledShow(metadata: orphan, showLoadingIndicator: false); + + expect(result, isA()); + expect((result as PlayQueueError).error.toString(), contains('serverId')); + }); + + testWidgets('launchShuffledShow on a show fetches series episodes and shuffles', (tester) async { + final ctx = await pumpContext(tester); + // 50 episodes makes a coincident-original ordering effectively impossible. + final originalIds = List.generate(50, (i) => 'ep$i'); + final fetched = originalIds.map(_ep).toList(); + final fakeClient = _RecordingJellyfinClient(seriesEpisodesResponse: fetched); + final playback = PlaybackStateProvider(); + final navigated = []; + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (m) async => navigated.add(m), + ); + + final show = MediaItem( + id: 'show-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + serverId: 'srv-jf', + serverName: 'My Jellyfin', + ); + + final result = await launcher.launchShuffledShow(metadata: show, showLoadingIndicator: false); + + expect(result, isA()); + expect(fakeClient.fetchSeriesEpisodesCalls, ['show-1']); + // Same set of episode ids, just reordered. + final shuffledIds = playback.loadedItems.map((m) => m.id).toList(); + expect(shuffledIds.toSet(), originalIds.toSet()); + expect(shuffledIds.length, originalIds.length); + expect(shuffledIds, isNot(equals(originalIds))); + expect(playback.isShuffleActive, isTrue); + expect(navigated.single.id, shuffledIds.first); + // Server identity is propagated onto the queue items. + expect(playback.loadedItems.first.serverId, 'srv-jf'); + expect(playback.loadedItems.first.serverName, 'My Jellyfin'); + }); + + testWidgets('launchShuffledShow on a season uses parentId as series anchor', (tester) async { + final ctx = await pumpContext(tester); + final fakeClient = _RecordingJellyfinClient(seriesEpisodesResponse: [_ep('a'), _ep('b')]); + final playback = PlaybackStateProvider(); + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async {}, + ); + + final season = MediaItem( + id: 'season-2', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + serverId: 'srv-jf', + parentId: 'show-7', + ); + + final result = await launcher.launchShuffledShow(metadata: season, showLoadingIndicator: false); + + expect(result, isA()); + expect(fakeClient.fetchSeriesEpisodesCalls, ['show-7']); + }); + + testWidgets('launchShuffledShow returns PlayQueueEmpty when series has no episodes', (tester) async { + final ctx = await pumpContext(tester); + final fakeClient = _RecordingJellyfinClient(seriesEpisodesResponse: const []); + final playback = PlaybackStateProvider(); + var didNavigate = false; + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async { + didNavigate = true; + }, + ); + + final show = MediaItem( + id: 'show-empty', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + serverId: 'srv-jf', + ); + + final result = await launcher.launchShuffledShow(metadata: show, showLoadingIndicator: false); + + expect(result, isA()); + expect(playback.isQueueActive, isFalse); + expect(didNavigate, isFalse); + }); + + testWidgets('empty fetch returns PlayQueueEmpty without seeding queue', (tester) async { + final ctx = await pumpContext(tester); + final fakeClient = _RecordingJellyfinClient(playableDescendantsResponse: const []); + final playback = PlaybackStateProvider(); + var didNavigate = false; + + final launcher = JellyfinSequentialLauncher( + context: ctx, + clientForTesting: fakeClient, + playbackStateForTesting: playback, + navigateForTesting: (_) async { + didNavigate = true; + }, + ); + + final collection = MediaItem( + id: 'col-empty', + backend: MediaBackend.jellyfin, + kind: MediaKind.collection, + serverId: 'srv-jf', + ); + + final result = await launcher.launchFromCollectionOrPlaylist( + item: collection, + shuffle: false, + showLoadingIndicator: false, + ); + + expect(result, isA()); + expect(playback.isQueueActive, isFalse); + expect(didNavigate, isFalse); + }); + }); +} diff --git a/test/services/jellyfin_trickplay_service_test.dart b/test/services/jellyfin_trickplay_service_test.dart new file mode 100644 index 00000000..5ac0bd79 --- /dev/null +++ b/test/services/jellyfin_trickplay_service_test.dart @@ -0,0 +1,260 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/painting.dart' show ImageProvider, MemoryImage; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/jellyfin_trickplay_service.dart'; +import 'package:plezy/services/scrub_preview_source.dart'; + +JellyfinConnection _conn() => JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jf.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: 'tok-abc', + deviceId: 'dev-xyz', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +TrickplayInfo _info({ + int width = 320, + int height = 180, + int tileWidth = 10, + int tileHeight = 10, + int thumbnailCount = 250, + int interval = 1000, + int bandwidth = 0, +}) => TrickplayInfo( + width: width, + height: height, + tileWidth: tileWidth, + tileHeight: tileHeight, + thumbnailCount: thumbnailCount, + interval: interval, + bandwidth: bandwidth, +); + +/// Stub that returns a constant 1×1 transparent image for any URL — keeps +/// tests off path_provider / cached_network_image's disk cache. +ImageProvider _fakeSheet(String _) => MemoryImage(Uint8List.fromList(const [0])); + +void main() { + group('JellyfinTrickplayService.create — width selection', () { + late JellyfinClient client; + + setUp(() async { + client = await JellyfinClient.create(_conn()); + }); + + tearDown(() => client.close()); + + test('returns null on empty manifest', () { + final svc = JellyfinTrickplayService.create( + client: client, + itemId: 'item-1', + mediaSourceId: null, + manifest: const {}, + sheetImageBuilder: _fakeSheet, + ); + expect(svc, isNull); + }); + + test('picks the smallest width >= target tooltip width', () { + final svc = JellyfinTrickplayService.create( + client: client, + itemId: 'item-1', + mediaSourceId: null, + manifest: {160: _info(width: 160), 240: _info(width: 240), 320: _info(width: 320)}, + sheetImageBuilder: _fakeSheet, + ); + expect(svc, isNotNull); + // Indirectly: at t=0 the tile crop's width matches the chosen tile width. + expect(svc!.tileLocationFor(Duration.zero)?.sourceTileSize.width, 160); + }); + + test('falls back to largest available when nothing meets the target', () { + final svc = JellyfinTrickplayService.create( + client: client, + itemId: 'item-1', + mediaSourceId: null, + manifest: {80: _info(width: 80), 120: _info(width: 120)}, + sheetImageBuilder: _fakeSheet, + ); + expect(svc, isNotNull); + expect(svc!.tileLocationFor(Duration.zero)?.sourceTileSize.width, 120); + }); + }); + + group('JellyfinTrickplayService.tileLocationFor — index math', () { + late JellyfinClient client; + // 250 thumbnails, 1s apart, 10×10 tiles per sheet ⇒ 3 sheets. + // sheet 0: indices 0..99 + // sheet 1: indices 100..199 (full) + // sheet 2: indices 200..249 (last sheet, only 50 thumbs ⇒ 5 rows) + + setUp(() async { + client = await JellyfinClient.create(_conn()); + }); + + tearDown(() => client.close()); + + JellyfinTrickplayService make({String? sourceId, int width = 320}) { + return JellyfinTrickplayService.create( + client: client, + itemId: 'item-1', + mediaSourceId: sourceId, + manifest: {width: _info(width: width)}, + sheetImageBuilder: _fakeSheet, + )!; + } + + test('t=0 maps to sheet 0, tile (0,0)', () { + final loc = make().tileLocationFor(Duration.zero)!; + expect(loc.sheetIndex, 0); + expect(loc.tileColumn, 0); + expect(loc.tileRow, 0); + }); + + test('t=99s maps to sheet 0, tile (9,9) — last tile in sheet 0', () { + final loc = make().tileLocationFor(const Duration(seconds: 99))!; + expect(loc.sheetIndex, 0); + expect(loc.tileColumn, 9); + expect(loc.tileRow, 9); + }); + + test('t=100s rolls into sheet 1, tile (0,0)', () { + final loc = make().tileLocationFor(const Duration(seconds: 100))!; + expect(loc.sheetIndex, 1); + expect(loc.tileColumn, 0); + expect(loc.tileRow, 0); + // Sheet 1 is full ⇒ 10 cols × 10 rows. + expect(loc.sheetColumns, 10); + expect(loc.sheetRows, 10); + }); + + test('t=249s lands at the last tile of the last (partial) sheet', () { + // index 249 ⇒ sheet 2, tile-in-sheet 49, col=9, row=4 + final loc = make().tileLocationFor(const Duration(seconds: 249))!; + expect(loc.sheetIndex, 2); + expect(loc.tileColumn, 9); + expect(loc.tileRow, 4); + // Sheet 2 has 50 thumbs ⇒ 5 rows, full 10 cols + expect(loc.sheetColumns, 10); + expect(loc.sheetRows, 5); + }); + + test('clamps past the end to the last available thumbnail', () { + final loc = make().tileLocationFor(const Duration(seconds: 10000))!; + expect(loc.sheetIndex, 2); + expect(loc.tileColumn, 9); + expect(loc.tileRow, 4); + }); + + test('isAvailable is true while there are thumbnails', () { + expect(make().isAvailable, isTrue); + }); + + test('dispose flips isAvailable to false and frames to null', () { + final svc = make(); + svc.dispose(); + expect(svc.isAvailable, isFalse); + expect(svc.tileLocationFor(Duration.zero), isNull); + expect(svc.getFrame(Duration.zero), isNull); + }); + }); + + group('JellyfinTrickplayService — partial last sheet sizing', () { + late JellyfinClient client; + + setUp(() async { + client = await JellyfinClient.create(_conn()); + }); + + tearDown(() => client.close()); + + test('last sheet with 1 thumbnail reports 1 col × 1 row', () { + // 17 thumbs, 4×4 sheet ⇒ sheet 0 full (16), sheet 1 has 1 thumb only. + final svc = JellyfinTrickplayService.create( + client: client, + itemId: 'item-1', + mediaSourceId: null, + manifest: { + 320: _info(width: 320, height: 180, tileWidth: 4, tileHeight: 4, thumbnailCount: 17, interval: 1000), + }, + sheetImageBuilder: _fakeSheet, + )!; + final loc = svc.tileLocationFor(const Duration(seconds: 16))!; + expect(loc.sheetIndex, 1); + expect(loc.tileColumn, 0); + expect(loc.tileRow, 0); + expect(loc.sheetColumns, 1); + expect(loc.sheetRows, 1); + }); + }); + + group('JellyfinTrickplayService — sheet URL forwarding', () { + test('sheet URL includes selected width, sheet index, and MediaSourceId', () async { + final client = await JellyfinClient.create(_conn()); + addTearDown(client.close); + final svc = JellyfinTrickplayService.create( + client: client, + itemId: 'item-99', + mediaSourceId: 'src-2', + manifest: {320: _info(width: 320)}, + sheetImageBuilder: _fakeSheet, + )!; + final url = svc.sheetUrlFor(1); + final uri = Uri.parse(url); + expect(uri.path, '/Videos/item-99/Trickplay/320/1.jpg'); + expect(uri.queryParameters['MediaSourceId'], 'src-2'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + }); + + test('sheet URL omits MediaSourceId when null', () async { + final client = await JellyfinClient.create(_conn()); + addTearDown(client.close); + final svc = JellyfinTrickplayService.create( + client: client, + itemId: 'item-99', + mediaSourceId: null, + manifest: {320: _info(width: 320)}, + sheetImageBuilder: _fakeSheet, + )!; + final uri = Uri.parse(svc.sheetUrlFor(0)); + expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse); + }); + }); + + group('JellyfinTrickplayService.getFrame — SheetScrubFrame integration', () { + test('builds a SheetScrubFrame with the injected ImageProvider', () async { + final client = await JellyfinClient.create(_conn()); + addTearDown(client.close); + final probe = MemoryImage(Uint8List.fromList(const [0])); + final svc = JellyfinTrickplayService.create( + client: client, + itemId: 'item-1', + mediaSourceId: null, + manifest: {320: _info(width: 320)}, + sheetImageBuilder: (_) => probe, + )!; + final frame = svc.getFrame(Duration.zero); + expect(frame, isA()); + final sheet = (frame as SheetScrubFrame).sheet; + expect(identical(sheet, probe), isTrue); + expect(frame.tileColumn, 0); + expect(frame.tileRow, 0); + expect(frame.sourceTileSize, equals(const Size(320, 180))); + }); + }); +} + +// Pin: keep ScrubPreviewSource referenced — JellyfinTrickplayService +// implements it but the tests rarely need to upcast, and the import +// otherwise looks unused to the analyzer. +// ignore: unused_element +ScrubPreviewSource? _unused; diff --git a/test/services/library_query_translator_test.dart b/test/services/library_query_translator_test.dart new file mode 100644 index 00000000..1aa0baeb --- /dev/null +++ b/test/services/library_query_translator_test.dart @@ -0,0 +1,276 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/library_query.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/services/library_query_translator.dart'; + +void main() { + group('PlexLibraryQueryTranslator', () { + const translator = PlexLibraryQueryTranslator(); + + test('empty query produces empty filter map', () { + expect(translator.toQueryParameters(const LibraryQuery()), isEmpty); + }); + + test('movie kind maps to type=1', () { + final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.movie)); + expect(params['type'], '1'); + }); + + test('show kind maps to type=2', () { + final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.show)); + expect(params['type'], '2'); + }); + + test('collection kind has no Plex type number (filtered separately)', () { + final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.collection)); + expect(params, isNot(contains('type'))); + }); + + test('ascending sort appends :asc suffix', () { + final params = translator.toQueryParameters( + const LibraryQuery( + sort: LibrarySort(field: 'titleSort', direction: LibrarySortDirection.ascending), + ), + ); + expect(params['sort'], 'titleSort:asc'); + }); + + test('parseSortParam strips explicit ascending suffix', () { + final sort = LibraryQueryTranslator.parseSortParam('titleSort:asc'); + expect(sort?.field, 'titleSort'); + expect(sort?.direction, LibrarySortDirection.ascending); + expect(translator.toQueryParameters(LibraryQuery(sort: sort))['sort'], 'titleSort:asc'); + }); + + test('descending sort appends :desc suffix (default direction)', () { + final params = translator.toQueryParameters(const LibraryQuery(sort: LibrarySort(field: 'addedAt'))); + expect(params['sort'], 'addedAt:desc'); + }); + + test('search puts text in title field', () { + final params = translator.toQueryParameters(const LibraryQuery(search: 'star wars')); + expect(params['title'], 'star wars'); + }); + + test('includeWatched=false sets unwatched=1', () { + final params = translator.toQueryParameters(const LibraryQuery(includeWatched: false)); + expect(params['unwatched'], '1'); + }); + + test('arbitrary filter clauses pass through verbatim', () { + final params = translator.toQueryParameters( + const LibraryQuery( + filters: [ + LibraryFilter(field: 'genre', values: ['Action', 'Drama']), + ], + ), + ); + expect(params['genre'], 'Action,Drama'); + }); + }); + + group('JellyfinLibraryQueryTranslator', () { + const translator = JellyfinLibraryQueryTranslator(userId: 'user-1', parentId: 'lib-1', fields: 'UserData'); + + test('always sets userId, ParentId, Recursive, IncludeItemTypes', () { + final params = translator.toQueryParameters(const LibraryQuery()); + expect(params['userId'], 'user-1'); + expect(params['ParentId'], 'lib-1'); + expect(params['Recursive'], 'true'); + expect(params['Fields'], 'UserData'); + expect(params['IncludeItemTypes'], isNotEmpty); + }); + + test('movie kind maps to IncludeItemTypes=Movie', () { + final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.movie)); + expect(params['IncludeItemTypes'], 'Movie'); + }); + + test('show kind maps to IncludeItemTypes=Series', () { + final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.show)); + expect(params['IncludeItemTypes'], 'Series'); + }); + + test('collection kind maps to IncludeItemTypes=BoxSet', () { + final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.collection)); + expect(params['IncludeItemTypes'], 'BoxSet'); + }); + + test('clip and photo kinds map to Jellyfin item types', () { + expect( + translator.toQueryParameters(const LibraryQuery(kind: MediaKind.clip))['IncludeItemTypes'], + 'Video,MusicVideo', + ); + expect(translator.toQueryParameters(const LibraryQuery(kind: MediaKind.photo))['IncludeItemTypes'], 'Photo'); + }); + + test('null kind falls back to multi-type include', () { + final params = translator.toQueryParameters(const LibraryQuery()); + expect(params['IncludeItemTypes'], 'Movie,Series,Episode,Audio'); + }); + + test('genres joined with pipe separator', () { + final params = translator.toQueryParameters(const LibraryQuery(genres: ['Action', 'Drama'])); + expect(params['Genres'], 'Action|Drama'); + }); + + test('years joined with comma separator', () { + final params = translator.toQueryParameters(const LibraryQuery(years: [2020, 2021])); + expect(params['Years'], '2020,2021'); + }); + + test('sort field "title" maps to SortName, "addedAt" to DateCreated', () { + final titleSort = translator.toQueryParameters( + const LibraryQuery( + sort: LibrarySort(field: 'title', direction: LibrarySortDirection.ascending), + ), + ); + expect(titleSort['SortBy'], 'SortName'); + expect(titleSort['SortOrder'], 'Ascending'); + + final addedSort = translator.toQueryParameters(const LibraryQuery(sort: LibrarySort(field: 'addedAt'))); + expect(addedSort['SortBy'], 'DateCreated'); + expect(addedSort['SortOrder'], 'Descending'); + }); + + test('nameStartsWith="#" maps to NameLessThan=A', () { + final params = translator.toQueryParameters(const LibraryQuery(nameStartsWith: '#')); + expect(params['NameLessThan'], 'A'); + expect(params, isNot(contains('NameStartsWith'))); + }); + + test('nameStartsWith=letter maps to NameStartsWith', () { + final params = translator.toQueryParameters(const LibraryQuery(nameStartsWith: 'B')); + expect(params['NameStartsWith'], 'B'); + expect(params, isNot(contains('NameLessThan'))); + }); + + test('includeWatched=false sets Filters=IsUnplayed', () { + final params = translator.toQueryParameters(const LibraryQuery(includeWatched: false)); + expect(params['Filters'], 'IsUnplayed'); + }); + + test('search puts text in SearchTerm', () { + final params = translator.toQueryParameters(const LibraryQuery(search: 'matrix')); + expect(params['SearchTerm'], 'matrix'); + }); + + test('offset/limit pass through as StartIndex/Limit strings', () { + final params = translator.toQueryParameters(const LibraryQuery(offset: 50, limit: 25)); + expect(params['StartIndex'], '50'); + expect(params['Limit'], '25'); + }); + }); + + group('LibraryQueryTranslator.parseSortParam', () { + test('returns null for null/empty input', () { + expect(LibraryQueryTranslator.parseSortParam(null), isNull); + expect(LibraryQueryTranslator.parseSortParam(''), isNull); + }); + + test('parses bare field as ascending', () { + final sort = LibraryQueryTranslator.parseSortParam('addedAt'); + expect(sort, isNotNull); + expect(sort!.field, 'addedAt'); + expect(sort.direction, LibrarySortDirection.ascending); + }); + + test('parses field:desc as descending', () { + final sort = LibraryQueryTranslator.parseSortParam('rating:desc'); + expect(sort, isNotNull); + expect(sort!.field, 'rating'); + expect(sort.direction, LibrarySortDirection.descending); + }); + + test('handles dotted Plex sort keys without losing the field', () { + final sort = LibraryQueryTranslator.parseSortParam('episode.originallyAvailableAt:desc'); + expect(sort!.field, 'episode.originallyAvailableAt'); + expect(sort.direction, LibrarySortDirection.descending); + }); + + test('returns null when only the suffix is present', () { + expect(LibraryQueryTranslator.parseSortParam(':desc'), isNull); + }); + }); + + // The library browse tab still keeps `_selectedFilters` as a Plex-shaped + // map (the FiltersBottomSheet emits that shape) but routes it through + // `libraryQueryFromPlexMap` at the `fetchLibraryPagedContent` boundary. + // The Plex client then translates the resulting `LibraryQuery` back to a + // map via `PlexLibraryQueryTranslator`. Round-tripping must be loss-free + // (modulo the `includeCollections=1` always-on case the client adds back + // explicitly) so user-saved filters from prior versions don't silently + // drop on first reload. + group('libraryQueryFromPlexMap round-trip with PlexLibraryQueryTranslator', () { + const translator = PlexLibraryQueryTranslator(); + + Map roundTrip(Map input, {MediaKind? libraryKind}) { + final query = libraryQueryFromPlexMap(map: input, libraryKind: libraryKind); + return translator.toQueryParameters(query); + } + + test('genre + sort round-trips into the same map', () { + final input = {'genre': 'Comedy', 'sort': 'addedAt:desc'}; + expect(roundTrip(input), {'genre': 'Comedy', 'sort': 'addedAt:desc'}); + }); + + test('multi-value year filter round-trips', () { + final input = {'year': '2010,2011,2012'}; + expect(roundTrip(input)['year'], '2010,2011,2012'); + }); + + test('contentRating + tag + alphaPrefix round-trip together', () { + final input = {'contentRating': 'PG-13', 'tag': 'Christmas', 'alphaPrefix': 'A'}; + expect(roundTrip(input), {'contentRating': 'PG-13', 'tag': 'Christmas', 'alphaPrefix': 'A'}); + }); + + test('unwatched=1 round-trips (LibraryQuery.includeWatched=false → unwatched=1)', () { + final input = {'unwatched': '1'}; + expect(roundTrip(input), {'unwatched': '1'}); + }); + + test('unwatched absent round-trips to absent (default includeWatched=true)', () { + expect(roundTrip(const {}), isEmpty); + }); + + test('unknown Plex filter keys (director) survive as generic LibraryFilter entries', () { + final input = {'director': '12345'}; + expect(roundTrip(input)['director'], '12345'); + }); + + test('libraryKind argument overrides any type entry in the map', () { + // The browse tab always passes the library's actual kind; map's `type` + // is dropped if the explicit arg is present. + final query = libraryQueryFromPlexMap(map: {'type': '1'}, libraryKind: MediaKind.show); + expect(query.kind, MediaKind.show); + }); + + test('multi-value type stays in the generic filters bucket (Plex passes it verbatim)', () { + // Plex shared libraries use `type=1,4` to mean movies+episodes — no + // single MediaKind covers that, so it has to round-trip via filters. + final input = {'type': '1,4'}; + expect(roundTrip(input)['type'], '1,4'); + }); + + test('numeric type maps to MediaKind when libraryKind is absent', () { + final query = libraryQueryFromPlexMap(map: {'type': '1'}); + expect(query.kind, MediaKind.movie); + }); + + test('full realistic browse-tab map round-trips byte-for-byte', () { + final input = { + 'genre': 'Comedy', + 'year': '2024', + 'contentRating': 'PG-13', + 'tag': 'Christmas', + 'unwatched': '1', + 'sort': 'rating:desc', + 'alphaPrefix': 'B', + }; + // includeCollections is added by PlexClient.fetchLibraryPagedContent + // *after* the translator, so the round-trip-only output skips it. The + // production path still emits it. + expect(roundTrip(input), input); + }); + }); +} diff --git a/test/services/live_session_tracker_test.dart b/test/services/live_session_tracker_test.dart new file mode 100644 index 00000000..e9e1374c --- /dev/null +++ b/test/services/live_session_tracker_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/live_session_tracker.dart'; + +class _FakeJellyfinClient implements JellyfinClient { + final calls = []; + final startGate = Completer(); + + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + await startGate.future; + calls.add('started:$itemId:$playSessionId'); + } + + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId'); + } + + @override + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + }) async { + calls.add('stopped:$itemId:$playSessionId'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('coalesces duplicate live starts and orders stop after in-flight start', () async { + final client = _FakeJellyfinClient(); + final tracker = JellyfinLiveSessionTracker(playSessionId: 'live-session-1'); + + final first = tracker.report( + client: client, + itemId: 'channel-1', + state: 'playing', + position: Duration.zero, + duration: Duration.zero, + ); + final second = tracker.report( + client: client, + itemId: 'channel-1', + state: 'playing', + position: const Duration(seconds: 1), + duration: Duration.zero, + ); + await Future.delayed(Duration.zero); + final stopped = tracker.report( + client: client, + itemId: 'channel-1', + state: 'stopped', + position: const Duration(seconds: 2), + duration: Duration.zero, + ); + + await Future.delayed(Duration.zero); + expect(client.calls, isEmpty); + + client.startGate.complete(); + await Future.wait([first, second, stopped]); + + expect(client.calls, ['started:channel-1:live-session-1', 'stopped:channel-1:live-session-1']); + }); +} diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index a9277e5a..7ccdf362 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -1,8 +1,37 @@ +import 'dart:async'; + +import 'package:drift/native.dart'; 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/database/app_database.dart'; +import 'package:plezy/models/plex/plex_config.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_auth_service.dart'; +import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import '../test_helpers/prefs.dart'; +JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection( + id: 'jf-machine/$userId', + baseUrl: 'https://jf.example.com', + serverName: 'Shared JF', + serverMachineId: 'jf-machine', + userId: userId, + userName: userId, + accessToken: 'token-$userId', + deviceId: 'device', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +JellyfinClient _jellyfinClient(String userId) => JellyfinClient.forTesting( + connection: _jellyfinConnection(userId), + httpClient: MockClient((_) async => http.Response('{}', 200)), +); + // NOTE on coverage scope: // [MultiServerManager.addServer] / `connectToAllServers` / `_createClientForServer` // all instantiate a real `PlexClient` via `findBestWorkingConnection`, which @@ -40,26 +69,26 @@ void main() { expect(m.serverIds, isEmpty); expect(m.onlineServerIds, isEmpty); expect(m.offlineServerIds, isEmpty); - expect(m.servers, isEmpty); + expect(m.plexServers, isEmpty); expect(m.onlineClients, isEmpty); }); - test('getClient/getServer return null for unknown ids', () { + test('getClient/getPlexServer return null for unknown ids', () { final m = MultiServerManager(); addTearDown(m.dispose); expect(m.getClient('nope'), isNull); - expect(m.getServer('nope'), isNull); + expect(m.getPlexServer('nope'), isNull); expect(m.isServerOnline('nope'), isFalse); }); - test('servers map is unmodifiable', () { + test('plexServers map is unmodifiable', () { final m = MultiServerManager(); addTearDown(m.dispose); // Map.unmodifiable rejects every mutating operation — clear() is the // simplest no-arg one to exercise the wrapper. - expect(() => m.servers.clear(), throwsUnsupportedError); + expect(() => m.plexServers.clear(), throwsUnsupportedError); }); }); @@ -122,6 +151,176 @@ void main() { }); }); + group('refreshTokensForProfile', () { + test('successful in-place Plex token refresh clears auth-error state', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + final m = MultiServerManager(); + addTearDown(m.dispose); + + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example', + token: 'old-token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1.0.0', + ), + serverId: 'server-1', + serverName: 'Plex', + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + m.debugRegisterClientForTesting(client, online: true); + m.debugMarkAuthErrorForTesting('server-1'); + + final bound = await m.refreshTokensForProfile( + PlexAccountConnection( + id: 'account-1', + accountToken: 'account-token', + clientIdentifier: 'client-id', + accountLabel: 'Account', + servers: [ + PlexServer( + name: 'Plex', + clientIdentifier: 'server-1', + accessToken: 'new-token', + connections: const [], + owned: true, + ), + ], + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + + expect(bound, {'server-1'}); + expect(m.authErrorServerIds, isNot(contains('server-1'))); + expect(client.config.token, 'new-token'); + }); + }); + + group('Jellyfin connection updates', () { + test('persists refreshed admin status discovered during health checks', () async { + final persisted = []; + final persistStarted = Completer(); + final allowPersist = Completer(); + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a'), + httpClient: MockClient((request) async { + expect(request.url.path, '/Users/Me'); + return http.Response( + '{"Policy":{"IsAdministrator":true}}', + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + final m = MultiServerManager() + ..onJellyfinConnectionUpdated = (connection) async { + persistStarted.complete(); + await allowPersist.future; + persisted.add(connection); + }; + addTearDown(m.dispose); + m.debugRegisterJellyfinClientForTesting(client); + + final healthFuture = m.checkServerHealth(); + await persistStarted.future; + expect(persisted, isEmpty); + allowPersist.complete(); + await healthFuture; + + expect(persisted, hasLength(1)); + expect(persisted.single.isAdministrator, isTrue); + }); + + test('health remains online when persisting refreshed admin status fails', () async { + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a'), + httpClient: MockClient( + (_) async => + http.Response('{"Policy":{"IsAdministrator":true}}', 200, headers: {'content-type': 'application/json'}), + ), + ); + addTearDown(client.close); + final m = MultiServerManager() + ..onJellyfinConnectionUpdated = (_) async { + throw Exception('disk full'); + }; + addTearDown(m.dispose); + m.debugRegisterJellyfinClientForTesting(client); + + await m.checkServerHealth(); + + expect(m.isServerOnline('jf-machine'), isTrue); + expect(m.isOwnerOrAdmin('jf-machine'), isTrue); + }); + + test('ignores stale admin-status persistence from a replaced Jellyfin client', () async { + final persisted = []; + final requestStarted = Completer(); + final allowResponse = Completer(); + final oldClient = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a'), + httpClient: MockClient((_) async { + requestStarted.complete(); + await allowResponse.future; + return http.Response( + '{"Policy":{"IsAdministrator":true}}', + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + final newClient = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a').copyWith(accessToken: 'new-token'), + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + addTearDown(oldClient.close); + final m = MultiServerManager()..onJellyfinConnectionUpdated = persisted.add; + addTearDown(m.dispose); + + m.debugRegisterJellyfinClientForTesting(oldClient); + final healthFuture = m.checkServerHealth(); + await requestStarted.future; + m.debugRegisterJellyfinClientForTesting(newClient); + allowResponse.complete(); + await healthFuture; + + expect(persisted, isEmpty); + expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), same(newClient)); + }); + + test('ignores stale health status when active Jellyfin user changes mid-check', () async { + final requestStarted = Completer(); + final allowResponse = Completer(); + final userA = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a'), + httpClient: MockClient((_) async { + requestStarted.complete(); + await allowResponse.future; + return http.Response('', 403); + }), + ); + final userB = _jellyfinClient('user-b'); + final m = MultiServerManager(); + addTearDown(m.dispose); + + m.debugRegisterJellyfinClientForTesting(userA); + final healthFuture = m.checkServerHealth(); + await requestStarted.future; + m.debugRegisterJellyfinClientForTesting(userB, online: true); + allowResponse.complete(); + await healthFuture; + + expect(m.getClient('jf-machine'), same(userB)); + expect(m.isServerOnline('jf-machine'), isTrue); + expect(m.authErrorServerIds, isNot(contains('jf-machine'))); + }); + }); + // ============================================================ // removeServer // ============================================================ @@ -162,6 +361,23 @@ void main() { expect(emitted, hasLength(1)); expect(emitted.first, isEmpty); }); + + test('removing a Jellyfin machine clears every scoped user client', () { + final m = MultiServerManager(); + addTearDown(m.dispose); + + m.debugRegisterJellyfinClientForTesting(_jellyfinClient('user-a')); + m.debugRegisterJellyfinClientForTesting(_jellyfinClient('user-b')); + + expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNotNull); + expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNotNull); + + m.removeServer('jf-machine'); + + expect(m.getClient('jf-machine'), isNull); + expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNull); + expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNull); + }); }); // ============================================================ @@ -188,6 +404,20 @@ void main() { expect(m.offlineServerIds, isEmpty); expect(emitted.last, isEmpty); }); + + test('clears inactive Jellyfin scoped clients', () { + final m = MultiServerManager(); + addTearDown(m.dispose); + + m.debugRegisterJellyfinClientForTesting(_jellyfinClient('user-a')); + m.debugRegisterJellyfinClientForTesting(_jellyfinClient('user-b')); + + m.disconnectAll(); + + expect(m.getClient('jf-machine'), isNull); + expect(m.getJellyfinClientByCompoundId('jf-machine/user-a'), isNull); + expect(m.getJellyfinClientByCompoundId('jf-machine/user-b'), isNull); + }); }); // ============================================================ diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 920dfc84..301c4bc3 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -1,10 +1,17 @@ import 'package:drift/native.dart'; import 'package:flutter/foundation.dart'; 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/database/app_database.dart'; +import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/offline_mode_source.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; +import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/prefs.dart'; @@ -56,11 +63,24 @@ class _FakeOfflineModeSource extends ChangeNotifier implements OfflineModeSource /// [MultiServerManager] (no servers added). ({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr}) _makeService() { final db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); final mgr = MultiServerManager(); final svc = OfflineWatchSyncService(database: db, serverManager: mgr); return (svc: svc, db: db, mgr: mgr); } +JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection( + id: 'jf-machine/$userId', + baseUrl: 'https://jf.example.com', + serverName: 'Shared JF', + serverMachineId: 'jf-machine', + userId: userId, + userName: userId, + accessToken: 'token-$userId', + deviceId: 'device', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + void main() { setUp(resetSharedPreferencesForTest); @@ -131,7 +151,7 @@ void main() { var notifications = 0; svc.addListener(() => notifications++); - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '42'); + await svc.queueMarkWatched(serverId: 'srv', itemId: '42'); expect(await svc.getPendingSyncCount(), 1); // ChangeNotifier emission was synchronous in the queue helper. @@ -153,7 +173,7 @@ void main() { await db.close(); }); - await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '42'); + await svc.queueMarkUnwatched(serverId: 'srv', itemId: '42'); final action = await db.getLatestWatchAction('srv:42'); expect(action, isNotNull); @@ -168,13 +188,13 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '42'); + await svc.queueMarkWatched(serverId: 'srv', itemId: '42'); expect(await svc.getPendingSyncCount(), 1); // The DB layer's insertWatchAction deletes any prior entries for the // same globalKey before inserting — so flipping watched/unwatched keeps // a single row. - await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '42'); + await svc.queueMarkUnwatched(serverId: 'srv', itemId: '42'); expect(await svc.getPendingSyncCount(), 1); final action = await db.getLatestWatchAction('srv:42'); @@ -189,9 +209,9 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1'); - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '2'); - await svc.queueMarkUnwatched(serverId: 'other', ratingKey: '1'); + await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); + await svc.queueMarkWatched(serverId: 'srv', itemId: '2'); + await svc.queueMarkUnwatched(serverId: 'other', itemId: '1'); expect(await svc.getPendingSyncCount(), 3); @@ -201,6 +221,49 @@ void main() { }); }); + group('syncPendingItems retry preservation', () { + test('server unavailable keeps queued action without consuming attempts', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + await svc.queueMarkWatched(serverId: 'srv', itemId: '42'); + + await svc.syncPendingItems(); + + final action = await db.getLatestWatchAction('srv:42'); + expect(action, isNotNull); + expect(action!.syncAttempts, 0); + expect(action.lastError, isNull); + }); + + test('max-attempt action is retained for explicit cleanup instead of deleted', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + await svc.queueMarkWatched(serverId: 'srv', itemId: '42'); + var action = await db.getLatestWatchAction('srv:42'); + for (var i = 0; i < OfflineWatchSyncService.maxSyncAttempts; i++) { + await db.updateSyncAttempt(action!.id, 'server error'); + action = await db.getLatestWatchAction('srv:42'); + } + + await svc.syncPendingItems(); + + final retained = await db.getLatestWatchAction('srv:42'); + expect(retained, isNotNull); + expect(retained!.syncAttempts, OfflineWatchSyncService.maxSyncAttempts); + expect(retained.lastError, 'server error'); + }); + }); + // ============================================================ // queueProgressUpdate (also exercised so we can test the progress branches // of getLocalWatchStatus / getLocalViewOffset). @@ -216,7 +279,7 @@ void main() { }); // 50% progress → below default 0.9 threshold. - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '42', viewOffset: 50, duration: 100); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50, duration: 100); final action = await db.getLatestWatchAction('srv:42'); expect(action, isNotNull); @@ -234,7 +297,7 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '42', viewOffset: 95, duration: 100); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95, duration: 100); final action = await db.getLatestWatchAction('srv:42'); expect(action!.shouldMarkWatched, isTrue); @@ -248,8 +311,8 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '42', viewOffset: 10, duration: 100); - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '42', viewOffset: 20, duration: 100); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 10, duration: 100); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 20, duration: 100); // upsertProgressAction merges by globalKey — only ONE row. expect(await svc.getPendingSyncCount(), 1); @@ -280,7 +343,7 @@ void main() { mgr.dispose(); await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1'); + await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); expect(await svc.getLocalWatchStatus('srv:1'), isTrue); }); @@ -291,7 +354,7 @@ void main() { mgr.dispose(); await db.close(); }); - await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '1'); + await svc.queueMarkUnwatched(serverId: 'srv', itemId: '1'); expect(await svc.getLocalWatchStatus('srv:1'), isFalse); }); @@ -304,11 +367,11 @@ void main() { }); // Below threshold → shouldMarkWatched=false → status=false. - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 50, duration: 100); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 50, duration: 100); expect(await svc.getLocalWatchStatus('srv:1'), isFalse); // Above threshold → shouldMarkWatched=true → status=true. - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '2', viewOffset: 99, duration: 100); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '2', viewOffset: 99, duration: 100); expect(await svc.getLocalWatchStatus('srv:2'), isTrue); }); }); @@ -336,10 +399,10 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1'); + await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); expect(await svc.getLocalViewOffset('srv:1'), isNull); - await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '2'); + await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2'); expect(await svc.getLocalViewOffset('srv:2'), isNull); }); @@ -351,7 +414,7 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 12345, duration: 60000); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 12345, duration: 60000); expect(await svc.getLocalViewOffset('srv:1'), 12345); }); @@ -363,13 +426,13 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 5000, duration: 10000); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 5000, duration: 10000); expect(await svc.getLocalViewOffset('srv:1'), 5000); // Manual "watched" wipes the progress row (insertWatchAction deletes // by globalKey first), so getLocalViewOffset reads the new row whose // actionType != 'progress' → null. - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1'); + await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); expect(await svc.getLocalViewOffset('srv:1'), isNull); }); }); @@ -389,9 +452,9 @@ void main() { expect(await svc.getPendingSyncCount(), 0); - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1'); - await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '2'); - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '3', viewOffset: 50, duration: 100); + await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); + await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2'); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '3', viewOffset: 50, duration: 100); expect(await svc.getPendingSyncCount(), 3); }); @@ -403,8 +466,8 @@ void main() { await db.close(); }); - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 10, duration: 100); - await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 20, duration: 100); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 10, duration: 100); + await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 20, duration: 100); expect(await svc.getPendingSyncCount(), 1); }); }); @@ -432,11 +495,11 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1'); - await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '2'); + await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); + await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2'); await svc.queueProgressUpdate( serverId: 'srv', - ratingKey: '3', + itemId: '3', viewOffset: 99, duration: 100, // above threshold ); @@ -449,6 +512,457 @@ void main() { // The map MUST contain every requested key, even when null. expect(result.keys.toSet(), {'srv:1', 'srv:2', 'srv:3', 'srv:missing'}); }); + + test('filters batched local statuses by active Jellyfin scope', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final activeUserB = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-b'), + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + addTearDown(activeUserB.close); + mgr.debugRegisterJellyfinClientForTesting(activeUserB); + + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: 3, + ); + await db.insertWatchAction( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + actionType: OfflineActionType.unwatched.id, + ); + await Future.delayed(const Duration(milliseconds: 2)); + await db.insertWatchAction( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-b', + ratingKey: 'item-1', + actionType: OfflineActionType.watched.id, + ); + + final result = await svc.getLocalWatchStatusesBatched({'jf-machine:item-1'}); + expect(result['jf-machine:item-1'], isTrue); + }); + + test('local watch actions are isolated by active profile', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + svc.setActiveProfileId('profile-a'); + await svc.queueMarkWatched(serverId: 'plex-machine', itemId: 'item-1'); + expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isTrue); + expect(await svc.getPendingSyncCount(), 1); + + svc.setActiveProfileId('profile-b'); + expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isNull); + expect(await svc.getPendingSyncCount(), 0); + await svc.queueMarkUnwatched(serverId: 'plex-machine', itemId: 'item-1'); + expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isFalse); + expect(await svc.getPendingSyncCount(), 1); + + svc.setActiveProfileId('profile-a'); + expect(await svc.getLocalWatchStatus('plex-machine:item-1'), isTrue); + expect(await svc.getPendingSyncCount(), 1); + }); + }); + + group('Jellyfin scoped sync', () { + test('queues with downloaded Jellyfin source scope when no active client is registered', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: 3, + ); + + await svc.queueMarkWatched(serverId: 'jf-machine', itemId: 'item-1'); + + final queued = await db.getPendingWatchActions(); + expect(queued.single.clientScopeId, 'jf-machine/user-a'); + }); + + test('local status and resume offset use active scope over downloaded source scope', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final activeUserB = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-b'), + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + addTearDown(activeUserB.close); + mgr.debugRegisterJellyfinClientForTesting(activeUserB); + + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: 3, + ); + await db.upsertProgressAction( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + viewOffset: 5000, + duration: 100000, + shouldMarkWatched: false, + ); + await Future.delayed(const Duration(milliseconds: 2)); + await db.upsertProgressAction( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-b', + ratingKey: 'item-1', + viewOffset: 90000, + duration: 100000, + shouldMarkWatched: true, + ); + + expect(await svc.getLocalWatchStatus('jf-machine:item-1'), isTrue); + expect(await svc.getLocalViewOffset('jf-machine:item-1'), 90000); + expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isFalse); + expect(await svc.getLocalViewOffset('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), 5000); + }); + + test('queues with active Jellyfin user instead of downloaded source scope', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: 3, + ); + + final activeUserB = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-b'), + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + addTearDown(activeUserB.close); + mgr.debugRegisterJellyfinClientForTesting(activeUserB); + + final returnedScope = await svc.queueMarkWatched(serverId: 'jf-machine', itemId: 'item-1'); + + final queued = await db.getPendingWatchActions(); + expect(returnedScope, 'jf-machine/user-b'); + expect(queued.single.clientScopeId, 'jf-machine/user-b'); + }); + + test('replays through the queued Jellyfin user after active user changes', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final pathsByUser = >{'user-a': [], 'user-b': []}; + + JellyfinClient clientFor(String userId) { + return JellyfinClient.forTesting( + connection: _jellyfinConnection(userId), + httpClient: MockClient((request) async { + pathsByUser[userId]!.add('${request.method} ${request.url.path}?${request.url.query}'); + if (request.method == 'GET' && request.url.path == '/Users/$userId/Items/item-1') { + return http.Response('{"Id":"item-1","Type":"Movie","Name":"Movie $userId"}', 200); + } + if (request.method == 'POST' && request.url.path == '/UserPlayedItems/item-1') { + return http.Response('', 204); + } + return http.Response('not found', 404); + }), + ); + } + + final userA = clientFor('user-a'); + final userB = clientFor('user-b'); + addTearDown(userA.close); + addTearDown(userB.close); + + mgr.debugRegisterJellyfinClientForTesting(userA); + await svc.queueMarkWatched(serverId: 'jf-machine', itemId: 'item-1'); + final queued = await db.getPendingWatchActions(); + expect(queued.single.clientScopeId, 'jf-machine/user-a'); + + // User B becomes the active machine client. The queued action must + // still resolve the specific user A client from clientScopeId. + mgr.debugRegisterJellyfinClientForTesting(userB, online: false); + await svc.syncPendingItems(); + + expect(await svc.getPendingSyncCount(), 0); + expect(pathsByUser['user-a'], contains('POST /UserPlayedItems/item-1?userId=user-a')); + expect(pathsByUser['user-b'], isEmpty); + }); + + test('legacy Jellyfin rows without clientScopeId are not synced through the active server client', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final paths = []; + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-b'), + httpClient: MockClient((request) async { + paths.add('${request.method} ${request.url.path}?${request.url.query}'); + if (request.method == 'GET' && request.url.path == '/Users/user-b/Items/item-1') { + return http.Response('{"Id":"item-1","Type":"Movie","Name":"Movie"}', 200); + } + if (request.method == 'POST' && request.url.path == '/UserPlayedItems/item-1') { + return http.Response('', 204); + } + return http.Response('not found', 404); + }), + ); + addTearDown(client.close); + + mgr.debugRegisterJellyfinClientForTesting(client); + await db.insertWatchAction(serverId: 'jf-machine', ratingKey: 'item-1', actionType: OfflineActionType.watched.id); + + await svc.syncPendingItems(); + + expect(await svc.getPendingSyncCount(), 1); + expect(paths, isEmpty); + }); + + test('legacy Jellyfin rows without clientScopeId do not borrow downloaded source scope during replay', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final pathsByUser = >{'user-a': [], 'user-b': []}; + + JellyfinClient clientFor(String userId) { + return JellyfinClient.forTesting( + connection: _jellyfinConnection(userId), + httpClient: MockClient((request) async { + pathsByUser[userId]!.add('${request.method} ${request.url.path}?${request.url.query}'); + if (request.method == 'GET' && request.url.path == '/Users/$userId/Items/item-1') { + return http.Response('{"Id":"item-1","Type":"Movie","Name":"Movie $userId"}', 200); + } + if (request.method == 'POST' && request.url.path == '/UserPlayedItems/item-1') { + return http.Response('', 204); + } + return http.Response('not found', 404); + }), + ); + } + + final userA = clientFor('user-a'); + final userB = clientFor('user-b'); + addTearDown(userA.close); + addTearDown(userB.close); + + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: 3, + ); + await db.insertWatchAction(serverId: 'jf-machine', ratingKey: 'item-1', actionType: OfflineActionType.watched.id); + + mgr.debugRegisterJellyfinClientForTesting(userA); + mgr.debugRegisterJellyfinClientForTesting(userB); + await svc.syncPendingItems(); + + expect(await svc.getPendingSyncCount(), 1); + expect(pathsByUser['user-a'], isEmpty); + expect(pathsByUser['user-b'], isEmpty); + }); + + test('watch-state pull uses active scope for shared movie downloads', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final pathsByUser = >{'user-a': [], 'user-b': []}; + + JellyfinClient clientFor(String userId) { + return JellyfinClient.forTesting( + connection: _jellyfinConnection(userId), + httpClient: MockClient((request) async { + pathsByUser[userId]!.add('${request.method} ${request.url.path}?${request.url.query}'); + if (request.method == 'GET' && request.url.path == '/Users/$userId/Items/item-1') { + return http.Response( + '{"Id":"item-1","Type":"Movie","Name":"Movie $userId","UserData":{"PlayCount":1}}', + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + } + + final userA = clientFor('user-a'); + final userB = clientFor('user-b'); + addTearDown(userA.close); + addTearDown(userB.close); + final events = []; + final sub = WatchStateNotifier().stream.listen(events.add); + addTearDown(sub.cancel); + + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: 3, + ); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:item-1'); + svc.setActiveProfileId('profile-b'); + + mgr.debugRegisterJellyfinClientForTesting(userA); + mgr.debugRegisterJellyfinClientForTesting(userB); + await svc.syncWatchStatesFromServer(); + await Future.delayed(Duration.zero); + + expect(pathsByUser['user-a'], isEmpty); + expect(pathsByUser['user-b']!.where((p) => p.startsWith('GET /Users/user-b/Items/item-1?')), isNotEmpty); + expect(events.single.cacheServerId, 'jf-machine/user-b'); + }); + + test('watch-state pull uses active scope for shared episode season batches', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final pathsByUser = >{'user-a': [], 'user-b': []}; + + JellyfinClient clientFor(String userId) { + return JellyfinClient.forTesting( + connection: _jellyfinConnection(userId), + httpClient: MockClient((request) async { + pathsByUser[userId]!.add('${request.method} ${request.url.path}?${request.url.query}'); + if (request.method == 'GET' && request.url.path == '/Shows/season-1/Seasons') { + return http.Response('not found', 404); + } + if (request.method == 'GET' && request.url.path == '/Items') { + return http.Response( + '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode $userId","UserData":{"PlayCount":1}}]}', + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.method == 'GET' && request.url.path == '/Users/$userId/Items/ep-1') { + return http.Response( + '{"Id":"ep-1","Type":"Episode","Name":"Episode $userId","UserData":{"PlayCount":1}}', + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + } + + final userA = clientFor('user-a'); + final userB = clientFor('user-b'); + addTearDown(userA.close); + addTearDown(userB.close); + + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'ep-1', + globalKey: 'jf-machine:ep-1', + type: 'episode', + parentRatingKey: 'season-1', + status: 3, + ); + await db.addDownloadOwner(profileId: 'profile-b', globalKey: 'jf-machine:ep-1'); + svc.setActiveProfileId('profile-b'); + + mgr.debugRegisterJellyfinClientForTesting(userA); + mgr.debugRegisterJellyfinClientForTesting(userB); + await svc.syncWatchStatesFromServer(); + + expect(pathsByUser['user-a'], isEmpty); + expect(pathsByUser['user-b']!.where((p) => p.startsWith('GET /Items?')), isNotEmpty); + }); + + test('watch-state pull ignores physical downloads not owned by active profile', () async { + final (svc: svc, db: db, mgr: mgr) = _makeService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final paths = []; + final userB = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-b'), + httpClient: MockClient((request) async { + paths.add('${request.method} ${request.url.path}?${request.url.query}'); + return http.Response('not found', 404); + }), + ); + addTearDown(userB.close); + + await db.insertDownload( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + globalKey: 'jf-machine:item-1', + type: 'movie', + status: 3, + ); + await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'jf-machine:item-1'); + svc.setActiveProfileId('profile-b'); + + mgr.debugRegisterJellyfinClientForTesting(userB); + await svc.syncWatchStatesFromServer(); + + expect(paths, isEmpty); + }); }); // ============================================================ @@ -464,8 +978,8 @@ void main() { await db.close(); }); - await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1'); - await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '2'); + await svc.queueMarkWatched(serverId: 'srv', itemId: '1'); + await svc.queueMarkUnwatched(serverId: 'srv', itemId: '2'); expect(await svc.getPendingSyncCount(), 2); var notifications = 0; diff --git a/test/services/play_queue_launcher_test.dart b/test/services/play_queue_launcher_test.dart index 56f240ac..aab18496 100644 --- a/test/services/play_queue_launcher_test.dart +++ b/test/services/play_queue_launcher_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_metadata.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/services/play_queue_launcher.dart'; import 'package:plezy/services/plex_client.dart'; @@ -77,10 +79,10 @@ void main() { ), ); - final launcher = PlayQueueLauncher(context: capturedContext, client: _StubPlexClient()); + final launcher = PlexPlayQueueLauncher(context: capturedContext, client: _StubPlexClient()); final result = await launcher.launchShuffledShow( - // 'movie' is not 'show' / 'season'. - metadata: PlexMetadata(ratingKey: 'rk1', type: 'movie'), + // movie is not show / season. + metadata: MediaItem(id: 'rk1', backend: MediaBackend.plex, kind: MediaKind.movie), showLoadingIndicator: false, ); @@ -102,7 +104,7 @@ void main() { ), ); - final launcher = PlayQueueLauncher(context: capturedContext, client: _StubPlexClient()); + final launcher = PlexPlayQueueLauncher(context: capturedContext, client: _StubPlexClient()); // Passing a String — neither a PlexMetadata nor a PlexPlaylist. final result = await launcher.launchFromCollectionOrPlaylist(item: 'not-a-real-item', shuffle: false); @@ -129,7 +131,7 @@ void main() { ); final client = _StubPlexClient(); - final launcher = PlayQueueLauncher( + final launcher = PlexPlayQueueLauncher( context: capturedContext, client: client, serverId: 'srv-A', diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart new file mode 100644 index 00000000..9c1790f1 --- /dev/null +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -0,0 +1,320 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plezy/database/app_database.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/models/download_models.dart'; +import 'package:plezy/services/cached_playback_metadata_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/jellyfin_media_info_mapper.dart'; +import 'package:plezy/services/playback_initialization_service.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import '../test_helpers/prefs.dart'; + +class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { + _FakePathProvider(this.root); + + final Directory root; + String get _docs => p.join(root.path, 'documents'); + String get _support => p.join(root.path, 'support'); + String get _cache => p.join(root.path, 'cache'); + String get _temp => p.join(root.path, 'temp'); + + @override + Future getApplicationDocumentsPath() async => _ensure(_docs); + + @override + Future getApplicationSupportPath() async => _ensure(_support); + + @override + Future getApplicationCachePath() async => _ensure(_cache); + + @override + Future getTemporaryPath() async => _ensure(_temp); + + String _ensure(String dir) { + Directory(dir).createSync(recursive: true); + return dir; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late Directory tmpRoot; + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + DownloadStorageService.resetForTesting(); + tmpRoot = await Directory.systemTemp.createTemp('playback_init_test_'); + PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + DownloadStorageService.resetForTesting(); + SettingsService.resetForTesting(); + if (await tmpRoot.exists()) { + await tmpRoot.delete(recursive: true); + } + }); + + test('pure-offline playback loads cached Plex media source info without a client', () async { + await _insertDownloaded(db, serverId: 'srv-1', ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1'); + await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope()); + + final result = await PlaybackInitializationService(database: db).getPlaybackData( + metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 0, + preferOffline: true, + ); + + expect(result.isOffline, isTrue); + expect(result.videoUrl, 'content://offline/movie-1'); + expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng'); + }); + + test('preferOffline uses cache without calling live client when local file exists', () async { + await _insertDownloaded(db, serverId: 'srv-1', ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1'); + await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope()); + final client = _FailingPlaybackClient(serverId: 'srv-1'); + + final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( + metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 0, + preferOffline: true, + ); + + expect(client.playbackInitializationCalls, 0); + expect(result.isOffline, isTrue); + expect(result.videoUrl, 'content://offline/movie-1'); + expect(result.availableVersions, isEmpty); + expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng'); + }); + + test('pure-offline playback uses cached Plex media source for selected version', () async { + await _insertDownloaded( + db, + serverId: 'srv-1', + ratingKey: 'movie-1', + videoFilePath: 'content://offline/movie-1-v2', + mediaIndex: 1, + ); + await PlexApiCache.instance.put( + 'srv-1', + '/library/metadata/movie-1', + _plexMetadataEnvelope(includeSecondVersion: true), + ); + + final result = await PlaybackInitializationService(database: db).getPlaybackData( + metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 1, + preferOffline: true, + ); + + expect(result.videoUrl, 'content://offline/movie-1-v2'); + expect(result.mediaInfo?.audioTracks.single.languageCode, 'fre'); + }); + + test('pure-offline Jellyfin cache works without a connection row', () async { + await _insertDownloaded( + db, + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'item-1', + videoFilePath: 'content://offline/jf-item-1', + ); + await db + .into(db.apiCache) + .insert( + ApiCacheCompanion.insert( + cacheKey: 'jf-machine/user-a:/Users/user-a/Items/item-1', + data: jsonEncode(_jellyfinItemRaw()), + pinned: const Value(true), + ), + ); + + final result = await PlaybackInitializationService(database: db).getPlaybackData( + metadata: MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'jf-machine'), + selectedMediaIndex: 0, + preferOffline: true, + ); + + expect(result.videoUrl, 'content://offline/jf-item-1'); + expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng'); + expect(result.mediaInfo?.chapters.single.title, 'Chapter 1'); + }); + + test('SAF offline playback discovers app-managed sidecar subtitles', () async { + await _insertDownloaded(db, serverId: 'srv-1', ratingKey: 'movie-1', videoFilePath: 'content://offline/movie-1'); + final subtitlePath = await DownloadStorageService.instance.getSubtitlePath('srv-1', 'movie-1', 2, 'srt'); + final subtitleFile = File(subtitlePath); + await subtitleFile.parent.create(recursive: true); + await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello'); + + final result = await PlaybackInitializationService(database: db).getPlaybackData( + metadata: MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv-1'), + selectedMediaIndex: 0, + preferOffline: true, + ); + + expect(result.videoUrl, 'content://offline/movie-1'); + expect(result.externalSubtitles, hasLength(1)); + expect(result.externalSubtitles.single.uri, Uri.file(subtitlePath).toString()); + }); + + test('cache-only playback extras parses Plex chapters and markers', () async { + await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope()); + + final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras( + backend: MediaBackend.plex, + cacheServerId: 'srv-1', + itemId: 'movie-1', + ); + + expect(extras?.chapters.single.title, 'Intro'); + expect(extras?.markers.single.type, 'credits'); + }); + + test('Plex extras parser skips malformed entries and keeps valid ones', () async { + await PlexApiCache.instance.put('srv-1', '/library/metadata/movie-1', _plexMetadataEnvelope(malformedExtras: true)); + + final extras = await CachedPlaybackMetadataService.fetchPlaybackExtras( + backend: MediaBackend.plex, + cacheServerId: 'srv-1', + itemId: 'movie-1', + ); + + expect(extras?.chapters.map((c) => c.title), ['Intro']); + expect(extras?.markers.map((m) => m.type), ['credits']); + }); + + test('Jellyfin extras parser tolerates non-string chapter names', () { + final extras = jellyfinPlaybackExtrasFromRaw({ + 'Chapters': [ + {'Name': 123, 'StartPositionTicks': 10000000}, + ], + }, 'item-1'); + + expect(extras.chapters.single.title, '123'); + }); +} + +class _FailingPlaybackClient implements MediaServerClient { + _FailingPlaybackClient({required this.serverId}); + + @override + final String serverId; + + int playbackInitializationCalls = 0; + + @override + Future getPlaybackInitialization(PlaybackInitializationOptions options) async { + playbackInitializationCalls++; + throw StateError('live playback initialization should not be called for downloaded playback'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Future _insertDownloaded( + AppDatabase db, { + required String serverId, + String? clientScopeId, + required String ratingKey, + required String videoFilePath, + int mediaIndex = 0, +}) async { + await db + .into(db.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: serverId, + clientScopeId: Value(clientScopeId), + ratingKey: ratingKey, + globalKey: '$serverId:$ratingKey', + type: 'movie', + status: DownloadStatus.completed.index, + videoFilePath: Value(videoFilePath), + mediaIndex: Value(mediaIndex), + ), + ); +} + +Map _plexMetadataEnvelope({bool includeSecondVersion = false, bool malformedExtras = false}) { + final chapters = >[ + if (malformedExtras) {'id': 'bad'}, + {'id': 1, 'index': 0, 'startTimeOffset': 0, 'endTimeOffset': 10000, 'tag': 'Intro'}, + ]; + final markers = >[ + if (malformedExtras) {'id': 99, 'type': 'broken'}, + {'id': 2, 'type': 'credits', 'startTimeOffset': 90000, 'endTimeOffset': 100000}, + ]; + final media = >[ + _plexMediaWithAudio('English', 'eng'), + if (includeSecondVersion) _plexMediaWithAudio('French', 'fre'), + ]; + return { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'movie-1', + 'type': 'movie', + 'title': 'Movie', + 'Chapter': chapters, + 'Marker': markers, + 'Media': media, + }, + ], + }, + }; +} + +Map _plexMediaWithAudio(String language, String languageCode) { + return { + 'Part': [ + { + 'Stream': [ + {'id': 10, 'streamType': 2, 'index': 1, 'language': language, 'languageCode': languageCode}, + ], + }, + ], + }; +} + +Map _jellyfinItemRaw() { + return { + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Jellyfin Movie', + 'Chapters': [ + {'Name': 'Chapter 1', 'StartPositionTicks': 0}, + ], + 'MediaSources': [ + { + 'Id': 'src-1', + 'MediaStreams': [ + {'Type': 'Audio', 'Index': 1, 'Language': 'eng', 'DisplayLanguage': 'English', 'IsDefault': true}, + ], + }, + ], + }; +} diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index b12eb88a..40ca9272 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -1,7 +1,12 @@ +import 'dart:async'; + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; -import 'package:plezy/models/plex_metadata.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_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; @@ -37,12 +42,41 @@ import '../test_helpers/prefs.dart'; /// Fake Player whose state is mutable from the test. class _FakePlayer implements Player { PlayerState _state; - _FakePlayer({Duration position = Duration.zero, Duration duration = Duration.zero, bool playing = true}) - : _state = PlayerState(playing: playing, duration: duration, position: position); + final PlayerStreams _streams = const PlayerStreams( + playing: Stream.empty(), + completed: Stream.empty(), + buffering: Stream.empty(), + position: Stream.empty(), + duration: Stream.empty(), + seekable: Stream.empty(), + buffer: Stream.empty(), + volume: Stream.empty(), + rate: Stream.empty(), + tracks: Stream.empty(), + track: Stream.empty(), + log: Stream.empty(), + error: Stream.empty(), + audioDevice: Stream.empty(), + audioDevices: Stream>.empty(), + bufferRanges: Stream>.empty(), + playbackRestart: Stream.empty(), + backendSwitched: Stream.empty(), + ); + + _FakePlayer({ + Duration position = Duration.zero, + Duration duration = Duration.zero, + bool playing = true, + Tracks tracks = const Tracks(), + TrackSelection track = const TrackSelection(), + }) : _state = PlayerState(playing: playing, duration: duration, position: position, tracks: tracks, track: track); @override PlayerState get state => _state; + @override + PlayerStreams get streams => _streams; + set position(Duration value) { _state = _state.copyWith(position: value); } @@ -77,13 +111,21 @@ class _FakePlexClient implements PlexClient { @override int get watchedThresholdPercent => thresholdPercent; + @override + double get watchedThreshold => thresholdPercent / 100.0; + /// (ratingKey, time, state, duration) tuples for every updateProgress call. final List<({String ratingKey, int time, String state, int? duration})> updateProgressCalls = []; - /// Rating keys passed to markAsWatched. + /// Rating keys passed to markWatched. final List markWatchedCalls = []; - /// If non-null, [updateProgress] / [markAsWatched] throw this on the next call. + /// PlaySessionIds forwarded through the reportPlayback* methods. + final List playbackSessionIds = []; + + final List<({String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex})> playbackStreamSelections = []; + + /// If non-null, the next reportPlayback*/markWatched call throws this. Object? throwOnNextCall; @override @@ -96,18 +138,88 @@ class _FakePlexClient implements PlexClient { updateProgressCalls.add((ratingKey: ratingKey, time: time, state: state, duration: duration)); } + // The interface report* methods delegate to updateProgress so existing + // assertions on `updateProgressCalls` keep working. @override - Future markAsWatched(String ratingKey, {PlexMetadata? metadata}) async { + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) { + playbackSessionIds.add(playSessionId); + playbackStreamSelections.add(( + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + )); + return updateProgress(itemId, time: position.inMilliseconds, state: 'playing', duration: duration?.inMilliseconds); + } + + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) { + playbackSessionIds.add(playSessionId); + playbackStreamSelections.add(( + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + )); + return updateProgress( + itemId, + time: position.inMilliseconds, + state: isPaused ? 'paused' : 'playing', + duration: duration.inMilliseconds, + ); + } + + @override + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + }) { + playbackSessionIds.add(playSessionId); + playbackStreamSelections.add((mediaSourceId: mediaSourceId, audioStreamIndex: null, subtitleStreamIndex: null)); + return updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds); + } + + @override + Future markWatched(MediaItem item) async { + if (throwOnNextCall != null) { + final err = throwOnNextCall!; + throwOnNextCall = null; + throw err; + } + markWatchedCalls.add(item.id); + WatchStateNotifier().notifyWatched(item: item, isNowWatched: true); + } + + @override + Future markAsWatched(String ratingKey, {MediaItem? item}) async { if (throwOnNextCall != null) { final err = throwOnNextCall!; throwOnNextCall = null; throw err; } markWatchedCalls.add(ratingKey); - // Production fires a WatchStateNotifier event from markAsWatched. Mirror - // that so tests can observe it through the singleton. - if (metadata != null) { - WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true); + if (item != null) { + WatchStateNotifier().notifyWatched(item: item, isNowWatched: true); } } @@ -115,8 +227,41 @@ class _FakePlexClient implements PlexClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -PlexMetadata _meta({String ratingKey = '42', String? serverId = 'srv', String? type = 'movie'}) => - PlexMetadata(ratingKey: ratingKey, type: type, title: 'Test Item', serverId: serverId); +class _DelayedStartClient extends _FakePlexClient { + final Completer startCompleter = Completer(); + + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + await startCompleter.future; + await super.reportPlaybackStarted( + itemId: itemId, + position: position, + duration: duration, + playSessionId: playSessionId, + playMethod: playMethod, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ); + } +} + +MediaItem _meta({String ratingKey = '42', String? serverId = 'srv', String? type = 'movie'}) => MediaItem( + id: ratingKey, + backend: MediaBackend.plex, + kind: MediaKind.fromString(type), + title: 'Test Item', + serverId: serverId, +); void main() { setUp(resetSharedPreferencesForTest); @@ -210,6 +355,204 @@ void main() { expect(client.updateProgressCalls, hasLength(1)); expect(client.updateProgressCalls.single.state, 'playing'); }); + + test('forwards PlaySessionId to started, progress, and stopped reports', () async { + final client = _FakePlexClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(ratingKey: '42'), + player: player, + isOffline: false, + playSessionId: 'play-session-1', + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + await tracker.sendProgress('stopped'); + + expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'playing', 'stopped']); + expect(client.playbackSessionIds, ['play-session-1', 'play-session-1', 'play-session-1']); + }); + + test('coalesces concurrent start reports while the first start is in flight', () async { + final client = _DelayedStartClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + expect(client.updateProgressCalls, isEmpty); + + client.startCompleter.complete(); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(client.updateProgressCalls.map((call) => call.state), ['playing']); + }); + + test('orders stopped after an in-flight start report', () async { + final client = _DelayedStartClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + + final stopFuture = tracker.sendProgress('stopped'); + await Future.delayed(Duration.zero); + expect(client.updateProgressCalls, isEmpty); + + client.startCompleter.complete(); + await stopFuture; + + expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'stopped']); + }); + + test('does not send queued progress after terminal stopped state', () async { + final client = _DelayedStartClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + + final stopFuture = tracker.sendProgress('stopped'); + client.startCompleter.complete(); + await stopFuture; + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + + expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'stopped']); + }); + + test('coalesces concurrent stopped reports into one terminal stop', () async { + final client = _FakePlexClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false); + addTearDown(tracker.dispose); + + final events = []; + final sub = WatchStateNotifier().forItem('42').listen(events.add); + addTearDown(sub.cancel); + + await Future.wait([tracker.sendProgress('stopped'), tracker.sendProgress('stopped')]); + await Future.delayed(Duration.zero); + + expect(client.updateProgressCalls.map((call) => call.state), ['stopped']); + expect(events.where((e) => e.changeType == WatchStateChangeType.progressUpdate), hasLength(1)); + }); + + test('allows a later stopped report to retry after final stop fails', () async { + final client = _FakePlexClient()..throwOnNextCall = Exception('network blip'); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false); + addTearDown(tracker.dispose); + + await tracker.sendProgress('stopped'); + expect(client.updateProgressCalls, isEmpty); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + expect(client.updateProgressCalls, isEmpty); + + await tracker.sendProgress('stopped'); + expect(client.updateProgressCalls.map((call) => call.state), ['stopped']); + }); + + test('maps current player tracks to server stream indexes for progress reports', () async { + final client = _FakePlexClient(); + const selectedAudio = AudioTrack(id: 'audio_1', language: 'jpn'); + const subtitlesOff = SubtitleTrack(id: 'no'); + final player = _FakePlayer( + position: const Duration(seconds: 5), + duration: const Duration(seconds: 100), + tracks: const Tracks( + audio: [ + AudioTrack(id: 'audio_0', language: 'eng'), + selectedAudio, + ], + subtitle: [SubtitleTrack(id: 'text_0', language: 'eng')], + ), + track: const TrackSelection(audio: selectedAudio, subtitle: subtitlesOff), + ); + final mediaInfo = MediaSourceInfo( + videoUrl: '', + audioTracks: [ + MediaAudioTrack(id: 1, languageCode: 'eng', selected: false), + MediaAudioTrack(id: 2, languageCode: 'jpn', selected: true), + ], + subtitleTracks: [MediaSubtitleTrack(id: 3, languageCode: 'eng', selected: false, forced: false)], + chapters: const [], + mediaSourceId: 'source-1', + ); + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(ratingKey: '42'), + player: player, + isOffline: false, + mediaInfo: mediaInfo, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + + final progressSelection = client.playbackStreamSelections[1]; + expect(progressSelection.mediaSourceId, 'source-1'); + expect(progressSelection.audioStreamIndex, 2); + expect(progressSelection.subtitleStreamIndex, -1); + }); + + test('stopped reports only resolve media source and do not include selected streams', () async { + final client = _FakePlexClient(); + const selectedAudio = AudioTrack(id: 'audio_1', language: 'jpn'); + final player = _FakePlayer( + position: const Duration(seconds: 5), + duration: const Duration(seconds: 100), + tracks: const Tracks( + audio: [selectedAudio], + subtitle: [SubtitleTrack(id: 'text_0', language: 'eng')], + ), + track: const TrackSelection( + audio: selectedAudio, + subtitle: SubtitleTrack(id: 'text_0', language: 'eng'), + ), + ); + final mediaInfo = MediaSourceInfo( + videoUrl: '', + audioTracks: [MediaAudioTrack(id: 2, languageCode: 'jpn', selected: true)], + subtitleTracks: [MediaSubtitleTrack(id: 3, languageCode: 'eng', selected: true, forced: false)], + chapters: const [], + mediaSourceId: 'source-1', + ); + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(ratingKey: '42'), + player: player, + isOffline: false, + mediaInfo: mediaInfo, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('stopped'); + + expect(client.playbackStreamSelections, hasLength(1)); + expect(client.playbackStreamSelections.single.mediaSourceId, 'source-1'); + expect(client.playbackStreamSelections.single.audioStreamIndex, isNull); + expect(client.playbackStreamSelections.single.subtitleStreamIndex, isNull); + }); }); // ============================================================ @@ -301,11 +644,13 @@ void main() { ); addTearDown(tracker2.dispose); - await tracker2.sendProgress('stopped'); + await tracker2.sendProgress('playing'); + await Future.delayed(Duration.zero); expect(precise.markWatchedAttempts, 1); // Retry — markAsWatched now succeeds. - await tracker2.sendProgress('stopped'); + await tracker2.sendProgress('playing'); + await Future.delayed(Duration.zero); expect(precise.markWatchedAttempts, 2); expect(precise.markWatchedSuccesses, 1); }); @@ -509,7 +854,7 @@ void main() { } /// A more precise fake than [_FakePlexClient]: lets the test independently -/// fail markAsWatched without touching updateProgress. +/// fail the scrobble (markWatched) without touching the progress signals. class _ScrobblePreciseClient implements PlexClient { _ScrobblePreciseClient({this.thresholdPercent = 90, this.failScrobbleFirstTime = false}); @@ -517,6 +862,9 @@ class _ScrobblePreciseClient implements PlexClient { @override int get watchedThresholdPercent => thresholdPercent; + @override + double get watchedThreshold => thresholdPercent / 100.0; + bool failScrobbleFirstTime; int markWatchedAttempts = 0; int markWatchedSuccesses = 0; @@ -525,7 +873,51 @@ class _ScrobblePreciseClient implements PlexClient { Future updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {} @override - Future markAsWatched(String ratingKey, {PlexMetadata? metadata}) async { + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async {} + + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async {} + + @override + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + }) async {} + + @override + Future markWatched(MediaItem item) async { + markWatchedAttempts++; + if (failScrobbleFirstTime) { + failScrobbleFirstTime = false; + throw StateError('simulated scrobble failure'); + } + markWatchedSuccesses++; + } + + @override + Future markAsWatched(String ratingKey, {MediaItem? item}) async { markWatchedAttempts++; if (failScrobbleFirstTime) { failScrobbleFirstTime = false; diff --git a/test/services/playback_report_session_test.dart b/test/services/playback_report_session_test.dart new file mode 100644 index 00000000..4589d51c --- /dev/null +++ b/test/services/playback_report_session_test.dart @@ -0,0 +1,175 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/services/playback_report_session.dart'; + +class _RecordingClient implements MediaServerClient { + final calls = []; + Completer? startGate; + bool failNextStop = false; + + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + final gate = startGate; + if (gate != null) await gate.future; + calls.add('started:${position.inMilliseconds}:$mediaSourceId:$audioStreamIndex:$subtitleStreamIndex'); + } + + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) async { + calls.add('${isPaused ? 'paused' : 'playing'}:${position.inMilliseconds}'); + } + + @override + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? mediaSourceId, + }) async { + calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId'); + if (failNextStop) { + failNextStop = false; + throw StateError('stop failed'); + } + calls.add('stopped:${position.inMilliseconds}:$mediaSourceId'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +PlaybackReportSnapshot _snapshot( + String state, { + int positionMs = 1000, + PlaybackStreamSelectionResolver resolveStreamSelection = _noStreamSelection, +}) { + return PlaybackReportSnapshot( + state: state, + position: Duration(milliseconds: positionMs), + duration: const Duration(minutes: 1), + resolveStreamSelection: resolveStreamSelection, + ); +} + +PlaybackStreamSelection _noStreamSelection() => PlaybackStreamSelection.none; + +void main() { + test('orders stopped after start even when stream selection is still resolving', () async { + final client = _RecordingClient(); + final session = PlaybackReportSession(client: client, itemId: 'item-1'); + final selectionGate = Completer(); + + final startFuture = session.report(_snapshot('playing', resolveStreamSelection: () => selectionGate.future)); + await Future.delayed(Duration.zero); + + final stopFuture = session.report(_snapshot('stopped', positionMs: 5000)); + await Future.delayed(Duration.zero); + expect(client.calls, isEmpty); + + selectionGate.complete(const PlaybackStreamSelection(mediaSourceId: 'source-1', audioStreamIndex: 2)); + await stopFuture; + await startFuture; + + expect(client.calls, ['started:1000:source-1:2:null', 'stopped-attempt:5000:null', 'stopped:5000:null']); + }); + + test('coalesces duplicate starts while start report is in flight', () async { + final client = _RecordingClient()..startGate = Completer(); + final session = PlaybackReportSession(client: client, itemId: 'item-1'); + + final first = session.report(_snapshot('playing', positionMs: 1000)); + final second = session.report(_snapshot('playing', positionMs: 2000)); + await Future.delayed(Duration.zero); + expect(client.calls, isEmpty); + + client.startGate!.complete(); + await Future.wait([first, second]); + + expect(client.calls, ['started:1000:null:null:null']); + }); + + test('coalesces a state change during start into one progress report after start', () async { + final client = _RecordingClient()..startGate = Completer(); + final session = PlaybackReportSession(client: client, itemId: 'item-1'); + + final first = session.report(_snapshot('playing', positionMs: 1000)); + final second = session.report(_snapshot('paused', positionMs: 2000)); + await Future.delayed(Duration.zero); + + client.startGate!.complete(); + await Future.wait([first, second]); + + expect(client.calls, ['started:1000:null:null:null', 'paused:2000']); + }); + + test('terminal stop suppresses in-flight progress after its stream selection resolves', () async { + final client = _RecordingClient(); + final session = PlaybackReportSession(client: client, itemId: 'item-1'); + await session.report(_snapshot('playing', positionMs: 1000)); + client.calls.clear(); + + final selectionGate = Completer(); + final progressFuture = session.report( + _snapshot('playing', positionMs: 2000, resolveStreamSelection: () => selectionGate.future), + ); + await Future.delayed(Duration.zero); + + final stopFuture = session.report(_snapshot('stopped', positionMs: 3000)); + selectionGate.complete(PlaybackStreamSelection.none); + await stopFuture; + expect(await progressFuture, isFalse); + + expect(client.calls, ['stopped-attempt:3000:null', 'stopped:3000:null']); + }); + + test('queued progress resolves false when terminal stop suppresses it during startup', () async { + final client = _RecordingClient()..startGate = Completer(); + final session = PlaybackReportSession(client: client, itemId: 'item-1'); + + final startFuture = session.report(_snapshot('playing', positionMs: 1000)); + final progressFuture = session.report(_snapshot('paused', positionMs: 2000)); + await Future.delayed(Duration.zero); + + final stopFuture = session.report(_snapshot('stopped', positionMs: 3000)); + client.startGate!.complete(); + + await stopFuture; + await startFuture; + + expect(await progressFuture, isFalse); + expect(client.calls, ['started:1000:null:null:null', 'stopped-attempt:3000:null', 'stopped:3000:null']); + }); + + test('stop failure allows explicit stopped retry but ignores non-stop reports', () async { + final client = _RecordingClient()..failNextStop = true; + final session = PlaybackReportSession(client: client, itemId: 'item-1'); + + await expectLater(session.report(_snapshot('stopped', positionMs: 1000)), throwsStateError); + await session.report(_snapshot('playing', positionMs: 2000)); + await session.report(_snapshot('stopped', positionMs: 3000)); + + expect(client.calls, ['stopped-attempt:1000:null', 'stopped-attempt:3000:null', 'stopped:3000:null']); + }); +} diff --git a/test/services/plex_api_cache_test.dart b/test/services/plex_api_cache_test.dart index 3e7ac9ea..c580b703 100644 --- a/test/services/plex_api_cache_test.dart +++ b/test/services/plex_api_cache_test.dart @@ -152,6 +152,17 @@ void main() { expect(await db.select(db.apiCache).get(), isEmpty); }); + + test('clearVolatile preserves pinned offline metadata', () async { + await cache.put('srv-a', '/library/metadata/1', mediaContainer(ratingKey: '1')); + await cache.put('srv-a', '/library/metadata/2', mediaContainer(ratingKey: '2')); + await cache.pinForOffline('srv-a', '1'); + + await cache.clearVolatile(); + + expect(await cache.get('srv-a', '/library/metadata/1'), isNotNull); + expect(await cache.get('srv-a', '/library/metadata/2'), isNull); + }); }); // ============================================================ @@ -161,29 +172,29 @@ void main() { group('pinning', () { test('isPinned defaults to false for a freshly cached item', () async { await cache.put('srv', '/library/metadata/1', mediaContainer()); - expect(await cache.isPinned('srv', '1'), isFalse); + expect(await cache.isPinnedRatingKey('srv', '1'), isFalse); }); test('isPinned returns false when the item is not cached at all', () async { - expect(await cache.isPinned('srv', 'missing'), isFalse); + expect(await cache.isPinnedRatingKey('srv', 'missing'), isFalse); }); test('pinForOffline marks the row as pinned', () async { await cache.put('srv', '/library/metadata/1', mediaContainer()); await cache.pinForOffline('srv', '1'); - expect(await cache.isPinned('srv', '1'), isTrue); + expect(await cache.isPinnedRatingKey('srv', '1'), isTrue); }); test('unpinForOffline reverts the pin', () async { await cache.put('srv', '/library/metadata/1', mediaContainer()); await cache.pinForOffline('srv', '1'); await cache.unpinForOffline('srv', '1'); - expect(await cache.isPinned('srv', '1'), isFalse); + expect(await cache.isPinnedRatingKey('srv', '1'), isFalse); }); test('pinForOffline on missing row is a no-op (no insert, no throw)', () async { await cache.pinForOffline('srv', 'missing'); - expect(await cache.isPinned('srv', 'missing'), isFalse); + expect(await cache.isPinnedRatingKey('srv', 'missing'), isFalse); }); test('getPinnedKeys extracts ratingKeys from pinned rows for the server', () async { @@ -244,7 +255,7 @@ void main() { final meta = await cache.getMetadata('srv', '42'); expect(meta, isNotNull); - expect(meta!.ratingKey, '42'); + expect(meta!.id, '42'); expect(meta.title, 'Hello'); expect(meta.serverId, 'srv'); }); diff --git a/test/services/plex_live_tv_support_test.dart b/test/services/plex_live_tv_support_test.dart new file mode 100644 index 00000000..dbdd772f --- /dev/null +++ b/test/services/plex_live_tv_support_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:drift/native.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/plex/plex_config.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.dart'; + +void main() { + late AppDatabase db; + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + test('favorite source follows requested lineup provider', () async { + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'tok', + clientIdentifier: 'client', + product: 'Plezy', + version: '1', + machineIdentifier: 'machine-1', + ), + serverId: 'machine-1', + httpClient: MockClient((_) async => http.Response('{}', 200)), + epgProviders: const [ + (identifier: 'provider-a', gridEndpoint: '/provider-a/grid'), + (identifier: 'provider-b', gridEndpoint: '/provider-b/grid'), + ], + ); + addTearDown(client.close); + + expect(await client.liveTv.buildFavoriteChannelSource(lineup: 'provider-b'), 'server://machine-1/provider-b'); + }); + + test('favorite store is account device scoped instead of token scoped', () { + PlexClient makeClient(String token) { + return PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: token, + clientIdentifier: 'account-device', + product: 'Plezy', + version: '1', + machineIdentifier: 'machine-1', + ), + serverId: 'machine-1', + httpClient: MockClient((_) async => http.Response('{}', 200)), + ); + } + + final a = makeClient('server-token-a'); + final b = makeClient('server-token-b'); + addTearDown(a.close); + addTearDown(b.close); + + expect(a.liveTv.favoriteStoreKey, b.liveTv.favoriteStoreKey); + }); +} diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart new file mode 100644 index 00000000..82057115 --- /dev/null +++ b/test/services/plex_mappers_test.dart @@ -0,0 +1,526 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/services/plex_mappers.dart'; + +const _serverId = 'plex-machine-1'; +const _serverName = 'Home'; + +void main() { + group('PlexMappers.mediaItem (movie)', () { + test('maps a Plex movie with watch state, ratings, genres, and people', () { + final json = { + 'ratingKey': '12345', + 'key': '/library/metadata/12345', + 'guid': 'plex://movie/5d776b59ad5437001f7be94b', + 'studio': 'Warner Bros.', + 'type': 'movie', + 'title': 'Inception', + 'titleSort': 'Inception', + 'originalTitle': 'Inception', + 'tagline': 'Your mind is the scene of the crime.', + 'contentRating': 'PG-13', + 'summary': 'Dom Cobb is a thief.', + 'rating': 8.8, + 'audienceRating': 9.1, + 'userRating': 9.5, + 'year': 2010, + 'originallyAvailableAt': '2010-07-16', + 'thumb': '/library/metadata/12345/thumb/1700000000', + 'art': '/library/metadata/12345/art/1700000000', + 'duration': 8880000, + 'addedAt': 1600000000, + 'updatedAt': 1700000000, + 'lastViewedAt': 1750000000, + 'viewOffset': 3000000, + 'viewCount': 1, + 'librarySectionID': 1, + 'librarySectionTitle': 'Movies', + 'ratingImage': 'rottentomatoes://image.rating.ripe', + 'audienceRatingImage': 'rottentomatoes://image.rating.upright', + 'Genre': [ + {'tag': 'Action'}, + {'tag': 'Sci-Fi'}, + ], + 'Director': [ + {'tag': 'Christopher Nolan'}, + ], + 'Writer': [ + {'tag': 'Christopher Nolan'}, + ], + 'Producer': [ + {'tag': 'Emma Thomas'}, + ], + 'Country': [ + {'tag': 'United States'}, + ], + 'Role': [ + {'id': 1, 'tag': 'Leonardo DiCaprio', 'role': 'Cobb', 'thumb': '/library/metadata/role/1/thumb'}, + {'id': 2, 'tag': 'Joseph Gordon-Levitt', 'role': 'Arthur'}, + ], + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId, serverName: _serverName); + + expect(item.id, '12345'); + expect(item.backend, MediaBackend.plex); + expect(item.kind, MediaKind.movie); + expect(item.guid, 'plex://movie/5d776b59ad5437001f7be94b'); + expect(item.title, 'Inception'); + expect(item.titleSort, 'Inception'); + expect(item.originalTitle, 'Inception'); + expect(item.tagline, 'Your mind is the scene of the crime.'); + expect(item.summary, 'Dom Cobb is a thief.'); + expect(item.studio, 'Warner Bros.'); + expect(item.year, 2010); + expect(item.originallyAvailableAt, '2010-07-16'); + expect(item.contentRating, 'PG-13'); + expect(item.rating, 8.8); + expect(item.audienceRating, 9.1); + expect(item.userRating, 9.5); + expect(item.ratingImage, 'rottentomatoes://image.rating.ripe'); + expect(item.audienceRatingImage, 'rottentomatoes://image.rating.upright'); + + // Plex stores all temporal fields in milliseconds — pass-through. + expect(item.durationMs, 8880000); + expect(item.viewOffsetMs, 3000000); + expect(item.viewCount, 1); + expect(item.lastViewedAt, 1750000000); + expect(item.addedAt, 1600000000); + expect(item.updatedAt, 1700000000); + + // Image paths kept relative — token-aware resolution lives on the client. + expect(item.thumbPath, '/library/metadata/12345/thumb/1700000000'); + expect(item.artPath, '/library/metadata/12345/art/1700000000'); + + // Tag lists from the heterogeneous `[{tag: ...}, ...]` shape. + expect(item.genres, ['Action', 'Sci-Fi']); + expect(item.directors, ['Christopher Nolan']); + expect(item.writers, ['Christopher Nolan']); + expect(item.producers, ['Emma Thomas']); + expect(item.countries, ['United States']); + + // Roles preserve role string and thumb path. + expect(item.roles, isNotNull); + expect(item.roles!.length, 2); + expect(item.roles![0].id, '1'); + expect(item.roles![0].tag, 'Leonardo DiCaprio'); + expect(item.roles![0].role, 'Cobb'); + expect(item.roles![0].thumbPath, '/library/metadata/role/1/thumb'); + expect(item.roles![1].thumbPath, isNull); + + // Library identification. + expect(item.libraryId, '1'); + expect(item.libraryTitle, 'Movies'); + + // Server-tagging. + expect(item.serverId, _serverId); + expect(item.serverName, _serverName); + }); + }); + + group('PlexMappers.mediaItem (show + season + episode)', () { + test('show preserves leaf counts and child counts', () { + final json = { + 'ratingKey': '500', + 'key': '/library/metadata/500', + 'type': 'show', + 'title': 'Breaking Bad', + 'leafCount': 62, + 'viewedLeafCount': 62, + 'childCount': 5, + 'year': 2008, + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + expect(item.kind, MediaKind.show); + expect(item.leafCount, 62); + expect(item.viewedLeafCount, 62); + expect(item.childCount, 5); + expect(item.isWatched, isTrue); + }); + + test('season carries parent (show) reference', () { + final json = { + 'ratingKey': '510', + 'type': 'season', + 'title': 'Season 1', + 'index': 1, + 'parentRatingKey': '500', + 'parentTitle': 'Breaking Bad', + 'parentThumb': '/library/metadata/500/thumb/1', + 'leafCount': 7, + 'viewedLeafCount': 3, + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + expect(item.kind, MediaKind.season); + expect(item.index, 1); + expect(item.parentId, '500'); + expect(item.parentTitle, 'Breaking Bad'); + expect(item.parentThumbPath, '/library/metadata/500/thumb/1'); + expect(item.leafCount, 7); + expect(item.viewedLeafCount, 3); + expect(item.isPartiallyWatched, isTrue); + }); + + test('episode carries parent (season) and grandparent (show) refs', () { + final json = { + 'ratingKey': '520', + 'type': 'episode', + 'title': 'Pilot', + 'index': 1, + 'parentIndex': 1, + 'parentRatingKey': '510', + 'parentTitle': 'Season 1', + 'parentThumb': '/library/metadata/510/thumb/1', + 'grandparentRatingKey': '500', + 'grandparentTitle': 'Breaking Bad', + 'grandparentThumb': '/library/metadata/500/thumb/1', + 'grandparentArt': '/library/metadata/500/art/1', + 'duration': 2820000, + 'viewOffset': 1410000, + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + expect(item.kind, MediaKind.episode); + expect(item.index, 1); + expect(item.parentIndex, 1); + expect(item.parentId, '510'); + expect(item.parentTitle, 'Season 1'); + expect(item.parentThumbPath, '/library/metadata/510/thumb/1'); + expect(item.grandparentId, '500'); + expect(item.grandparentTitle, 'Breaking Bad'); + expect(item.grandparentThumbPath, '/library/metadata/500/thumb/1'); + expect(item.grandparentArtPath, '/library/metadata/500/art/1'); + expect(item.durationMs, 2820000); + expect(item.viewOffsetMs, 1410000); + }); + }); + + group('PlexMappers.mediaItem (music)', () { + test('album preserves studio and parent (artist) reference', () { + final json = { + 'ratingKey': '700', + 'type': 'album', + 'title': 'Random Access Memories', + 'parentRatingKey': '699', + 'parentTitle': 'Daft Punk', + 'studio': 'Columbia Records', + 'year': 2013, + 'leafCount': 13, + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + expect(item.kind, MediaKind.album); + expect(item.title, 'Random Access Memories'); + expect(item.parentId, '699'); + expect(item.parentTitle, 'Daft Punk'); + expect(item.studio, 'Columbia Records'); + expect(item.year, 2013); + expect(item.leafCount, 13); + }); + + test('track maps "audio" type to MediaKind.track', () { + final json = { + 'ratingKey': '710', + 'type': 'track', + 'title': 'Get Lucky', + 'index': 8, + 'parentRatingKey': '700', + 'parentTitle': 'Random Access Memories', + 'grandparentRatingKey': '699', + 'grandparentTitle': 'Daft Punk', + 'duration': 369000, + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + expect(item.kind, MediaKind.track); + expect(item.title, 'Get Lucky'); + expect(item.index, 8); + expect(item.durationMs, 369000); + expect(item.parentId, '700'); + expect(item.parentTitle, 'Random Access Memories'); + expect(item.grandparentId, '699'); + expect(item.grandparentTitle, 'Daft Punk'); + }); + }); + + group('PlexMappers.mediaItem (media versions + image arrays)', () { + test('Media + Part list yields a MediaVersion with one MediaPart', () { + final json = { + 'ratingKey': '12345', + 'type': 'movie', + 'title': 'Inception', + 'Media': [ + { + 'id': 1, + 'videoResolution': '1080', + 'videoCodec': 'h264', + 'bitrate': 8000, + 'width': 1920, + 'height': 1080, + 'container': 'mkv', + 'Part': [ + {'key': '/library/parts/1/file.mkv'}, + ], + }, + ], + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + expect(item.mediaVersions, isNotNull); + final v = item.mediaVersions!.single; + expect(v.id, '1'); + expect(v.videoResolution, '1080'); + expect(v.videoCodec, 'h264'); + expect(v.bitrate, 8000); + expect(v.width, 1920); + expect(v.height, 1080); + expect(v.container, 'mkv'); + expect(v.parts.single.streamPath, '/library/parts/1/file.mkv'); + }); + + test('Image array (clearLogo, backgroundSquare) is hoisted onto top-level fields', () { + final json = { + 'ratingKey': '12345', + 'type': 'movie', + 'title': 'Inception', + 'Image': [ + {'type': 'clearLogo', 'url': '/library/metadata/12345/clearLogo'}, + {'type': 'backgroundSquare', 'url': '/library/metadata/12345/squareBg'}, + {'type': 'snapshot', 'url': '/library/metadata/12345/snap'}, + ], + }; + + final item = PlexMappers.mediaItemFromJson(json, serverId: _serverId); + expect(item.clearLogoPath, '/library/metadata/12345/clearLogo'); + expect(item.backgroundSquarePath, '/library/metadata/12345/squareBg'); + }); + }); + + group('PlexMappers.mediaLibrary', () { + test('library Directory entry maps to MediaLibrary with kind from type', () { + final json = { + 'key': '1', + 'title': 'Movies', + 'type': 'movie', + 'agent': 'tv.plex.agents.movie', + 'language': 'en-US', + 'updatedAt': 1700000000, + 'createdAt': 1600000000, + 'hidden': 0, + }; + + final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId, serverName: _serverName); + expect(lib.id, '1'); + expect(lib.backend, MediaBackend.plex); + expect(lib.title, 'Movies'); + expect(lib.kind, MediaKind.movie); + expect(lib.language, 'en-US'); + expect(lib.updatedAt, 1700000000); + expect(lib.createdAt, 1600000000); + expect(lib.hidden, isFalse); + expect(lib.isShared, isFalse); + expect(lib.serverId, _serverId); + expect(lib.serverName, _serverName); + }); + + test('shared library is marked isShared', () { + final json = {'key': 'shared', 'title': 'Shared with you', 'type': 'movie'}; + final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId, isShared: true); + expect(lib.isShared, isTrue); + }); + + test('hidden=1 maps to true', () { + final json = {'key': '2', 'title': 'Hidden', 'type': 'show', 'hidden': 1}; + final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId); + expect(lib.hidden, isTrue); + }); + + test('library with missing title/type falls back to empty strings', () { + // Past regression: bare `as String` casts on title/type in + // PlexLibraryDto.fromJson would throw TypeError when Plex omitted + // either field. Confirms graceful degradation. + final json = {'key': '99'}; + final lib = PlexMappers.mediaLibraryFromJson(json, serverId: _serverId); + expect(lib.id, '99'); + expect(lib.title, ''); + expect(lib.kind, MediaKind.unknown); + }); + }); + + group('PlexMappers.mediaHub', () { + test('hub with mixed-type items maps to MediaHub with neutral items', () { + final json = { + 'key': '/hubs/movie.recentlyAdded', + 'title': 'Recently Added Movies', + 'type': 'movie', + 'hubIdentifier': 'movie.recentlyAdded.1', + 'size': 2, + 'more': true, + 'Metadata': [ + {'ratingKey': '1', 'type': 'movie', 'title': 'Movie A'}, + {'ratingKey': '2', 'type': 'movie', 'title': 'Movie B'}, + ], + }; + + final hub = PlexMappers.mediaHubFromJson(json, serverId: _serverId, serverName: _serverName); + expect(hub.id, '/hubs/movie.recentlyAdded'); + expect(hub.identifier, 'movie.recentlyAdded.1'); + expect(hub.title, 'Recently Added Movies'); + expect(hub.type, 'movie'); + expect(hub.size, 2); + expect(hub.more, isTrue); + expect(hub.items.length, 2); + expect(hub.items[0].title, 'Movie A'); + expect(hub.items[0].kind, MediaKind.movie); + expect(hub.items[1].title, 'Movie B'); + expect(hub.items[0].serverId, _serverId); + expect(hub.items[0].serverName, _serverName); + expect(hub.serverId, _serverId); + expect(hub.serverName, _serverName); + }); + + test('Directory entries without type are inferred (folder vs show)', () { + final json = { + 'key': '/hubs/foo', + 'title': 'Foo', + 'type': 'mixed', + 'Directory': [ + // Has leafCount → looks like a show + {'ratingKey': '10', 'title': 'A Show', 'leafCount': 24}, + // Plain folder + {'ratingKey': '11', 'title': 'A Folder'}, + ], + }; + + final hub = PlexMappers.mediaHubFromJson(json, serverId: _serverId); + expect(hub.items.length, 2); + expect(hub.items[0].kind, MediaKind.show); + expect(hub.items[1].kind, MediaKind.unknown); + }); + + test('parses Metadata + Directory together', () { + final json = { + 'key': '/hubs/foo', + 'title': 'Foo', + 'type': 'mixed', + 'Metadata': [ + {'ratingKey': '1', 'type': 'movie', 'title': 'Movie A'}, + ], + 'Directory': [ + {'ratingKey': '10', 'type': 'show', 'title': 'Show B'}, + ], + }; + + final hub = PlexMappers.mediaHubFromJson(json, serverId: _serverId); + expect(hub.items.length, 2); + expect(hub.items[0].kind, MediaKind.movie); + expect(hub.items[1].kind, MediaKind.show); + }); + }); + + group('PlexMappers.mediaPlaylist', () { + test('video playlist maps with summary and counts', () { + final json = { + 'ratingKey': '999', + 'key': '/playlists/999/items', + 'type': 'playlist', + 'title': 'Date Night', + 'summary': 'Movies for date night', + 'smart': false, + 'playlistType': 'video', + 'duration': 14400000, + 'leafCount': 5, + 'composite': '/playlists/999/composite/1700000000', + 'addedAt': 1600000000, + 'updatedAt': 1700000000, + 'lastViewedAt': 1750000000, + 'viewCount': 3, + 'thumb': '/playlists/999/thumb', + }; + + final p = PlexMappers.mediaPlaylistFromJson(json, serverId: _serverId, serverName: _serverName); + expect(p.id, '999'); + expect(p.backend, MediaBackend.plex); + expect(p.title, 'Date Night'); + expect(p.summary, 'Movies for date night'); + expect(p.smart, isFalse); + expect(p.playlistType, 'video'); + expect(p.durationMs, 14400000); + expect(p.leafCount, 5); + expect(p.viewCount, 3); + expect(p.addedAt, 1600000000); + expect(p.updatedAt, 1700000000); + expect(p.lastViewedAt, 1750000000); + expect(p.compositeImagePath, '/playlists/999/composite/1700000000'); + expect(p.thumbPath, '/playlists/999/thumb'); + expect(p.serverId, _serverId); + expect(p.serverName, _serverName); + }); + + test('smart playlist preserves the smart flag', () { + final json = { + 'ratingKey': '888', + 'key': '/playlists/888/items', + 'type': 'playlist', + 'title': 'Recently Added', + 'smart': true, + 'playlistType': 'audio', + }; + + final p = PlexMappers.mediaPlaylistFromJson(json, serverId: _serverId); + expect(p.smart, isTrue); + expect(p.playlistType, 'audio'); + }); + + test('playlist with missing key/type/smart/playlistType falls back to safe defaults', () { + // Past regression: bare `as String` / `as bool` casts in + // PlexPlaylistDto.fromJson would throw TypeError when Plex omitted + // optional fields. Confirms graceful degradation. + final json = {'ratingKey': '777', 'title': 'Bare', 'summary': null}; + final p = PlexMappers.mediaPlaylistFromJson(json, serverId: _serverId); + expect(p.id, '777'); + expect(p.title, 'Bare'); + expect(p.smart, isFalse); + expect(p.playlistType, ''); + }); + }); + + group('PlexMappers DTO direct entry points', () { + test('mediaItem (DTO) preserves data identical to JSON path', () { + final json = {'ratingKey': '1', 'type': 'movie', 'title': 'Test', 'year': 2024}; + final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: _serverId); + final item = PlexMappers.mediaItem(dto); + expect(item.id, '1'); + expect(item.title, 'Test'); + expect(item.year, 2024); + expect(item.serverId, _serverId); + }); + + test('mediaVersion (DTO) maps version + part', () { + final json = { + 'id': 42, + 'videoResolution': '4k', + 'videoCodec': 'hevc', + 'bitrate': 25000, + 'width': 3840, + 'height': 2160, + 'container': 'mp4', + 'Part': [ + {'key': '/library/parts/42/file.mp4'}, + ], + }; + final v = PlexMappers.mediaVersionFromJson(json); + expect(v.id, '42'); + expect(v.videoResolution, '4k'); + expect(v.videoCodec, 'hevc'); + expect(v.bitrate, 25000); + expect(v.width, 3840); + expect(v.height, 2160); + expect(v.container, 'mp4'); + expect(v.parts.single.streamPath, '/library/parts/42/file.mp4'); + }); + }); +} diff --git a/test/services/server_registry_test.dart b/test/services/server_registry_test.dart index d6a00e4f..ce8b8c15 100644 --- a/test/services/server_registry_test.dart +++ b/test/services/server_registry_test.dart @@ -61,7 +61,7 @@ void main() { registry = ServerRegistry(storage); } - group('getServers', () { + group('getServers (legacy migration read)', () { test('returns empty list when no servers JSON is set', () async { await bootstrap(); expect(await registry.getServers(), isEmpty); @@ -69,177 +69,30 @@ void main() { test('returns empty list for empty-string JSON', () async { await bootstrap(); - await storage.saveServersListJson(''); + await storage.prefs.setString('servers_list', ''); expect(await registry.getServers(), isEmpty); }); test('returns empty list when stored JSON is malformed', () async { await bootstrap(); - await storage.saveServersListJson('not-valid-json'); + await storage.prefs.setString('servers_list', 'not-valid-json'); // Corrupt JSON is logged and treated as no servers, NOT thrown. expect(await registry.getServers(), isEmpty); }); - test('parses a list of servers from saved JSON', () async { + test('parses a list of servers from raw JSON written under the legacy key', () async { await bootstrap(); final s1 = _server(clientIdentifier: 'a'); final s2 = _server(clientIdentifier: 'b', name: 'Other'); - await registry.saveServers([s1, s2]); + await storage.prefs.setString('servers_list', jsonEncode([s1.toJson(), s2.toJson()])); final loaded = await registry.getServers(); expect(loaded.map((s) => s.clientIdentifier).toList(), ['a', 'b']); expect(loaded.first.name, 'Home Server'); expect(loaded.last.name, 'Other'); }); - }); - group('saveServers', () { - test('overwrites stored JSON with the latest list', () async { - await bootstrap(); - await registry.saveServers([_server(clientIdentifier: 'a')]); - await registry.saveServers([_server(clientIdentifier: 'b'), _server(clientIdentifier: 'c', name: 'Cee')]); - - final loaded = await registry.getServers(); - expect(loaded.map((s) => s.clientIdentifier).toList(), ['b', 'c']); - }); - - test('saving empty list yields empty getServers', () async { - await bootstrap(); - await registry.saveServers([_server(clientIdentifier: 'a')]); - await registry.saveServers([]); - expect(await registry.getServers(), isEmpty); - }); - - test('persists JSON in a shape parseable by PlexServer.fromJson', () async { - await bootstrap(); - final s = _server(clientIdentifier: 'srv-z', name: 'Zee'); - await registry.saveServers([s]); - - final raw = storage.getServersListJson(); - expect(raw, isNotNull); - final decoded = jsonDecode(raw!) as List; - expect(decoded, hasLength(1)); - - final parsed = PlexServer.fromJson(decoded.first as Map); - expect(parsed.clientIdentifier, 'srv-z'); - expect(parsed.name, 'Zee'); - }); - }); - - group('getServer', () { - test('returns matching server', () async { - await bootstrap(); - await registry.saveServers([_server(clientIdentifier: 'a'), _server(clientIdentifier: 'b', name: 'Bee')]); - final found = await registry.getServer('b'); - expect(found, isNotNull); - expect(found!.name, 'Bee'); - }); - - test('returns null when id is unknown', () async { - await bootstrap(); - await registry.saveServers([_server(clientIdentifier: 'a')]); - expect(await registry.getServer('missing'), isNull); - }); - - test('returns null when no servers are stored', () async { - await bootstrap(); - expect(await registry.getServer('anything'), isNull); - }); - }); - - group('upsertServer', () { - test('adds a new server when id is not present', () async { - await bootstrap(); - await registry.upsertServer(_server(clientIdentifier: 'a')); - final servers = await registry.getServers(); - expect(servers, hasLength(1)); - expect(servers.first.clientIdentifier, 'a'); - }); - - test('updates an existing server in place (preserves order)', () async { - await bootstrap(); - await registry.saveServers([ - _server(clientIdentifier: 'a', name: 'Original A'), - _server(clientIdentifier: 'b', name: 'Bee'), - _server(clientIdentifier: 'c', name: 'Cee'), - ]); - - await registry.upsertServer(_server(clientIdentifier: 'b', name: 'Updated B')); - - final servers = await registry.getServers(); - expect(servers.map((s) => s.clientIdentifier).toList(), ['a', 'b', 'c']); - expect(servers[1].name, 'Updated B'); - expect(servers[0].name, 'Original A'); - }); - - test('appends new servers in insertion order', () async { - await bootstrap(); - await registry.upsertServer(_server(clientIdentifier: 'a')); - await registry.upsertServer(_server(clientIdentifier: 'b')); - await registry.upsertServer(_server(clientIdentifier: 'c')); - - final servers = await registry.getServers(); - expect(servers.map((s) => s.clientIdentifier).toList(), ['a', 'b', 'c']); - }); - }); - - group('removeServer', () { - test('removes only the matching server', () async { - await bootstrap(); - await registry.saveServers([ - _server(clientIdentifier: 'a'), - _server(clientIdentifier: 'b'), - _server(clientIdentifier: 'c'), - ]); - await registry.removeServer('b'); - final servers = await registry.getServers(); - expect(servers.map((s) => s.clientIdentifier).toList(), ['a', 'c']); - }); - - test('removing an unknown id is a no-op', () async { - await bootstrap(); - await registry.saveServers([_server(clientIdentifier: 'a')]); - await registry.removeServer('missing'); - final servers = await registry.getServers(); - expect(servers.map((s) => s.clientIdentifier).toList(), ['a']); - }); - - test('removing on empty list is a no-op', () async { - await bootstrap(); - await registry.removeServer('a'); - expect(await registry.getServers(), isEmpty); - }); - }); - - group('clearAllServers', () { - test('clears the underlying servers list JSON', () async { - await bootstrap(); - await registry.saveServers([_server(clientIdentifier: 'a'), _server(clientIdentifier: 'b')]); - - await registry.clearAllServers(); - - expect(await registry.getServers(), isEmpty); - expect(storage.getServersListJson(), isNull); - }); - }); - - group('refreshServersFromApi', () { - test('returns noToken when no Plex token is stored', () async { - await bootstrap(); - final result = await registry.refreshServersFromApi(); - expect(result, ServerRefreshResult.noToken); - }); - - test('returns noToken for empty Plex token', () async { - await bootstrap(); - await storage.savePlexToken(''); - final result = await registry.refreshServersFromApi(); - expect(result, ServerRefreshResult.noToken); - }); - }); - - group('Round-trip via raw storage', () { - test('saveServers preserves all PlexServer fields after re-read', () async { + test('preserves all PlexServer fields after re-read from raw JSON', () async { await bootstrap(); final original = _server( clientIdentifier: 'rt', @@ -255,7 +108,7 @@ void main() { ], ); - await registry.saveServers([original]); + await storage.prefs.setString('servers_list', jsonEncode([original.toJson()])); final loaded = (await registry.getServers()).single; expect(loaded.name, original.name); diff --git a/test/services/settings_export_service_test.dart b/test/services/settings_export_service_test.dart index 1139c835..af59ca2e 100644 --- a/test/services/settings_export_service_test.dart +++ b/test/services/settings_export_service_test.dart @@ -81,7 +81,9 @@ void main() { await prefs.setString('plex_token', 'abc'); await prefs.setString('client_identifier', 'xyz'); await prefs.setString('current_user_uuid', 'user-1'); + await prefs.setString('active_app_profile_id', 'profile-1'); await prefs.setString('user_profile', '{}'); + await prefs.setString('credential_vault_key_v1', 'base64-key'); // Plus a good-faith key that should stay. await prefs.setBool('keep_me', true); @@ -91,16 +93,20 @@ void main() { expect(p, isNot(contains('plex_token'))); expect(p, isNot(contains('client_identifier'))); expect(p, isNot(contains('current_user_uuid'))); + expect(p, isNot(contains('active_app_profile_id'))); expect(p, isNot(contains('user_profile'))); + expect(p, isNot(contains('credential_vault_key_v1'))); expect(p, contains('keep_me')); }); - test('drops prefix-deny keys (server_endpoint_, episode_count_, watched_threshold_, trakt_)', () async { + test('drops prefix-deny keys', () async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setString('server_endpoint_srv1', 'http://x'); await prefs.setInt('episode_count_show42', 24); await prefs.setInt('watched_threshold_srv1', 95); await prefs.setString('trakt_access_token', 'secret'); + await prefs.setString('plex_home_users_conn-1', '[{"title":"Kid"}]'); + await prefs.setInt('profile_last_used_profile-1', 123); // The trakt feature flag uses a different prefix and SHOULD survive. await prefs.setBool('enable_trakt_scrobble', true); @@ -111,6 +117,8 @@ void main() { expect(p, isNot(contains('episode_count_show42'))); expect(p, isNot(contains('watched_threshold_srv1'))); expect(p, isNot(contains('trakt_access_token'))); + expect(p, isNot(contains('plex_home_users_conn-1'))); + expect(p, isNot(contains('profile_last_used_profile-1'))); expect(p, contains('enable_trakt_scrobble')); }); @@ -366,7 +374,11 @@ void main() { 'formatVersion': SettingsExportService.formatVersion, 'prefs': { 'plex_token': {'type': 'string', 'value': 'malicious'}, + 'credential_vault_key_v1': {'type': 'string', 'value': 'attacker-key'}, + 'active_app_profile_id': {'type': 'string', 'value': 'stale-profile'}, 'server_endpoint_srv': {'type': 'string', 'value': 'http://attacker.test'}, + 'plex_home_users_conn': {'type': 'string', 'value': '[]'}, + 'profile_last_used_stale': {'type': 'int', 'value': 1}, 'good_key': {'type': 'bool', 'value': true}, }, }, @@ -375,9 +387,13 @@ void main() { ); expect(result.keysImported, 1); - expect(result.keysSkipped, 2); + expect(result.keysSkipped, 6); expect(prefs.getString('plex_token'), isNull); + expect(prefs.getString('credential_vault_key_v1'), isNull); + expect(prefs.getString('active_app_profile_id'), isNull); expect(prefs.getString('server_endpoint_srv'), isNull); + expect(prefs.getString('plex_home_users_conn'), isNull); + expect(prefs.getInt('profile_last_used_stale'), isNull); expect(prefs.getBool('good_key'), isTrue); }); }); diff --git a/test/services/storage_service_test.dart b/test/services/storage_service_test.dart index e20de3d7..a2074911 100644 --- a/test/services/storage_service_test.dart +++ b/test/services/storage_service_test.dart @@ -18,52 +18,45 @@ void main() { test('reset rebuilds against current SharedPreferences', () async { final first = await StorageService.getInstance(); - await first.savePlexToken('token-1'); + await first.prefs.setString('plex_token', 'token-1'); BaseSharedPreferencesService.resetForTesting(); final second = await StorageService.getInstance(); expect(identical(first, second), isFalse); // Reset only the cached singleton, not the underlying prefs — values survive. + // ignore: deprecated_member_use_from_same_package expect(second.getPlexToken(), 'token-1'); }); }); // ============================================================ - // Plex token / client identifier + // Plex token / client identifier (legacy, retained for migration) // ============================================================ - group('PlexToken & ClientIdentifier', () { - test('savePlexToken persists value', () async { + group('PlexToken & ClientIdentifier (legacy migration slots)', () { + test('getPlexToken reads the legacy slot', () async { final s = await StorageService.getInstance(); + // ignore: deprecated_member_use_from_same_package expect(s.getPlexToken(), isNull); - await s.savePlexToken('abc-123'); + await s.prefs.setString('plex_token', 'abc-123'); + // ignore: deprecated_member_use_from_same_package expect(s.getPlexToken(), 'abc-123'); }); - test('saveClientIdentifier persists value', () async { - final s = await StorageService.getInstance(); - expect(s.getClientIdentifier(), isNull); - await s.saveClientIdentifier('client-xyz'); - expect(s.getClientIdentifier(), 'client-xyz'); - }); - test('getOrCreateClientIdentifier returns existing value when set', () async { final s = await StorageService.getInstance(); - await s.saveClientIdentifier('preset-id'); + await s.prefs.setString('client_identifier', 'preset-id'); final result = await s.getOrCreateClientIdentifier(); expect(result, 'preset-id'); - expect(s.getClientIdentifier(), 'preset-id'); }); test('getOrCreateClientIdentifier generates and persists a UUID on first call', () async { final s = await StorageService.getInstance(); - expect(s.getClientIdentifier(), isNull); final generated = await s.getOrCreateClientIdentifier(); expect(generated, isNotEmpty); // UUIDv4 has 5 hyphen-separated segments. expect(generated.split('-'), hasLength(5)); - expect(s.getClientIdentifier(), generated); // Second call returns the same value, not a new UUID. final again = await s.getOrCreateClientIdentifier(); @@ -72,10 +65,9 @@ void main() { test('getOrCreateClientIdentifier replaces empty stored value', () async { final s = await StorageService.getInstance(); - await s.saveClientIdentifier(''); + await s.prefs.setString('client_identifier', ''); final generated = await s.getOrCreateClientIdentifier(); expect(generated, isNotEmpty); - expect(s.getClientIdentifier(), generated); }); }); @@ -105,47 +97,40 @@ void main() { }); // ============================================================ - // Multi-server JSON list & order + // Multi-server slot (legacy, only `getServersListJson` retained for migration) // ============================================================ - group('Servers list & order', () { - test('servers list JSON round-trips', () async { + group('Servers list (legacy migration slot)', () { + test('legacy raw read returns null when nothing is stored', () async { final s = await StorageService.getInstance(); + // ignore: deprecated_member_use_from_same_package expect(s.getServersListJson(), isNull); - const payload = '[{"name":"home"}]'; - await s.saveServersListJson(payload); - expect(s.getServersListJson(), payload); }); test('clearServersList removes the value', () async { final s = await StorageService.getInstance(); - await s.saveServersListJson('[{"x":1}]'); + // Write directly under the legacy key — the public setter is gone. + await s.prefs.setString('servers_list', '[{"x":1}]'); + // ignore: deprecated_member_use_from_same_package + expect(s.getServersListJson(), '[{"x":1}]'); await s.clearServersList(); + // ignore: deprecated_member_use_from_same_package expect(s.getServersListJson(), isNull); }); - test('server order round-trips and clears', () async { + test('clearMultiServerData clears legacy list + order + endpoint prefixes', () async { final s = await StorageService.getInstance(); - expect(s.getServerOrder(), isNull); - - await s.saveServerOrder(['srv-2', 'srv-1', 'srv-3']); - expect(s.getServerOrder(), ['srv-2', 'srv-1', 'srv-3']); - - await s.clearServerOrder(); - expect(s.getServerOrder(), isNull); - }); - - test('clearMultiServerData clears list + order + endpoint prefixes', () async { - final s = await StorageService.getInstance(); - await s.saveServersListJson('[{"x":1}]'); - await s.saveServerOrder(['a', 'b']); + // Write legacy values directly — the setters are gone. + await s.prefs.setString('servers_list', '[{"x":1}]'); + await s.prefs.setString('server_order', json.encode(['a', 'b'])); await s.saveServerEndpoint('a', 'http://foo.test'); await s.saveServerEndpoint('b', 'http://bar.test'); await s.clearMultiServerData(); + // ignore: deprecated_member_use_from_same_package expect(s.getServersListJson(), isNull); - expect(s.getServerOrder(), isNull); + expect(s.prefs.getString('server_order'), isNull); expect(s.getServerEndpoint('a'), isNull); expect(s.getServerEndpoint('b'), isNull); }); @@ -190,7 +175,7 @@ void main() { }); // ============================================================ - // Library order (List) + // Library order (List) — scoped to active profile // ============================================================ group('Library order', () { @@ -205,40 +190,78 @@ void main() { expect(s.getLibraryOrder(), ['c', 'a', 'b']); }); - test('legacy unscoped value migrates into scoped key when user UUID is set', () async { + test('legacy unscoped value migrates into scoped key when an active profile is set', () async { final s = await StorageService.getInstance(); // Write a legacy (unscoped) library order, mimicking pre-multi-user data. await s.prefs.setString('library_order', json.encode(['x', 'y'])); - // Set a current user UUID so reads/writes become scoped. - await s.saveCurrentUserUUID('user-1'); + // Set an active profile so reads/writes become scoped. + await s.setActiveProfileId('local-user-1'); final read = s.getLibraryOrder(); expect(read, ['x', 'y']); // Migration should have copied the legacy value under the scoped key. - final scopedRaw = s.prefs.getString('user_user-1_library_order'); + final scopedRaw = s.prefs.getString('user_local-user-1_library_order'); expect(scopedRaw, json.encode(['x', 'y'])); + expect(s.prefs.getString('library_order'), isNull); + }); + + test('migrated legacy order is not inherited by another profile', () async { + final s = await StorageService.getInstance(); + await s.prefs.setString('library_order', json.encode(['legacy'])); + + await s.setActiveProfileId('local-user-1'); + expect(s.getLibraryOrder(), ['legacy']); + + await s.setActiveProfileId('local-user-2'); + expect(s.getLibraryOrder(), isNull); }); test('per-user scoping isolates orders', () async { final s = await StorageService.getInstance(); - await s.saveCurrentUserUUID('user-1'); + await s.setActiveProfileId('local-user-1'); await s.saveLibraryOrder(['u1-a', 'u1-b']); - await s.saveCurrentUserUUID('user-2'); + await s.setActiveProfileId('local-user-2'); expect(s.getLibraryOrder(), isNull); await s.saveLibraryOrder(['u2-a']); // Switch back — user-1 sees their own list. - await s.saveCurrentUserUUID('user-1'); + await s.setActiveProfileId('local-user-1'); expect(s.getLibraryOrder(), ['u1-a', 'u1-b']); - await s.saveCurrentUserUUID('user-2'); + await s.setActiveProfileId('local-user-2'); expect(s.getLibraryOrder(), ['u2-a']); }); + + test('plex_home profile id parses out the home-user UUID for the prefix', () async { + final s = await StorageService.getInstance(); + // Format: `plex-home-{accountConnectionId}-{homeUserUuid}` where both + // the accountConnectionId AND the UUID contain hyphens. The scope must + // be the FULL 36-char UUID — `lastIndexOf('-')` would slice inside the + // UUID and break legacy `currentUserUUID`-scoped storage migration. + await s.setActiveProfileId('plex-home-plex.abc-def-123-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + await s.saveLibraryOrder(['x']); + expect(s.prefs.getString('user_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_library_order'), json.encode(['x'])); + }); + + test('legacy currentUserUUID scope migrates into the plex-home profile slot', () async { + // Regression: with the old `lastIndexOf('-')` parser, the scope was + // only the trailing 12 hex chars of the UUID, so the per-user prefs + // written under the legacy `currentUserUUID` (which used the FULL + // UUID as the prefix) would not be picked up after migration. + final s = await StorageService.getInstance(); + const uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + // Pre-seed the legacy per-user-scoped order under the full UUID. + await s.prefs.setString('user_${uuid}_library_order', json.encode(['legacy'])); + + await s.setActiveProfileId('plex-home-plex.abc-def-123-$uuid'); + expect(s.getLibraryOrder(), ['legacy']); + }); }); // ============================================================ @@ -263,6 +286,19 @@ void main() { expect(s.getLibraryFilters(), {'global': 'true'}); }); + test('legacy per-section filters migrate once into scoped key', () async { + final s = await StorageService.getInstance(); + await s.prefs.setString('library_filters_sec-1', json.encode({'genre': 'drama'})); + + await s.setActiveProfileId('local-user-1'); + expect(s.getLibraryFilters(sectionId: 'sec-1'), {'genre': 'drama'}); + expect(s.prefs.getString('user_local-user-1_library_filters_sec-1'), json.encode({'genre': 'drama'})); + expect(s.prefs.getString('library_filters_sec-1'), isNull); + + await s.setActiveProfileId('local-user-2'); + expect(s.getLibraryFilters(sectionId: 'sec-1'), isEmpty); + }); + test('library sort round-trips with descending flag', () async { final s = await StorageService.getInstance(); await s.saveLibrarySort('sec-1', 'titleSort', descending: true); @@ -304,59 +340,21 @@ void main() { }); // ============================================================ - // User profile / UUID + // Current user UUID (legacy slot retained for migration) // ============================================================ - group('User profile & UUID', () { - test('saveUserProfile + getUserProfile round-trip preserves nested data', () async { + group('CurrentUserUUID (legacy migration slot)', () { + test('clearCurrentUserUUID wipes the slot', () async { final s = await StorageService.getInstance(); - expect(s.getUserProfile(), isNull); - final profile = {'id': 1, 'username': 'edde', 'email': 'e@example.test'}; - await s.saveUserProfile(profile); - expect(s.getUserProfile(), profile); - }); - - test('saveCurrentUserUUID + clearCurrentUserUUID', () async { - final s = await StorageService.getInstance(); - await s.saveCurrentUserUUID('u-1'); + await s.prefs.setString('current_user_uuid', 'u-1'); + // ignore: deprecated_member_use_from_same_package expect(s.getCurrentUserUUID(), 'u-1'); await s.clearCurrentUserUUID(); + // ignore: deprecated_member_use_from_same_package expect(s.getCurrentUserUUID(), isNull); }); }); - // ============================================================ - // Home users cache (TTL) - // ============================================================ - - group('Home users cache', () { - test('saved cache is readable while non-expired', () async { - final s = await StorageService.getInstance(); - await s.saveHomeUsersCache({'users': []}); - expect(s.getHomeUsersCache(), {'users': []}); - }); - - test('expired cache returns null and self-clears', () async { - final s = await StorageService.getInstance(); - await s.saveHomeUsersCache({'users': []}); - // Force-expire the cache by writing a past timestamp under the expiry key. - await s.prefs.setInt('home_users_cache_expiry', DateTime.now().millisecondsSinceEpoch - 1000); - expect(s.getHomeUsersCache(), isNull); - // After self-clear, both keys are gone. - expect(s.prefs.getString('home_users_cache'), isNull); - expect(s.prefs.getInt('home_users_cache_expiry'), isNull); - }); - - test('clearHomeUsersCache removes both data and expiry', () async { - final s = await StorageService.getInstance(); - await s.saveHomeUsersCache({'users': []}); - await s.clearHomeUsersCache(); - expect(s.getHomeUsersCache(), isNull); - expect(s.prefs.getString('home_users_cache'), isNull); - expect(s.prefs.getInt('home_users_cache_expiry'), isNull); - }); - }); - // ============================================================ // Episode count persistence (prefix-based) // ============================================================ @@ -376,7 +374,7 @@ void main() { await s.saveTotalEpisodeCount('srv:s1', 1); await s.saveTotalEpisodeCount('srv:s2', 2); // Unrelated keys must not bleed in. - await s.savePlexToken('tok'); + await s.prefs.setString('plex_token', 'tok'); final counts = s.loadAllEpisodeCounts(); expect(counts, {'srv:s1': 1, 'srv:s2': 2}); @@ -400,37 +398,38 @@ void main() { test('removes credential keys, plex token, and multi-server data', () async { final s = await StorageService.getInstance(); - await s.savePlexToken('tok-x'); - await s.saveClientIdentifier('client-x'); - await s.saveUserProfile({'id': 99}); - await s.saveHomeUsersCache({'users': []}); - await s.saveServersListJson('[{"x":1}]'); - await s.saveServerOrder(['a']); + // Seed every legacy slot directly — the runtime setters were removed + // when we collapsed the legacy/new dual-write. + await s.prefs.setString('plex_token', 'tok-x'); + await s.prefs.setString('client_identifier', 'client-x'); + await s.prefs.setString('servers_list', '[{"x":1}]'); + await s.prefs.setString('server_order', json.encode(['a'])); await s.saveServerEndpoint('a', 'http://foo.test'); - // Library prefs and unrelated counters: write WITHOUT a current-user UUID - // so they land on the legacy unscoped key. The credentials clear path - // wipes current_user_uuid; we want to confirm library data is untouched. + // Library prefs and unrelated counters: write WITHOUT an active profile id + // so they land on the legacy unscoped key. await s.saveLibraryOrder(['lib-1']); await s.saveTotalEpisodeCount('srv:s1', 7); - // Now set a user UUID — clearCredentials should remove this. - await s.saveCurrentUserUUID('u-x'); + // Now seed current_user_uuid — clearCredentials should remove this. + await s.prefs.setString('current_user_uuid', 'u-x'); await s.clearCredentials(); // Credential-bucket keys all gone. + // ignore: deprecated_member_use_from_same_package expect(s.getPlexToken(), isNull); - expect(s.getClientIdentifier(), isNull); + expect(s.prefs.getString('client_identifier'), isNull); + // ignore: deprecated_member_use_from_same_package expect(s.getCurrentUserUUID(), isNull); - expect(s.getUserProfile(), isNull); // Multi-server data wiped. + // ignore: deprecated_member_use_from_same_package expect(s.getServersListJson(), isNull); - expect(s.getServerOrder(), isNull); + expect(s.prefs.getString('server_order'), isNull); expect(s.getServerEndpoint('a'), isNull); - // Library prefs and unrelated state untouched (user UUID is gone, so + // Library prefs and unrelated state untouched (no scope active, so // the scoped read falls through to the same legacy key it was written to). expect(s.getLibraryOrder(), ['lib-1']); expect(s.getTotalEpisodeCount('srv:s1'), 7); @@ -446,7 +445,7 @@ void main() { final s = await StorageService.getInstance(); // user-1's library prefs - await s.saveCurrentUserUUID('user-1'); + await s.setActiveProfileId('local-user-1'); await s.saveLibraryOrder(['u1-a', 'u1-b']); await s.saveSelectedLibraryKey('u1-key'); await s.saveLibraryFilters({'genre': 'horror'}, sectionId: 'sec-1'); @@ -456,7 +455,7 @@ void main() { await s.saveHiddenLibraries({'h-1'}); // user-2's library prefs (must not be touched by clearing user-1) - await s.saveCurrentUserUUID('user-2'); + await s.setActiveProfileId('local-user-2'); await s.saveLibraryOrder(['u2-a']); await s.saveSelectedLibraryKey('u2-key'); @@ -465,7 +464,7 @@ void main() { expect(s.getLibraryOrder(), isNull); expect(s.getSelectedLibraryKey(), isNull); - await s.saveCurrentUserUUID('user-1'); + await s.setActiveProfileId('local-user-1'); expect(s.getLibraryOrder(), ['u1-a', 'u1-b']); expect(s.getSelectedLibraryKey(), 'u1-key'); expect(s.getLibraryFilters(sectionId: 'sec-1'), {'genre': 'horror'}); @@ -484,6 +483,20 @@ void main() { expect(s.getLibraryTab('sec-1'), isNull); expect(s.getHiddenLibraries(), isEmpty); }); + + test('clearing scoped prefs also consumes pending legacy values', () async { + final s = await StorageService.getInstance(); + await s.prefs.setString('library_order', json.encode(['legacy'])); + await s.prefs.setString('library_filters_sec-1', json.encode({'genre': 'drama'})); + + await s.setActiveProfileId('local-user-1'); + await s.clearLibraryPreferences(); + + expect(s.getLibraryOrder(), isNull); + expect(s.getLibraryFilters(sectionId: 'sec-1'), isEmpty); + expect(s.prefs.getString('library_order'), isNull); + expect(s.prefs.getString('library_filters_sec-1'), isNull); + }); }); // ============================================================ @@ -494,17 +507,17 @@ void main() { test('combines credentials and library-preferences clear', () async { final s = await StorageService.getInstance(); - await s.savePlexToken('tok'); - await s.saveCurrentUserUUID('user-1'); + await s.prefs.setString('plex_token', 'tok'); + await s.setActiveProfileId('local-user-1'); await s.saveLibraryOrder(['lib-a']); await s.saveHiddenLibraries({'h-1'}); await s.clearUserData(); + // ignore: deprecated_member_use_from_same_package expect(s.getPlexToken(), isNull); - // current_user_uuid is part of the credentials bucket; clearing it - // means the scoped-key prefix flips to empty and reads return null. - expect(s.getCurrentUserUUID(), isNull); + // setActiveProfileId is unaffected by clearCredentials, so the prefix + // is still active — clearLibraryPreferences cleared the scoped values. expect(s.getLibraryOrder(), isNull); expect(s.getHiddenLibraries(), isEmpty); }); diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart new file mode 100644 index 00000000..6ad7618b --- /dev/null +++ b/test/services/sync_rule_executor_test.dart @@ -0,0 +1,392 @@ +import 'package:drift/native.dart'; +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/database/app_database.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/media/server_capabilities.dart'; +import 'package:plezy/models/download_models.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/sync_rule_executor.dart'; + +import '../test_helpers/prefs.dart'; + +JellyfinConnection _jellyfinConnection(String userId) => JellyfinConnection( + id: 'jf-machine/$userId', + baseUrl: 'https://jf.example.com', + serverName: 'Shared JF', + serverMachineId: 'jf-machine', + userId: userId, + userName: userId, + accessToken: 'token-$userId', + deviceId: 'device', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +void main() { + setUp(resetSharedPreferencesForTest); + + test('profile-scoped Jellyfin sync rule executes through active client', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await db.close(); + }); + + final pathsByUser = >{'user-a': [], 'user-b': []}; + + JellyfinClient clientFor(String userId) { + return JellyfinClient.forTesting( + connection: _jellyfinConnection(userId), + httpClient: MockClient((request) async { + pathsByUser[userId]!.add('${request.method} ${request.url.path}?${request.url.query}'); + if (request.method == 'GET' && request.url.path == '/Users/$userId/Items/show-1') { + return http.Response( + '{"Id":"show-1","Type":"Series","Name":"Show $userId","RecursiveItemCount":1}', + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.method == 'GET' && request.url.path == '/Items') { + return http.Response( + '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode $userId","SeriesId":"show-1","UserData":{"PlayCount":0}}]}', + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + } + + final userA = clientFor('user-a'); + final userB = clientFor('user-b'); + addTearDown(userA.close); + addTearDown(userB.close); + + manager.debugRegisterJellyfinClientForTesting(userB, online: false); + manager.debugRegisterJellyfinClientForTesting(userA); + + await db.insertSyncRule( + profileId: 'profile-a', + serverId: 'jf-machine', + ratingKey: 'show-1', + globalKey: 'profile-a|jf-machine:show-1', + targetType: 'show', + episodeCount: 1, + ); + await db.insertWatchAction( + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-b', + ratingKey: 'ep-1', + actionType: OfflineActionType.watched.id, + ); + await db.insertWatchAction( + profileId: 'profile-b', + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'ep-1', + actionType: OfflineActionType.watched.id, + ); + + final queued = <({MediaItem item, MediaServerClient client})>[]; + final executor = SyncRuleExecutor(database: db); + final results = await executor.executeSyncRules( + profileId: 'profile-a', + serverManager: manager, + downloads: const {}, + metadata: const {}, + queueSingleDownload: (item, client, {int mediaIndex = 0}) async { + queued.add((item: item, client: client)); + return true; + }, + force: true, + ); + + expect(results.single.queuedCount, 1); + expect(queued.single.client, same(userA)); + expect(pathsByUser['user-a']!.where((p) => p.startsWith('GET /Items?')), isNotEmpty); + expect(pathsByUser['user-b'], isEmpty); + }); + + test('profile-scoped sync rule excludes only the active profile local watched actions', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await db.close(); + }); + + final paths = []; + final userA = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-a'), + httpClient: MockClient((request) async { + paths.add('${request.method} ${request.url.path}?${request.url.query}'); + if (request.method == 'GET' && request.url.path == '/Users/user-a/Items/show-1') { + return http.Response( + '{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}', + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.method == 'GET' && request.url.path == '/Items') { + return http.Response( + '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}', + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + addTearDown(userA.close); + manager.debugRegisterJellyfinClientForTesting(userA); + + await db.insertSyncRule( + profileId: 'profile-a', + serverId: 'jf-machine', + ratingKey: 'show-1', + globalKey: 'profile-a|jf-machine:show-1', + targetType: 'show', + episodeCount: 1, + ); + await db.insertWatchAction( + profileId: 'profile-a', + serverId: 'jf-machine', + clientScopeId: 'jf-machine/user-a', + ratingKey: 'ep-1', + actionType: OfflineActionType.watched.id, + ); + + final queued = []; + final executor = SyncRuleExecutor(database: db); + final results = await executor.executeSyncRules( + profileId: 'profile-a', + serverManager: manager, + downloads: const {}, + metadata: const {}, + queueSingleDownload: (item, client, {int mediaIndex = 0}) async { + queued.add(item); + return true; + }, + force: true, + ); + + expect(results, isEmpty); + expect(queued, isEmpty); + expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty); + }); + + test('profile-scoped Jellyfin sync rule does not execute for another profile', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await db.close(); + }); + + final paths = []; + final userB = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-b'), + httpClient: MockClient((request) async { + paths.add('${request.method} ${request.url.path}?${request.url.query}'); + return http.Response('not found', 404); + }), + ); + addTearDown(userB.close); + manager.debugRegisterJellyfinClientForTesting(userB); + + await db.insertSyncRule( + profileId: 'profile-a', + serverId: 'jf-machine', + ratingKey: 'show-1', + globalKey: 'profile-a|jf-machine:show-1', + targetType: 'show', + episodeCount: 1, + ); + + final executor = SyncRuleExecutor(database: db); + final results = await executor.executeSyncRules( + profileId: 'profile-b', + serverManager: manager, + downloads: const {}, + metadata: const {}, + queueSingleDownload: (item, client, {int mediaIndex = 0}) async => true, + force: true, + ); + + expect(results, isEmpty); + expect(paths, isEmpty); + }); + + test('profile-scoped Jellyfin sync rule counts shared public downloads as already present', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await db.close(); + }); + + final paths = []; + final userB = JellyfinClient.forTesting( + connection: _jellyfinConnection('user-b'), + httpClient: MockClient((request) async { + paths.add('${request.method} ${request.url.path}?${request.url.query}'); + if (request.method == 'GET' && request.url.path == '/Users/user-b/Items/show-1') { + return http.Response( + '{"Id":"show-1","Type":"Series","Name":"Show","RecursiveItemCount":1}', + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.method == 'GET' && request.url.path == '/Items') { + return http.Response( + '{"Items":[{"Id":"ep-1","Type":"Episode","Name":"Episode","SeriesId":"show-1","UserData":{"PlayCount":0}}]}', + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }), + ); + addTearDown(userB.close); + manager.debugRegisterJellyfinClientForTesting(userB); + + await db.insertSyncRule( + profileId: 'profile-b', + serverId: 'jf-machine', + ratingKey: 'show-1', + globalKey: 'profile-b|jf-machine:show-1', + targetType: 'show', + episodeCount: 1, + ); + + final queued = []; + final executor = SyncRuleExecutor(database: db); + final results = await executor.executeSyncRules( + profileId: 'profile-b', + serverManager: manager, + downloads: const { + 'jf-machine:ep-1': DownloadProgress(globalKey: 'jf-machine:ep-1', status: DownloadStatus.completed), + }, + metadata: const {}, + queueSingleDownload: (item, client, {int mediaIndex = 0}) async { + queued.add(item); + return true; + }, + force: true, + ); + + expect(results, isEmpty); + expect(queued, isEmpty); + expect(paths.where((p) => p.startsWith('GET /Items?')), isNotEmpty); + }); + + test('collection sync rule pages through collection API instead of metadata children', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final manager = MultiServerManager(); + addTearDown(() async { + manager.dispose(); + await db.close(); + }); + + final client = _CollectionPagingClient(); + manager.debugRegisterClientForTesting(client); + + const ruleKey = 'profile-a|plex-machine:collection-1'; + final collection = MediaItem( + id: 'collection-1', + backend: MediaBackend.plex, + kind: MediaKind.collection, + title: 'Collection', + serverId: 'plex-machine', + ); + + await db.insertSyncRule( + profileId: 'profile-a', + serverId: 'plex-machine', + ratingKey: 'collection-1', + globalKey: ruleKey, + targetType: 'collection', + episodeCount: 0, + downloadFilter: SyncRuleFilter.all, + ); + + final queued = []; + final executor = SyncRuleExecutor(database: db); + final results = await executor.executeSyncRules( + profileId: 'profile-a', + serverManager: manager, + downloads: const {}, + metadata: {ruleKey: collection}, + queueSingleDownload: (item, client, {int mediaIndex = 0}) async { + queued.add(item); + return true; + }, + force: true, + ); + + expect(results.single.queuedCount, 1); + expect(queued.single.id, 'movie-1'); + expect(client.collectionPageCalls, [(start: 0, size: 100)]); + expect(client.fetchChildrenCalled, isFalse); + }); +} + +class _CollectionPagingClient implements MediaServerClient { + bool fetchChildrenCalled = false; + final collectionPageCalls = <({int? start, int? size})>[]; + + @override + String get serverId => 'plex-machine'; + + @override + String? get serverName => 'Plex'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + bool get isOfflineMode => false; + + @override + void close() {} + + @override + Future fetchItem(String id) async => null; + + @override + Future> fetchChildren(String parentId) async { + fetchChildrenCalled = true; + throw StateError('collection rules must not use fetchChildren'); + } + + @override + Future> fetchCollectionPage(String collectionId, {int? start, int? size, abort}) async { + collectionPageCalls.add((start: start, size: size)); + expect(collectionId, 'collection-1'); + return LibraryPage( + items: [MediaItem(id: 'movie-1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie')], + totalCount: 1, + offset: start ?? 0, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index f181e5e3..be9e27a5 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -1,7 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_metadata.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/mpv/mpv.dart'; -import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/track_manager.dart'; import '../test_helpers/prefs.dart'; @@ -28,19 +29,14 @@ import '../test_helpers/prefs.dart'; // `SettingsService.getInstance()` returning a service AND `Player.streams` // emitting Tracks. Out of scope without re-implementing the player. // - `onAudioTrackChanged` / `onSubtitleTrackChanged` — server-sync paths -// require a fully-faked PlexClient and PlexMediaInfo with realistic +// require a fully-faked PlexClient and MediaSourceInfo with realistic // stream IDs. The matching logic itself lives in [TrackSelectionService] // and is covered there. // - `onBackendSwitched` — wraps applyTrackSelectionWhenReady and is // therefore gated on the same SettingsService dependency. // - `resumeAfterSubtitleLoad` — schedules a real wall-clock fallback Timer. -PlexMetadata _meta({String ratingKey = 'rk1'}) => PlexMetadata(ratingKey: ratingKey, type: 'movie'); - -class _FakePlexClient implements PlexClient { - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} +MediaItem _meta({String id = 'rk1'}) => MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie); /// Player that records calls and can be configured per-test. class _FakePlayer implements Player { @@ -84,14 +80,14 @@ class _FakePlayer implements Player { TrackManager _make({ required _FakePlayer player, - PlexMetadata? metadata, + MediaItem? metadata, bool active = true, void Function(String, {Duration? duration})? showMessage, }) { return TrackManager( player: player, isActive: () => active, - getClient: () => _FakePlexClient(), + persistTrackPreference: _noopPersister, getProfileSettings: () => null, waitForProfileSettings: () async {}, metadata: metadata ?? _meta(), @@ -99,6 +95,14 @@ TrackManager _make({ ); } +Future _noopPersister({ + required String id, + required int partId, + required String trackType, + String? languageCode, + int? streamID, +}) async {} + void main() { // The constructor doesn't touch prefs, but [dispose] / [applyTrackSelection] // could leak across tests — reset to be safe. @@ -114,7 +118,7 @@ void main() { final mgr = TrackManager( player: player, isActive: () => true, - getClient: () => _FakePlexClient(), + persistTrackPreference: _noopPersister, getProfileSettings: () => null, waitForProfileSettings: () async {}, metadata: _meta(), @@ -127,7 +131,7 @@ void main() { expect(mgr.preferredAudioTrack?.id, 'a-1'); expect(mgr.preferredSubtitleTrack?.id, 's-1'); expect(mgr.preferredSecondarySubtitleTrack?.id, 's-2'); - expect(mgr.metadata.ratingKey, 'rk1'); + expect(mgr.metadata.id, 'rk1'); expect(mgr.waitingForExternalSubsTrackSelection, isFalse); expect(mgr.lastExternalSubtitles, isEmpty); expect(mgr.mediaInfo, isNull); @@ -137,11 +141,11 @@ void main() { final mgr = _make(player: _FakePlayer()); addTearDown(mgr.dispose); - mgr.metadata = _meta(ratingKey: 'next'); + mgr.metadata = _meta(id: 'next'); mgr.preferredAudioTrack = const AudioTrack(id: 'a2', language: 'fre'); mgr.waitingForExternalSubsTrackSelection = true; - expect(mgr.metadata.ratingKey, 'next'); + expect(mgr.metadata.id, 'next'); expect(mgr.preferredAudioTrack?.id, 'a2'); expect(mgr.waitingForExternalSubsTrackSelection, isTrue); }); diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart index f0f76bd0..ab644030 100644 --- a/test/services/track_selection_service_test.dart +++ b/test/services/track_selection_service_test.dart @@ -1,7 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_media_info.dart'; -import 'package:plezy/models/plex_metadata.dart'; -import 'package:plezy/models/plex_user_profile.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_user_profile.dart'; +import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/models/jellyfin/jellyfin_user_profile.dart'; +import 'package:plezy/models/plex/plex_user_profile.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/track_selection_service.dart'; @@ -39,8 +43,14 @@ import 'package:plezy/services/track_selection_service.dart'; // Fixtures // ============================================================ -PlexMetadata _meta({String? audioLanguage, String? subtitleLanguage}) => - PlexMetadata(ratingKey: 'rk1', audioLanguage: audioLanguage, subtitleLanguage: subtitleLanguage); +MediaItem _meta({MediaBackend backend = MediaBackend.plex, String? audioLanguage, String? subtitleLanguage}) => + MediaItem( + id: 'rk1', + backend: backend, + kind: MediaKind.movie, + audioLanguage: audioLanguage, + subtitleLanguage: subtitleLanguage, + ); PlexUserProfile _profile({ bool autoSelectAudio = true, @@ -65,13 +75,32 @@ PlexUserProfile _profile({ ); } +JellyfinUserProfile _jellyfinProfile({ + String? defaultAudioLanguage, + String? defaultSubtitleLanguage, + SubtitlePlaybackMode? subtitleMode, +}) { + return JellyfinUserProfile( + autoSelectAudio: true, + defaultAudioLanguage: defaultAudioLanguage, + defaultSubtitleLanguage: defaultSubtitleLanguage, + subtitleMode: subtitleMode, + ); +} + AudioTrack _audio(String id, {String? lang, String? title, String? codec, int? channels, bool isDefault = false}) => AudioTrack(id: id, language: lang, title: title, codec: codec, channels: channels, isDefault: isDefault); -SubtitleTrack _sub(String id, {String? lang, String? title, String? codec, bool isDefault = false}) => - SubtitleTrack(id: id, language: lang, title: title, codec: codec, isDefault: isDefault); +SubtitleTrack _sub( + String id, { + String? lang, + String? title, + String? codec, + bool isDefault = false, + bool isForced = false, +}) => SubtitleTrack(id: id, language: lang, title: title, codec: codec, isDefault: isDefault, isForced: isForced); -PlexAudioTrack _plexAudio( +MediaAudioTrack _plexAudio( int id, { String? language, String? languageCode, @@ -80,7 +109,7 @@ PlexAudioTrack _plexAudio( bool selected = false, String? codec, }) { - return PlexAudioTrack( + return MediaAudioTrack( id: id, language: language, languageCode: languageCode ?? language, @@ -91,7 +120,7 @@ PlexAudioTrack _plexAudio( ); } -PlexSubtitleTrack _plexSub( +MediaSubtitleTrack _plexSub( int id, { String? language, String? languageCode, @@ -100,7 +129,7 @@ PlexSubtitleTrack _plexSub( bool forced = false, String? codec, }) { - return PlexSubtitleTrack( + return MediaSubtitleTrack( id: id, language: language, languageCode: languageCode ?? language, @@ -111,8 +140,17 @@ PlexSubtitleTrack _plexSub( ); } -PlexMediaInfo _info({List? audio, List? subs}) => - PlexMediaInfo(videoUrl: '', audioTracks: audio ?? const [], subtitleTracks: subs ?? const [], chapters: const []); +MediaSourceInfo _info({ + List? audio, + List? subs, + int? defaultSubtitleStreamIndex, +}) => MediaSourceInfo( + videoUrl: '', + audioTracks: audio ?? const [], + subtitleTracks: subs ?? const [], + chapters: const [], + defaultSubtitleStreamIndex: defaultSubtitleStreamIndex, +); /// Minimal Player stub — TrackSelectionService never reads from the player /// in any of the public-pure helpers we test. @@ -121,7 +159,7 @@ class _StubPlayer implements Player { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -TrackSelectionService _svc({PlexMetadata? metadata, PlexUserProfile? profile, PlexMediaInfo? info}) { +TrackSelectionService _svc({MediaItem? metadata, MediaServerUserProfile? profile, MediaSourceInfo? info}) { return TrackSelectionService( player: _StubPlayer(), metadata: metadata ?? _meta(), @@ -303,7 +341,7 @@ void main() { // matcher resolves on Plex's selected (French). final result = _svc(info: info).selectAudioTrack(tracks, null); expect(result, isNotNull); - expect(result!.priority, TrackSelectionPriority.plexSelected); + expect(result!.priority, TrackSelectionPriority.serverSelected); expect(result.track.language, 'fre'); }); @@ -378,7 +416,7 @@ void main() { ], ); final result = _svc(info: info).selectSubtitleTrack(tracks, null, null); - expect(result.priority, TrackSelectionPriority.plexSelected); + expect(result.priority, TrackSelectionPriority.serverSelected); expect(result.track.language, 'fre'); }); @@ -392,10 +430,125 @@ void main() { ], ); final result = _svc(info: info).selectSubtitleTrack(tracks, null, null); - expect(result.priority, TrackSelectionPriority.plexSelected); + expect(result.priority, TrackSelectionPriority.serverSelected); expect(result.track.id, 'no'); }); + test('Jellyfin media info with subs but none selected falls through to default fallback', () { + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre', isDefault: true)]; + final info = _info( + subs: [ + _plexSub(10, language: 'eng'), + _plexSub(11, language: 'fre'), + ], + ); + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + info: info, + ).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.defaultTrack); + expect(result.track.id, '2'); + }); + + test('Jellyfin explicit DefaultSubtitleStreamIndex=-1 forces subtitles off', () { + final tracks = [_sub('1', lang: 'eng', isDefault: true), _sub('2', lang: 'fre')]; + final info = _info( + defaultSubtitleStreamIndex: -1, + subs: [ + _plexSub(10, language: 'eng'), + _plexSub(11, language: 'fre'), + ], + ); + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + info: info, + ).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.serverSelected); + expect(result.track.id, 'no'); + }); + + test('Jellyfin SubtitleMode.None forces subtitles off', () { + final tracks = [_sub('1', lang: 'eng', isDefault: true)]; + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.none), + ).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.profile); + expect(result.track.id, 'no'); + }); + + test('Jellyfin SubtitleMode.OnlyForced selects matching forced subtitle', () { + final tracks = [ + _sub('1', lang: 'eng'), + _sub('2', lang: 'eng', isForced: true), + _sub('3', lang: 'jpn', isForced: true), + ]; + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced), + ).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.profile); + expect(result.track.id, '2'); + }); + + test('Jellyfin SubtitleMode.OnlyForced turns off when no forced subtitle exists', () { + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'jpn')]; + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced), + ).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.profile); + expect(result.track.id, 'no'); + }); + + test('Jellyfin SubtitleMode.Always selects preferred subtitle language', () { + final tracks = [_sub('1', lang: 'jpn'), _sub('2', lang: 'eng')]; + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always), + ).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.profile); + expect(result.track.id, '2'); + }); + + test('Jellyfin SubtitleMode.Always falls back to default then first subtitle', () { + final tracks = [_sub('1', lang: 'jpn'), _sub('2', lang: 'fre', isDefault: true)]; + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always), + ).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.profile); + expect(result.track.id, '2'); + }); + + test('Jellyfin SubtitleMode.Smart uses forced subtitle when audio matches preferred language', () { + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'eng', isForced: true)]; + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + profile: _jellyfinProfile( + defaultAudioLanguage: 'eng', + defaultSubtitleLanguage: 'eng', + subtitleMode: SubtitlePlaybackMode.smart, + ), + ).selectSubtitleTrack(tracks, null, _audio('A', lang: 'eng')); + expect(result.priority, TrackSelectionPriority.profile); + expect(result.track.id, '2'); + }); + + test('Jellyfin SubtitleMode.Smart uses preferred subtitle when audio differs', () { + final tracks = [_sub('1', lang: 'jpn'), _sub('2', lang: 'eng')]; + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + profile: _jellyfinProfile( + defaultAudioLanguage: 'eng', + defaultSubtitleLanguage: 'eng', + subtitleMode: SubtitlePlaybackMode.smart, + ), + ).selectSubtitleTrack(tracks, null, _audio('A', lang: 'jpn')); + expect(result.priority, TrackSelectionPriority.profile); + expect(result.track.id, '2'); + }); + test('Priority 3: default-flagged track when no Plex info', () { final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre', isDefault: true)]; final result = _svc().selectSubtitleTrack(tracks, null, null); diff --git a/test/utils/content_utils_test.dart b/test/utils/content_utils_test.dart index e592ce0e..c0c1a04c 100644 --- a/test/utils/content_utils_test.dart +++ b/test/utils/content_utils_test.dart @@ -1,21 +1,25 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/plex_metadata.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_item_types.dart'; +import 'package:plezy/media/media_kind.dart'; import 'package:plezy/utils/content_utils.dart'; -PlexMetadata _episode({int? viewOffset, int? duration, int? viewCount, int? leafCount, int? viewedLeafCount}) { - return PlexMetadata( - ratingKey: '1', - type: 'episode', - viewOffset: viewOffset, - duration: duration, +MediaItem _episode({int? viewOffsetMs, int? durationMs, int? viewCount, int? leafCount, int? viewedLeafCount}) { + return MediaItem( + id: '1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + viewOffsetMs: viewOffsetMs, + durationMs: durationMs, viewCount: viewCount, leafCount: leafCount, viewedLeafCount: viewedLeafCount, ); } -PlexMetadata _movie({int? viewCount}) { - return PlexMetadata(ratingKey: '1', type: 'movie', viewCount: viewCount); +MediaItem _movie({int? viewCount}) { + return MediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.movie, viewCount: viewCount); } void main() { @@ -50,10 +54,10 @@ void main() { }); }); - group('PlexMetadataType.shouldHideSpoiler', () { + group('MediaItemTypes.shouldHideSpoiler', () { test('false for non-episodes', () { expect(_movie().shouldHideSpoiler, isFalse); - final show = PlexMetadata(ratingKey: '1', type: 'show'); + final show = MediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.show); expect(show.shouldHideSpoiler, isFalse); }); @@ -62,22 +66,22 @@ void main() { }); test('false when >= 50% watched', () { - expect(_episode(viewOffset: 5000, duration: 10000).shouldHideSpoiler, isFalse); - expect(_episode(viewOffset: 8000, duration: 10000).shouldHideSpoiler, isFalse); + expect(_episode(viewOffsetMs: 5000, durationMs: 10000).shouldHideSpoiler, isFalse); + expect(_episode(viewOffsetMs: 8000, durationMs: 10000).shouldHideSpoiler, isFalse); }); test('true when < 50% watched', () { - expect(_episode(viewOffset: 1000, duration: 10000).shouldHideSpoiler, isTrue); - expect(_episode(viewOffset: 4999, duration: 10000).shouldHideSpoiler, isTrue); + expect(_episode(viewOffsetMs: 1000, durationMs: 10000).shouldHideSpoiler, isTrue); + expect(_episode(viewOffsetMs: 4999, durationMs: 10000).shouldHideSpoiler, isTrue); }); test('true when no progress at all (unwatched)', () { expect(_episode().shouldHideSpoiler, isTrue); - expect(_episode(viewOffset: 0).shouldHideSpoiler, isTrue); + expect(_episode(viewOffsetMs: 0).shouldHideSpoiler, isTrue); }); test('true when duration is missing', () { - expect(_episode(viewOffset: 500).shouldHideSpoiler, isTrue); + expect(_episode(viewOffsetMs: 500).shouldHideSpoiler, isTrue); }); }); diff --git a/test/utils/external_ids_test.dart b/test/utils/external_ids_test.dart new file mode 100644 index 00000000..8b726728 --- /dev/null +++ b/test/utils/external_ids_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/external_ids.dart'; + +void main() { + group('ExternalIds.fromGuids', () { + test('parses Plex `imdb://`, `tmdb://`, `tvdb://` URIs', () { + final ids = ExternalIds.fromGuids([ + {'id': 'imdb://tt12345'}, + {'id': 'tmdb://456'}, + {'id': 'tvdb://789'}, + ]); + expect(ids.imdb, 'tt12345'); + expect(ids.tmdb, 456); + expect(ids.tvdb, 789); + expect(ids.hasAny, isTrue); + }); + + test('ignores unknown schemes and bad shapes', () { + final ids = ExternalIds.fromGuids([ + {'id': 'mbid://abc'}, + 'not-a-map', + {'id': null}, + {'id': 'tmdb://not-a-number'}, + ]); + expect(ids.hasAny, isFalse); + }); + }); + + group('ExternalIds.fromJellyfinProviderIds', () { + test('extracts Tmdb/Imdb/Tvdb (case-insensitive)', () { + final ids = ExternalIds.fromJellyfinProviderIds({'Tmdb': '12345', 'Imdb': 'tt99999', 'Tvdb': '777'}); + expect(ids.tmdb, 12345); + expect(ids.imdb, 'tt99999'); + expect(ids.tvdb, 777); + }); + + test('handles lowercase keys', () { + final ids = ExternalIds.fromJellyfinProviderIds({'tmdb': '111', 'imdb': 'tt000'}); + expect(ids.tmdb, 111); + expect(ids.imdb, 'tt000'); + expect(ids.tvdb, isNull); + }); + + test('ignores unknown providers and empty values', () { + final ids = ExternalIds.fromJellyfinProviderIds({'AniList': '42', 'Tvdb': ''}); + expect(ids.hasAny, isFalse); + }); + + test('ignores non-numeric numeric IDs', () { + final ids = ExternalIds.fromJellyfinProviderIds({'Tmdb': 'not-a-number', 'Imdb': 'tt12345'}); + expect(ids.tmdb, isNull); + expect(ids.imdb, 'tt12345'); + }); + }); +} diff --git a/test/utils/log_redaction_manager_test.dart b/test/utils/log_redaction_manager_test.dart index 3d0b58a4..c934a8a4 100644 --- a/test/utils/log_redaction_manager_test.dart +++ b/test/utils/log_redaction_manager_test.dart @@ -31,6 +31,51 @@ void main() { expect(result.contains('[REDACTED]'), isTrue); }); + test('redacts Jellyfin api_key query parameter without registration', () { + final input = 'https://example.com/Items/1/Images/Primary?api_key=jelly-token-123&fmt=jpg'; + final result = LogRedactionManager.redact(input); + expect(result.contains('jelly-token-123'), isFalse); + expect(result.contains('api_key=[REDACTED]'), isTrue); + expect(result.contains('fmt=jpg'), isTrue); + }); + + test('api_key redaction is case-insensitive', () { + final result = LogRedactionManager.redact('API_KEY=topsecret&z=1'); + expect(result.contains('topsecret'), isFalse); + expect(result.contains('api_key=[REDACTED]'), isTrue); + }); + + test('redacts Jellyfin Quick Connect secret query parameter without registration', () { + final input = 'https://example.com/QuickConnect/Connect?secret=quick-secret-123&next=1'; + final result = LogRedactionManager.redact(input); + expect(result.contains('quick-secret-123'), isFalse); + expect(result.contains('secret=[REDACTED]'), isTrue); + expect(result.contains('next=1'), isTrue); + }); + + test('Quick Connect secret redaction is case-insensitive and preserves other params', () { + final result = LogRedactionManager.redact('SECRET=a%2Fb%20c&Authenticated=false'); + expect(result.contains('a%2Fb%20c'), isFalse); + expect(result.contains('secret=[REDACTED]'), isTrue); + expect(result.contains('Authenticated=false'), isTrue); + }); + + test('redacts X-Emby-Token header form', () { + final result = LogRedactionManager.redact('X-Emby-Token: emby-secret'); + expect(result.contains('emby-secret'), isFalse); + expect(result.contains('[REDACTED]'), isTrue); + }); + + test('redacts MediaBrowser Authorization Token segment', () { + final input = + 'Authorization: MediaBrowser Client="Plezy", Device="Plezy", DeviceId="dev-1", Version="1.0", Token="opaque-jellyfin-token"'; + final result = LogRedactionManager.redact(input); + expect(result.contains('opaque-jellyfin-token'), isFalse); + expect(result.contains('Token="[REDACTED]"'), isTrue); + // Surrounding metadata stays intact for debugging. + expect(result.contains('Client="Plezy"'), isTrue); + }); + test('masks IPv4 addresses with dots', () { final result = LogRedactionManager.redact('connect to 192.168.1.42 now'); expect(result.contains('192.168.1.42'), isFalse); @@ -86,12 +131,12 @@ void main() { }); group('registerServerUrl', () { - test('masks a registered server URL with start/end preview', () { + test('fully redacts a registered server URL', () { LogRedactionManager.registerServerUrl('https://my-cool-plex-server.example.com'); final result = LogRedactionManager.redact('GET https://my-cool-plex-server.example.com/library/sections'); expect(result.contains('my-cool-plex-server'), isFalse); - // start preview length is 12, end preview length is 8 - expect(result.contains('...[REDACTED_URL]...'), isTrue); + expect(result.contains('https://'), isFalse); + expect(result.contains('[REDACTED_URL]'), isTrue); }); test('skips IPv4-host URLs (regex IP redaction handles them)', () { diff --git a/test/utils/media_server_http_exception_test.dart b/test/utils/media_server_http_exception_test.dart new file mode 100644 index 00000000..90439646 --- /dev/null +++ b/test/utils/media_server_http_exception_test.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:io'; + +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/utils/media_server_http_client.dart'; + +void main() { + group('MediaServerHttpException.from', () { + final uri = Uri.parse('http://example/api/thing'); + + test('returns same instance for MediaServerHttpException input (no re-wrap)', () { + final original = MediaServerHttpException( + type: MediaServerHttpErrorType.connectionError, + message: 'boom', + requestUri: uri, + ); + final result = MediaServerHttpException.from(original, uri: uri); + expect(identical(result, original), isTrue); + }); + + test('TimeoutException -> connectionTimeout', () { + final tm = TimeoutException('took too long', const Duration(seconds: 1)); + final result = MediaServerHttpException.from(tm, uri: uri); + expect(result.type, MediaServerHttpErrorType.connectionTimeout); + expect(result.message, 'took too long'); + expect(result.requestUri, uri); + }); + + test('SocketException -> connectionError', () { + final result = MediaServerHttpException.from(const SocketException('refused'), uri: uri); + expect(result.type, MediaServerHttpErrorType.connectionError); + expect(result.message, 'refused'); + expect(result.requestUri, uri); + }); + + test('HttpException -> connectionError', () { + final result = MediaServerHttpException.from(const HttpException('bad header'), uri: uri); + expect(result.type, MediaServerHttpErrorType.connectionError); + expect(result.message, 'bad header'); + expect(result.requestUri, uri); + }); + + test('http.ClientException -> connectionError, prefers error.uri over passed uri', () { + final clientUri = Uri.parse('http://other/path'); + final ex = http.ClientException('bad', clientUri); + final result = MediaServerHttpException.from(ex, uri: uri); + expect(result.type, MediaServerHttpErrorType.connectionError); + expect(result.message, 'bad'); + expect(result.requestUri, clientUri); + }); + + test('http.ClientException with null uri falls back to passed uri', () { + final ex = http.ClientException('bad'); + final result = MediaServerHttpException.from(ex, uri: uri); + expect(result.requestUri, uri); + }); + + test('RequestAbortedException maps to cancelled (not connectionError) despite extending ClientException', () { + final abortUri = Uri.parse('http://abort/x'); + final ex = http.RequestAbortedException(abortUri); + final result = MediaServerHttpException.from(ex, uri: uri); + expect(result.type, MediaServerHttpErrorType.cancelled); + expect(result.requestUri, abortUri); + }); + + test('RequestAbortedException with no uri falls back to passed uri', () { + final ex = http.RequestAbortedException(); + final result = MediaServerHttpException.from(ex, uri: uri); + expect(result.type, MediaServerHttpErrorType.cancelled); + expect(result.requestUri, uri); + }); + + test('unknown error -> unknown type with toString() message', () { + final result = MediaServerHttpException.from(Exception('weird'), uri: uri); + expect(result.type, MediaServerHttpErrorType.unknown); + expect(result.message, contains('weird')); + expect(result.requestUri, uri); + }); + + test('no uri passed -> requestUri is null for non-ClientException', () { + final result = MediaServerHttpException.from(TimeoutException('t')); + expect(result.requestUri, isNull); + }); + + test('toString includes type and message', () { + final e = MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'halt'); + expect(e.toString(), 'MediaServerHttpException(cancelled: halt)'); + }); + }); + + group('MediaServerHttpException.isTransient', () { + MediaServerHttpException ex(MediaServerHttpErrorType t) => MediaServerHttpException(type: t); + + test('connectionTimeout is transient', () { + expect(ex(MediaServerHttpErrorType.connectionTimeout).isTransient, isTrue); + }); + + test('receiveTimeout is transient', () { + expect(ex(MediaServerHttpErrorType.receiveTimeout).isTransient, isTrue); + }); + + test('connectionError is transient', () { + expect(ex(MediaServerHttpErrorType.connectionError).isTransient, isTrue); + }); + + test('cancelled is NOT transient (user-driven abort)', () { + expect(ex(MediaServerHttpErrorType.cancelled).isTransient, isFalse); + }); + + test('unknown is NOT transient', () { + expect(ex(MediaServerHttpErrorType.unknown).isTransient, isFalse); + }); + }); + + group('MediaServerHttpClient malformed JSON handling', () { + test('preserves 401 status and raw body when JSON decoding fails', () async { + final client = MediaServerHttpClient( + baseUrl: 'https://example.test', + client: MockClient((_) async => http.Response('{bad json', 401, headers: {'content-type': 'application/json'})), + ); + addTearDown(client.close); + + await expectLater( + client.get('/Users/Me'), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 401) + .having((e) => e.responseData, 'responseData', '{bad json') + .having((e) => e.requestUri?.path, 'requestUri.path', '/Users/Me'), + ), + ); + }); + + test('preserves 500 status and raw body when JSON decoding fails', () async { + final client = MediaServerHttpClient( + baseUrl: 'https://example.test', + client: MockClient((_) async => http.Response('{bad json', 500, headers: {'content-type': 'application/json'})), + ); + addTearDown(client.close); + + await expectLater( + client.get('/System/Info'), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 500) + .having((e) => e.responseData, 'responseData', '{bad json'), + ), + ); + }); + + test('preserves 200 status when successful JSON response is malformed', () async { + final client = MediaServerHttpClient( + baseUrl: 'https://example.test', + client: MockClient((_) async => http.Response('{bad json', 200, headers: {'content-type': 'application/json'})), + ); + addTearDown(client.close); + + await expectLater( + client.get('/Items'), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 200)), + ); + }); + + test('treats Content-Type header names case-insensitively', () async { + final client = MediaServerHttpClient( + baseUrl: 'https://example.test', + client: MockClient((_) async => http.Response('{bad json', 401, headers: {'Content-Type': 'application/json'})), + ); + addTearDown(client.close); + + await expectLater( + client.get('/Users/Me'), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 401) + .having((e) => e.responseData, 'responseData', '{bad json'), + ), + ); + }); + + test('decodes profiled JSON content types case-insensitively', () async { + final client = MediaServerHttpClient( + baseUrl: 'https://example.test', + client: MockClient( + (_) async => + http.Response('{"ok":true}', 200, headers: {'Content-Type': 'application/json; profile="PascalCase"'}), + ), + ); + addTearDown(client.close); + + final response = await client.get('/Items'); + expect(response.data, {'ok': true}); + }); + + test('decodes JSON when Content-Type value casing differs', () async { + final client = MediaServerHttpClient( + baseUrl: 'https://example.test', + client: MockClient( + (_) async => http.Response('{"ok":true}', 200, headers: {'content-type': 'Application/JSON'}), + ), + ); + addTearDown(client.close); + + final response = await client.get('/Items'); + expect(response.data, {'ok': true}); + }); + }); + + group('MediaServerHttpClient HTTP error handling', () { + test('preserves requestUri for decodable HTTP errors', () async { + final client = MediaServerHttpClient( + baseUrl: 'https://example.test', + client: MockClient( + (_) async => http.Response('{"error":"nope"}', 500, headers: {'content-type': 'application/json'}), + ), + ); + addTearDown(client.close); + + await expectLater( + () async => throwIfHttpError(await client.get('/System/Info')), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 500) + .having((e) => e.requestUri?.path, 'requestUri.path', '/System/Info'), + ), + ); + }); + }); +} diff --git a/test/utils/plex_http_exception_test.dart b/test/utils/plex_http_exception_test.dart deleted file mode 100644 index 263daf27..00000000 --- a/test/utils/plex_http_exception_test.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:plezy/utils/plex_http_exception.dart'; - -void main() { - group('PlexHttpException.from', () { - final uri = Uri.parse('http://example/api/thing'); - - test('returns same instance for PlexHttpException input (no re-wrap)', () { - final original = PlexHttpException(type: PlexHttpErrorType.connectionError, message: 'boom', requestUri: uri); - final result = PlexHttpException.from(original, uri: uri); - expect(identical(result, original), isTrue); - }); - - test('TimeoutException -> connectionTimeout', () { - final tm = TimeoutException('took too long', const Duration(seconds: 1)); - final result = PlexHttpException.from(tm, uri: uri); - expect(result.type, PlexHttpErrorType.connectionTimeout); - expect(result.message, 'took too long'); - expect(result.requestUri, uri); - }); - - test('SocketException -> connectionError', () { - final result = PlexHttpException.from(const SocketException('refused'), uri: uri); - expect(result.type, PlexHttpErrorType.connectionError); - expect(result.message, 'refused'); - expect(result.requestUri, uri); - }); - - test('HttpException -> connectionError', () { - final result = PlexHttpException.from(const HttpException('bad header'), uri: uri); - expect(result.type, PlexHttpErrorType.connectionError); - expect(result.message, 'bad header'); - expect(result.requestUri, uri); - }); - - test('http.ClientException -> connectionError, prefers error.uri over passed uri', () { - final clientUri = Uri.parse('http://other/path'); - final ex = http.ClientException('bad', clientUri); - final result = PlexHttpException.from(ex, uri: uri); - expect(result.type, PlexHttpErrorType.connectionError); - expect(result.message, 'bad'); - expect(result.requestUri, clientUri); - }); - - test('http.ClientException with null uri falls back to passed uri', () { - final ex = http.ClientException('bad'); - final result = PlexHttpException.from(ex, uri: uri); - expect(result.requestUri, uri); - }); - - test('RequestAbortedException maps to cancelled (not connectionError) despite extending ClientException', () { - final abortUri = Uri.parse('http://abort/x'); - final ex = http.RequestAbortedException(abortUri); - final result = PlexHttpException.from(ex, uri: uri); - expect(result.type, PlexHttpErrorType.cancelled); - expect(result.requestUri, abortUri); - }); - - test('RequestAbortedException with no uri falls back to passed uri', () { - final ex = http.RequestAbortedException(); - final result = PlexHttpException.from(ex, uri: uri); - expect(result.type, PlexHttpErrorType.cancelled); - expect(result.requestUri, uri); - }); - - test('unknown error -> unknown type with toString() message', () { - final result = PlexHttpException.from(Exception('weird'), uri: uri); - expect(result.type, PlexHttpErrorType.unknown); - expect(result.message, contains('weird')); - expect(result.requestUri, uri); - }); - - test('no uri passed -> requestUri is null for non-ClientException', () { - final result = PlexHttpException.from(TimeoutException('t')); - expect(result.requestUri, isNull); - }); - - test('toString includes type and message', () { - final e = PlexHttpException(type: PlexHttpErrorType.cancelled, message: 'halt'); - expect(e.toString(), 'PlexHttpException(cancelled: halt)'); - }); - }); - - group('PlexHttpException.isTransient', () { - PlexHttpException ex(PlexHttpErrorType t) => PlexHttpException(type: t); - - test('connectionTimeout is transient', () { - expect(ex(PlexHttpErrorType.connectionTimeout).isTransient, isTrue); - }); - - test('receiveTimeout is transient', () { - expect(ex(PlexHttpErrorType.receiveTimeout).isTransient, isTrue); - }); - - test('connectionError is transient', () { - expect(ex(PlexHttpErrorType.connectionError).isTransient, isTrue); - }); - - test('cancelled is NOT transient (user-driven abort)', () { - expect(ex(PlexHttpErrorType.cancelled).isTransient, isFalse); - }); - - test('unknown is NOT transient', () { - expect(ex(PlexHttpErrorType.unknown).isTransient, isFalse); - }); - }); -} diff --git a/test/widgets/download_tree_view_test.dart b/test/widgets/download_tree_view_test.dart new file mode 100644 index 00000000..64d5e712 --- /dev/null +++ b/test/widgets/download_tree_view_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.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/models/download_models.dart'; +import 'package:plezy/widgets/download_tree_view.dart'; + +DownloadTreeNode _episodeNode(String globalKey) => DownloadTreeNode( + key: globalKey, + title: 'Episode', + type: DownloadNodeType.episode, + status: DownloadStatus.completed, +); + +DownloadTreeNode _seasonNode({required String key, required List children}) => DownloadTreeNode( + key: key, + title: 'Season', + type: DownloadNodeType.season, + status: DownloadStatus.completed, + children: children, +); + +DownloadTreeNode _showNode({required String key, required List children}) => DownloadTreeNode( + key: key, + title: 'Show', + type: DownloadNodeType.show, + status: DownloadStatus.completed, + children: children, +); + +MediaItem _episodeMeta({ + required String id, + required String? serverId, + required String? grandparentId, + required String? parentId, +}) => MediaItem( + id: id, + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Ep $id', + serverId: serverId, + grandparentId: grandparentId, + parentId: parentId, +); + +void main() { + group('resolveDownloadContainerGlobalKey', () { + test('show node: builds globalKey from leaf serverId + grandparentId', () { + final ep = _episodeNode('plex1:ep100'); + final season = _seasonNode(key: 'show42:season7', children: [ep]); + final show = _showNode(key: 'show42', children: [season]); + final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '7')}; + + expect(resolveDownloadContainerGlobalKey(show, metadata), 'plex1:42'); + }); + + test('season node: builds globalKey from leaf serverId + parentId', () { + final ep = _episodeNode('plex1:ep100'); + final season = _seasonNode(key: 'show42:season7', children: [ep]); + final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '7')}; + + expect(resolveDownloadContainerGlobalKey(season, metadata), 'plex1:7'); + }); + + test('episode and movie nodes return null (not container types)', () { + final ep = _episodeNode('plex1:ep100'); + final movie = DownloadTreeNode( + key: 'plex1:movie5', + title: 'M', + type: DownloadNodeType.movie, + status: DownloadStatus.completed, + ); + final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '7')}; + + expect(resolveDownloadContainerGlobalKey(ep, metadata), isNull); + expect(resolveDownloadContainerGlobalKey(movie, metadata), isNull); + }); + + test('container with no leaves returns null', () { + final empty = _showNode(key: 'show42', children: []); + expect(resolveDownloadContainerGlobalKey(empty, {}), isNull); + }); + + test('leaf metadata missing in map returns null', () { + final ep = _episodeNode('plex1:ep100'); + final show = _showNode(key: 'show42', children: [ep]); + expect(resolveDownloadContainerGlobalKey(show, const {}), isNull); + }); + + test('leaf metadata missing serverId returns null', () { + final ep = _episodeNode('plex1:ep100'); + final show = _showNode(key: 'show42', children: [ep]); + final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: null, grandparentId: '42', parentId: '7')}; + expect(resolveDownloadContainerGlobalKey(show, metadata), isNull); + }); + + test('show node with leaf missing grandparentId returns null', () { + final ep = _episodeNode('plex1:ep100'); + final show = _showNode(key: 'show42', children: [ep]); + final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: null, parentId: '7')}; + expect(resolveDownloadContainerGlobalKey(show, metadata), isNull); + }); + + test('season node with leaf missing parentId returns null', () { + final ep = _episodeNode('plex1:ep100'); + final season = _seasonNode(key: 'show42:season7', children: [ep]); + final metadata = {'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: null)}; + expect(resolveDownloadContainerGlobalKey(season, metadata), isNull); + }); + + test('walks nested season for first leaf when show has multiple seasons', () { + final ep1 = _episodeNode('plex1:ep100'); + final ep2 = _episodeNode('plex1:ep200'); + final s1 = _seasonNode(key: 'show42:season1', children: [ep1]); + final s2 = _seasonNode(key: 'show42:season2', children: [ep2]); + final show = _showNode(key: 'show42', children: [s1, s2]); + final metadata = { + 'plex1:ep100': _episodeMeta(id: '100', serverId: 'plex1', grandparentId: '42', parentId: '1'), + 'plex1:ep200': _episodeMeta(id: '200', serverId: 'plex1', grandparentId: '42', parentId: '2'), + }; + + expect(resolveDownloadContainerGlobalKey(show, metadata), 'plex1:42'); + }); + }); +} diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart new file mode 100644 index 00000000..1904e5c0 --- /dev/null +++ b/test/widgets/media_context_menu_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/widgets/media_context_menu.dart'; + +void main() { + group('isAdminActionAllowedForMediaItem', () { + test('blocks non-admin Plex Home users on Plex items', () { + final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false)); + + expect( + isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile), + isFalse, + ); + }); + + test('does not apply Plex Home role to Jellyfin items', () { + final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: false)); + + expect( + isAdminActionAllowedForMediaItem( + isOwnerOrAdmin: true, + itemBackend: MediaBackend.jellyfin, + activeProfile: profile, + ), + isTrue, + ); + }); + + test('allows Plex admin Home users on Plex items', () { + final profile = Profile.virtualPlexHome(connectionId: 'plex-1', homeUser: _homeUser(admin: true)); + + expect( + isAdminActionAllowedForMediaItem(isOwnerOrAdmin: true, itemBackend: MediaBackend.plex, activeProfile: profile), + isTrue, + ); + }); + }); +} + +PlexHomeUser _homeUser({required bool admin}) { + return PlexHomeUser( + id: 0, + uuid: 'home-user', + title: 'Home User', + username: null, + email: null, + friendlyName: null, + thumb: 'https://plex.tv/users/home-user/avatar', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: admin, + guest: false, + protected: false, + ); +} diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart new file mode 100644 index 00000000..952253ca --- /dev/null +++ b/test/widgets/video_controls_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/media/media_version.dart'; +import 'package:plezy/widgets/video_controls/video_controls.dart'; + +void main() { + group('effectiveVersionQualityControls', () { + test('clears switchable version and quality state during offline playback', () { + final version = MediaVersion(id: 'v1', videoResolution: '1080'); + final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false); + + final result = effectiveVersionQualityControls( + isOfflinePlayback: true, + availableVersions: [version], + serverSupportsTranscoding: true, + isTranscoding: true, + sourceAudioTracks: [audio], + selectedAudioStreamId: 1, + ); + + expect(result.canSwitch, isFalse); + expect(result.availableVersions, isEmpty); + expect(result.serverSupportsTranscoding, isFalse); + expect(result.isTranscoding, isFalse); + expect(result.sourceAudioTracks, isEmpty); + expect(result.selectedAudioStreamId, isNull); + }); + + test('keeps switchable state during online playback', () { + final version = MediaVersion(id: 'v1', videoResolution: '1080'); + final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false); + + final result = effectiveVersionQualityControls( + isOfflinePlayback: false, + availableVersions: [version], + serverSupportsTranscoding: true, + isTranscoding: true, + sourceAudioTracks: [audio], + selectedAudioStreamId: 1, + ); + + expect(result.canSwitch, isTrue); + expect(result.availableVersions, [version]); + expect(result.serverSupportsTranscoding, isTrue); + expect(result.isTranscoding, isTrue); + expect(result.sourceAudioTracks, [audio]); + expect(result.selectedAudioStreamId, 1); + }); + }); +}