import '../media/media_backend.dart'; import '../models/plex/plex_home_user.dart'; import '../services/plex_auth_service.dart'; import '../utils/json_utils.dart'; import '../utils/url_utils.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; /// Active server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`. final String baseUrl; /// Candidate server URLs for this Jellyfin server, with [baseUrl] first. /// Existing installs only have [baseUrl]; deserialization backfills this. final List baseUrls; /// Server's reported name (System/Info). final String serverName; /// 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; /// The authenticated user's `PrimaryImageTag`, or `null` when they have no /// profile picture. Jellyfin omits the key entirely in that case, and the /// tag is `MD5(imagePath + lastModified)` so it changes on every upload — /// which makes the derived avatar URL self-invalidating. Captured at auth /// time and refreshed by [JellyfinClient.checkHealth]. final String? primaryImageTag; JellyfinConnection({ required this.id, required String baseUrl, List? baseUrls, required this.serverName, required this.serverMachineId, required this.userId, required this.userName, required this.accessToken, required this.deviceId, this.isAdministrator = false, this.primaryImageTag, this.status = ConnectionStatus.unknown, required this.createdAt, this.lastAuthenticatedAt, }) : baseUrl = canonicalizeBaseUrl(baseUrl), baseUrls = _normalizeBaseUrls(baseUrl, baseUrls); @override ConnectionKind get kind => ConnectionKind.jellyfin; @override String get displayName => '$userName · $serverName'; @override String get displayLabel => serverName; @override String? get displaySubtitle { final extraCount = baseUrls.length - 1; final suffix = extraCount > 0 ? ' +$extraCount' : ''; return '$userName · ${_truncateUrl(baseUrl)}$suffix'; } static String _truncateUrl(String url) { if (url.length <= 40) return url; return '${url.substring(0, 37)}…'; } static List _normalizeBaseUrls(String activeBaseUrl, List? urls) { final result = []; final seen = {}; void add(String url) { final normalized = canonicalizeBaseUrl(url); if (normalized.isEmpty || !seen.add(normalized)) return; result.add(normalized); } add(activeBaseUrl); for (final url in urls ?? const []) { add(url); } return List.unmodifiable(result); } JellyfinConnection copyWith({ String? id, String? baseUrl, List? baseUrls, String? serverName, String? serverMachineId, String? userId, String? userName, String? accessToken, String? deviceId, bool? isAdministrator, String? primaryImageTag, /// Deleting a Jellyfin profile picture drops `PrimaryImageTag` from the /// user DTO, so a refresh must be able to null the cached value — a bare /// `primaryImageTag: null` is indistinguishable from "unchanged". bool clearPrimaryImageTag = false, ConnectionStatus? status, DateTime? createdAt, DateTime? lastAuthenticatedAt, }) { final nextBaseUrl = baseUrl ?? this.baseUrl; return JellyfinConnection( id: id ?? this.id, baseUrl: nextBaseUrl, baseUrls: baseUrls ?? this.baseUrls, 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, primaryImageTag: clearPrimaryImageTag ? null : (primaryImageTag ?? this.primaryImageTag), status: status ?? this.status, createdAt: createdAt ?? this.createdAt, lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt, ); } @override Map toConfigJson() { return { 'baseUrl': baseUrl, 'baseUrls': baseUrls, 'serverName': serverName, 'serverMachineId': serverMachineId, 'userId': userId, 'userName': userName, 'accessToken': accessToken, 'deviceId': deviceId, 'isAdministrator': isAdministrator, 'primaryImageTag': primaryImageTag, }; } factory JellyfinConnection.fromConfigJson({ required String id, required Map json, required ConnectionStatus status, required DateTime createdAt, DateTime? lastAuthenticatedAt, }) { final rawBaseUrls = json['baseUrls']; final baseUrls = rawBaseUrls is List ? rawBaseUrls.whereType().toList(growable: false) : const []; final rawBaseUrl = json['baseUrl'] as String?; final baseUrl = rawBaseUrl != null && rawBaseUrl.isNotEmpty ? rawBaseUrl : (baseUrls.isNotEmpty ? baseUrls.first : ''); return JellyfinConnection( id: id, baseUrl: baseUrl, baseUrls: baseUrls, 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, primaryImageTag: normalizePrimaryImageTag(json['primaryImageTag']), status: status, createdAt: createdAt, lastAuthenticatedAt: lastAuthenticatedAt, ); } /// Tolerant read of a Jellyfin user DTO's `PrimaryImageTag`. /// /// The tag is decorative — a fork or a drifted scalar type must never brick /// sign-in — so this coerces through [readStringField] rather than casting, /// and collapses absent/blank to `null` ("no picture"). static String? readPrimaryImageTag(Map userDto) => normalizePrimaryImageTag(readStringField(userDto, 'PrimaryImageTag')); static String? normalizePrimaryImageTag(Object? raw) { final tag = raw?.toString().trim(); return tag == null || tag.isEmpty ? null : tag; } }