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; } /// Re-read per-connection Plex Home user caches from storage. /// /// This is used after boot-time legacy migration. The service is started /// before [ConnectionBootstrap] runs, so it may have already missed the /// copied `plex_home_users_{connectionId}` cache and new connection row. Future reloadFromStorage() async { await start(); _storage ??= await StorageService.getInstance(); final current = await _connections.list(); final plexIds = current.whereType().map((c) => c.id).toSet(); var changed = false; for (final id in _byConnection.keys.toList()) { if (!plexIds.contains(id)) { _byConnection.remove(id); changed = true; } } for (final conn in current.whereType()) { final cached = _readCache(conn.id); if (cached == null) continue; _byConnection[conn.id] = cached; changed = true; } if (changed) _emit(); } 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(); } }