import 'dart:convert'; import '../models/plex/plex_home.dart'; import '../models/plex/plex_home_user.dart'; import '../profiles/profile.dart'; import '../profiles/plex_home_cache_codec.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'; /// 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; } } 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 { return decodePlexHomeUsersCache(raw); } 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, encodePlexHomeUsersCache(users)); 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')]); } } 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> _fetchPlexUserInfo(String accountToken) async { final auth = await PlexAuthService.create(); try { return await auth.getUserInfo(accountToken); } finally { auth.dispose(); } }