import 'dart:async'; import 'dart:convert'; 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); } /// 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` and `createdAt` (so token/metadata /// refreshes don't clear the default flag or restamp creation order). /// /// Creation order is behaviour, not bookkeeping: it decides which /// connection lends a profile its picture, and `remove` promotes the oldest /// remaining row to default. Re-authenticating rebuilds the model with /// `DateTime.now()` and reuses the same stable id, so without this the /// originally-first connection would jump to last on every re-sign-in. Future upsert(Connection connection) async { await _db.runIdentityMutation(() async { final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull(); final bool isDefault; final int createdAt; if (existing != null) { isDefault = existing.isDefault; createdAt = existing.createdAt; } else { final any = await (_db.selectOnly(_db.connections) ..addColumns([_db.connections.id]) ..limit(1)) .getSingleOrNull(); isDefault = any == null; createdAt = connection.createdAt.millisecondsSinceEpoch; } 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(createdAt), 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.runIdentityMutation(() 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.runIdentityMutation(() 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.runIdentityMutation(() async { await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write( ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)), ); }); } Future clear() async { await _db.runIdentityMutation(() 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(); } /// 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; } 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; } } }