Files
plezy/lib/profiles/plex_home_service.dart
T

299 lines
11 KiB
Dart

import 'dart:async';
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';
import 'plex_home_cache_codec.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<Map<connectionId, List<PlexHomeUser>>>` 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 this._connections,
required this._profileConnections,
this._storage,
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
this._refreshInterval = const Duration(hours: 1),
}) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher;
final ConnectionRegistry _connections;
final ProfileConnectionRegistry _profileConnections;
StorageService? _storage;
final Future<List<PlexHomeUser>> Function(String accountToken) _fetchHomeUsers;
final Duration _refreshInterval;
final Map<String, List<PlexHomeUser>> _byConnection = {};
final _controller = StreamController<Map<String, List<PlexHomeUser>>>.broadcast();
StreamSubscription<List<Connection>>? _connSub;
Timer? _refreshTimer;
Future<void>? _startFuture;
bool _started = false;
/// Snapshot of the current cache (immutable view).
Map<String, List<PlexHomeUser>> 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<Map<String, List<PlexHomeUser>>> get stream {
late StreamController<Map<String, List<PlexHomeUser>>> ctrl;
StreamSubscription<Map<String, List<PlexHomeUser>>>? sub;
ctrl = StreamController<Map<String, List<PlexHomeUser>>>(
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<void> 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<void> reloadFromStorage() async {
await start();
_storage ??= await StorageService.getInstance();
final current = await _connections.list();
final plexIds = current.whereType<PlexAccountConnection>().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<PlexAccountConnection>()) {
final cached = _readCache(conn.id);
if (cached == null) continue;
_byConnection[conn.id] = cached;
changed = true;
}
if (changed) _emit();
}
Future<void> _start() async {
_storage ??= await StorageService.getInstance();
final initial = await _connections.list();
for (final conn in initial.whereType<PlexAccountConnection>()) {
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<void> _onChange(List<Connection> current) async {
final storage = _storage;
if (storage == null) return;
final plexConns = current.whereType<PlexAccountConnection>().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<void> _refreshAll() async {
final list = await _connections.list();
for (final conn in list.whereType<PlexAccountConnection>()) {
unawaited(_fetchAndCache(conn));
}
}
/// Force-refresh a single account. Useful after sign-in / borrow flows.
/// Returns whether the fetch succeeded (callers that REQUIRE home users —
/// e.g. first sign-in, which can't build any profile without them — must
/// not conflate a failed fetch with "no users").
Future<bool> refresh(PlexAccountConnection conn) => _fetchAndCache(conn);
Future<bool> _fetchAndCache(PlexAccountConnection conn) async {
if (conn.accountToken.isEmpty) {
appLogger.w('PlexHomeService: skipping fetch for ${conn.accountLabel} (${conn.id}) — empty token');
return false;
}
final storage = _storage ?? await StorageService.getInstance();
_storage = storage;
try {
final users = await _fetchHomeUsers(conn.accountToken);
// The account may have been removed while the fetch was in flight —
// caching now would resurrect its home users (and virtual profiles)
// as ghosts until the next removal event.
if (await _connections.get(conn.id) == null) {
appLogger.d('PlexHomeService: dropping fetch result for removed account ${conn.accountLabel}');
return false;
}
final encoded = encodePlexHomeUsersCache(users);
// Unchanged fetches (the hourly ticker, mostly) must not emit: every
// emission fans out through ActiveProfileProvider into a full
// recompute/notify cascade across the app.
if (_byConnection.containsKey(conn.id) &&
storage.getPlexHomeUsersCacheJson(conn.id) == encodePlexHomeUsersCacheJson(users)) {
appLogger.d('PlexHomeService: home users unchanged for ${conn.accountLabel}');
return true;
}
_byConnection[conn.id] = users;
await storage.savePlexHomeUsersCache(conn.id, encoded);
_emit();
appLogger.d('PlexHomeService: cached ${users.length} home users for ${conn.accountLabel}');
return true;
} catch (e, st) {
appLogger.w('PlexHomeService: refresh failed for ${conn.accountLabel}', error: e, stackTrace: st);
return false;
}
}
List<PlexHomeUser>? _readCache(String connectionId) {
final storage = _storage;
if (storage == null) return null;
final raw = storage.getPlexHomeUsersCacheJson(connectionId);
if (raw == null) return null;
try {
return decodePlexHomeUsersCache(raw);
} 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<PlexHome?> 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<PlexHome?> materializeFirstPlexHome() async {
await start();
final all = await _connections.list();
final first = all.whereType<PlexAccountConnection>().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<void> clearAll() async {
_byConnection.clear();
final storage = _storage ?? await StorageService.getInstance();
await storage.clearAllPlexHomeUsersCache();
_emit();
}
Future<void> dispose() async {
_refreshTimer?.cancel();
_refreshTimer = null;
await _connSub?.cancel();
_connSub = null;
_startFuture = null;
if (!_controller.isClosed) await _controller.close();
_started = false;
}
}
Future<List<PlexHomeUser>> _defaultHomeUserFetcher(String accountToken) async {
final auth = await PlexAuthService.create();
try {
final home = await auth.getHomeUsers(accountToken);
return home.users;
} finally {
auth.dispose();
}
}