Files
plezy/lib/connection/connection_bootstrap.dart
edde746 8033a308ce refactor(connection): drop dead status/kind enums, share the Plex account build pipeline
Connection.status was write-only health state (only the Jellyfin refresh wrote it, nothing read it — MultiServerManager owns real server status), ConnectionKind duplicated MediaBackend line for line, the token-to-PlexAccountConnection pipeline existed three times (sign-in, dev-token seed, legacy migration) with drifting label/dedup policy, and JellyfinConnectionAuthService.validate/refresh/signOut had no production callers.

Connection.kind is now MediaBackend (persisted 'plex'/'jellyfin'/'emby' strings are identical, no migration); buildPlexAccountConnection in plex_account_setup.dart owns identity resolution with the three callers keeping only their genuine deltas; the test-only auth trio and its jellyfinSignOut timeout constant are gone.
2026-08-17 19:02:18 +02:00

268 lines
11 KiB
Dart

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';
import 'plex_account_setup.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<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
final StorageService storage;
final ConnectionRegistry connectionRegistry;
final ServerRegistry serverRegistry;
final ProfileRegistry profileRegistry;
final Future<List<PlexHomeUser>> Function(String accountToken) _plexHomeUserFetcher;
final Future<Map<String, dynamic>> 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<void> 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<void> seedFromDevTokenDefine() async {
const devToken = String.fromEnvironment('PLEX_TOKEN');
if (devToken.isEmpty) return;
final existing = await connectionRegistry.list();
if (existing.whereType<PlexAccountConnection>().isNotEmpty) return;
try {
// The dev seed always keys its row by the device client id and aborts
// on an identity-lookup failure — a bad PLEX_TOKEN must not seed a
// mislabelled row.
final build = await buildPlexAccountConnection(devToken, keyByAccountUuid: false, tolerateUserInfoFailure: false);
await connectionRegistry.upsert(build.connection);
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${build.connection.id}');
} 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<PlexAccountConnection?> migrateLegacyPlexAccount() async {
final token = storage.getPlexToken();
if (token == null || token.isEmpty) return null;
final existing = await connectionRegistry.list();
final alreadyMigrated = existing
.whereType<PlexAccountConnection>()
.where((c) => c.accountToken == token)
.firstOrNull;
if (alreadyMigrated != null) {
return alreadyMigrated;
}
try {
final build = await buildPlexAccountConnection(
token,
clientIdentifier: await storage.getOrCreateClientIdentifier(),
fetchUserInfo: _plexUserInfoFetcher,
// The migration wraps the cached legacy server list; a network server
// fetch could fail exactly when the migration must succeed offline.
fetchServers: (_) => serverRegistry.getServers(),
);
await connectionRegistry.upsert(build.connection);
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${build.connection.id}');
return build.connection;
} 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<bool> _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<PlexHomeUser> users) {
return users.firstWhere((u) => u.admin, orElse: () => users.first);
}
Future<bool> _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<String, dynamic>) 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<PlexHomeUser>? _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<List<PlexHomeUser>> _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<void> _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<PlexAccountConnection?> _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<void> _recoverLegacyProfilePromotionIfNeeded(PlexAccountConnection? account) async {
if (!_hasLegacyProfileState()) return;
final target = await _firstPlexAccount(account);
if (target == null) return;
await _preparePlexVirtualProfile(target);
}
}
Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async {
final auth = await PlexAuthService.create();
try {
return await auth.getUserInfo(accountToken);
} finally {
auth.dispose();
}
}