diff --git a/lib/media/account_preferences_target.dart b/lib/media/account_preferences_target.dart index 35e3950f6..57932d18e 100644 --- a/lib/media/account_preferences_target.dart +++ b/lib/media/account_preferences_target.dart @@ -13,7 +13,7 @@ class AccountPreferenceTarget { required this.ref, required this.label, this.subtitle, - this.isActiveProfileAccount = false, + this.isDefaultConnection = false, }); final AccountRef ref; @@ -24,9 +24,9 @@ class AccountPreferenceTarget { /// Secondary row label: user · URL (MediaBrowser) or Home user (Plex). final String? subtitle; - /// Whether this is the account the active profile is currently browsing - /// with. Used to order the picker; not a permission. - final bool isActiveProfileAccount; + /// Whether the profile designated this connection as its picker default. + /// Presentation only: this does not establish playback authority. + final bool isDefaultConnection; MediaBackend get backend => ref.backend; } diff --git a/lib/profiles/active_plex_token.dart b/lib/profiles/active_plex_token.dart index b16d96642..42e401f35 100644 --- a/lib/profiles/active_plex_token.dart +++ b/lib/profiles/active_plex_token.dart @@ -16,7 +16,7 @@ class ActivePlexToken { final String token; } -/// THE resolver for the active profile's effective Plex credential. +/// Resolve the active profile's effective Plex credential for cloud callers. /// /// One policy: the profile's per-user token (the `ProfileConnection` row /// bound to the identity's account, minted via `/home/users/{uuid}/switch`) @@ -24,11 +24,9 @@ class ActivePlexToken { /// /// [allowAccountTokenForHomeUser] controls the owner-token fallback for Plex /// Home profiles. Cloud Discover and Seerr pass `true` — any credential on -/// the account is acceptable there. User-settings resolution passes `false` -/// because the owner's token belongs to a *different* plex.tv user, so a -/// Home profile without its switched token resolves to `null` instead of -/// silently impersonating the owner. Local profiles always fall back: their -/// selected account *is* the profile's identity. +/// the account is acceptable there. Account preferences resolve separately: +/// their Home principal requires its exact parent's switched token, without +/// owner or other-account fallback. Local profiles retain owner-token fallback. Future resolveActivePlexToken({ required ActiveProfileProvider activeProfile, required ConnectionRegistry connections, diff --git a/lib/providers/account_preferences_controller.dart b/lib/providers/account_preferences_controller.dart index 1c60f3c0a..dfb8a6f80 100644 --- a/lib/providers/account_preferences_controller.dart +++ b/lib/providers/account_preferences_controller.dart @@ -64,6 +64,8 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN StreamSubscription>? _profileConnectionsSubscription; Profile? _watchedProfile; String? _lastSeenActiveProfileId; + int _generation = 0; + int _accountGeneration = 0; /// Latest rows the registry watchers delivered, null until each has emitted /// for the current registry/profile. Accounts resolve from these rather than @@ -100,7 +102,11 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN /// awaits this before entering the session so the first playback does not /// race the fetch; an already loaded value is kept rather than re-fetched. Future ensureActiveLoaded() async { - await _activeProfile?.awaitBindingSettle(); + while (!isDisposed) { + final generation = _generation; + await _activeProfile?.awaitBindingSettle(); + if (generation == _generation) break; + } // Every watcher emission after attach supersedes the previous resolve; // waiting for the chain to go quiet is what makes the cached check below // meaningful. @@ -125,12 +131,18 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN }) { if (isDisposed) return; _serverManager = serverManager; + final dependenciesChanged = + (_connections != null && !identical(_connections, connections)) || + (_profileConnections != null && !identical(_profileConnections, profileConnections)) || + (_activeProfile != null && !identical(_activeProfile, activeProfile)); + if (dependenciesChanged) _resetScope(); if (!identical(_connections, connections)) { _connections = connections; _connectionRows = null; _connectionsSubscription?.cancel(); _connectionsSubscription = connections.watchConnections().listen((rows) { + if (isDisposed || !identical(_connections, connections)) return; _connectionRows = rows; _resolve(); }); @@ -159,17 +171,31 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN final active = _activeProfile; if (active == null) return; final id = active.activeId; - if (id == _lastSeenActiveProfileId) return; + if (id == _lastSeenActiveProfileId) { + final profile = active.active; + if (_watchedProfile?.kind != profile?.kind || + _watchedProfile?.parentConnectionId != profile?.parentConnectionId || + _watchedProfile?.plexHomeUserUuid != profile?.plexHomeUserUuid) { + _resetScope(); + } + _watchedProfile = profile; + _resolve(); + return; + } _lastSeenActiveProfileId = id; - // Another user's values must not survive the switch, not even for the - // duration of the next fetch. - _activeRef = null; - repository.clear(); - safeNotifyListeners(); + _resetScope(); _watchActiveProfile(active.active); _resolve(); } + void _resetScope() { + _generation++; + _activeRef = null; + _accounts = const []; + repository.clear(); + safeNotifyListeners(); + } + void _watchActiveProfile(Profile? profile) { final registry = _profileConnections; if (_watchedProfile?.id == profile?.id && _profileConnectionsSubscription != null) return; @@ -181,6 +207,7 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN if (registry == null || profile == null) return; _profileConnectionsSubscription = registry.watchForProfile(profile.id).listen((rows) { + if (isDisposed || _watchedProfile?.id != profile.id || !identical(_profileConnections, registry)) return; _profileConnectionRows = rows; _resolve(); }); @@ -191,23 +218,24 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN /// [ensureActiveLoaded] has something to wait for. No active profile /// resolves to no accounts at once. void _resolve() { + if (isDisposed) return; final profile = _watchedProfile; final connectionRows = _connectionRows; final profileRows = profile == null ? const [] : _profileConnectionRows; - if (connectionRows == null || profileRows == null) { + if (profile != null && (connectionRows == null || profileRows == null)) { _resolveTask = (_firstRows ??= Completer()).future; return; } - List resolved; + AccountPreferenceResolution resolved; try { resolved = resolveAccountPreferenceAccounts( profile: profile, - profileConnections: profileRows, - connections: connectionRows, + profileConnections: profileRows ?? const [], + connections: connectionRows ?? const [], ); } catch (error, stackTrace) { appLogger.w('AccountPreferencesController: failed to resolve accounts', error: error, stackTrace: stackTrace); - resolved = const []; + resolved = const AccountPreferenceResolution(); } final firstRows = _firstRows; _firstRows = null; @@ -215,30 +243,52 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN firstRows?.complete(); } - Future _applyAccounts(List resolved) async { - final changed = !_sameAccounts(_accounts, resolved); - if (changed) { - _accounts = resolved; - safeNotifyListeners(); + Future _applyAccounts(AccountPreferenceResolution resolved) async { + final accounts = resolved.accounts; + final byRef = {for (final account in accounts) account.ref: account}; + final previousRefs = {for (final account in _accounts) account.ref}; + for (final account in accounts) { + if (!previousRefs.contains(account.ref)) repository.invalidate(account.ref); } - // Sorted best-first, so the head is the active profile's own account. - final ref = resolved.isEmpty ? null : resolved.first.ref; + var credentialsChanged = false; + for (final previous in _accounts) { + final current = byRef[previous.ref]; + if (current == null || !_sameCredentials(previous, current)) { + credentialsChanged = true; + repository.invalidate(previous.ref); + } + } + if (credentialsChanged) _accountGeneration++; + final changed = !_sameAccounts(_accounts, accounts); + final ref = resolved.playbackRef; final refChanged = ref != _activeRef; + _accounts = accounts; _activeRef = ref; - if (ref == null) return; - // Re-read on any account change (a re-minted token must not serve the old - // cached value) and on a profile switch that resolves to the same account - // (the switch cleared the cache). - if (changed || refChanged) await _loadActive(ref); + // Consumers must observe the new list, authority and invalidated cache in + // the same notification, including transitions to no eligible principal. + if (changed || refChanged) safeNotifyListeners(); + if (ref != null && (refChanged || repository.cached(ref) == null)) await _loadActive(ref); + } + + static bool _sameCredentials(AccountPreferenceAccount a, AccountPreferenceAccount b) { + if (a.plexToken != b.plexToken) return false; + final left = a.connection; + final right = b.connection; + if (left is JellyfinConnection && right is JellyfinConnection) { + return left.accessToken == right.accessToken && left.userId == right.userId && left.deviceId == right.deviceId; + } + return true; } Future _loadActive(AccountRef ref) async { + final generation = _generation; + final accountGeneration = _accountGeneration; // The binder mints a Home token and connects servers after activation; // reading before it settles would fail on an unminted token or an absent // client. Concurrent loads for one account share a request in the // repository, so re-entering here is cheap. await _activeProfile?.awaitBindingSettle(); - if (isDisposed || ref != _activeRef) return; + if (isDisposed || generation != _generation || accountGeneration != _accountGeneration || ref != _activeRef) return; try { await repository.load(ref, forceRefresh: true); } on AccountPreferencesUnavailableException { @@ -252,21 +302,27 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN } } - Future> _readAccounts() async { + Future _readAccounts() async { + final generation = _generation; final connections = _connections; final profileConnections = _profileConnections; final profile = _activeProfile?.active; - if (connections == null || profileConnections == null || profile == null) return const []; + if (connections == null || profileConnections == null || profile == null) { + return const AccountPreferenceResolution(); + } try { + final profileRows = await profileConnections.listForProfile(profile.id); + final connectionRows = await connections.list(); + if (isDisposed || generation != _generation) return const AccountPreferenceResolution(); return resolveAccountPreferenceAccounts( profile: profile, - profileConnections: await profileConnections.listForProfile(profile.id), - connections: await connections.list(), + profileConnections: profileRows, + connections: connectionRows, ); } catch (error, stackTrace) { appLogger.w('AccountPreferencesController: failed to resolve accounts', error: error, stackTrace: stackTrace); - return const []; + return const AccountPreferenceResolution(); } } @@ -274,7 +330,8 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (a[i].ref != b[i].ref || a[i].target.label != b[i].target.label) return false; - if (a[i].target.subtitle != b[i].target.subtitle || a[i].plexToken != b[i].plexToken) return false; + if (a[i].target.subtitle != b[i].target.subtitle || !_sameCredentials(a[i], b[i])) return false; + if (a[i].target.isDefaultConnection != b[i].target.isDefaultConnection) return false; } return true; } @@ -282,7 +339,7 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN /// The account backing [connectionId] for the active profile, resolved fresh /// so a caller during startup does not race the first snapshot. Future accountForConnectionId(String connectionId) async { - for (final account in await _readAccounts()) { + for (final account in (await _readAccounts()).accounts) { if (account.ref.connectionId == connectionId) return account; } return null; @@ -292,30 +349,41 @@ class AccountPreferencesController extends ChangeNotifier with DisposableChangeN /// unreachable. Resolved per call: a Plex Home token is minted lazily by the /// binder and a MediaBrowser client only exists once its server is online. Future _sourceFor(AccountRef ref) async { + final generation = _generation; + AccountPreferenceAccount? selected; + for (final account in (await _readAccounts()).accounts) { + if (account.ref == ref) { + selected = account; + break; + } + } + if (isDisposed || generation != _generation || selected == null) return null; + for (final current in _accounts) { + if (current.ref == ref && !_sameCredentials(current, selected)) return null; + } switch (ref.backend) { case MediaBackend.jellyfin: case MediaBackend.emby: final client = _serverManager?.getJellyfinClientByCompoundId(ref.connectionId); if (client is! JellyfinClient) return null; + final connection = selected.connection; + if (connection is! JellyfinConnection || + client.connection.accessToken != connection.accessToken || + client.connection.userId != connection.userId || + client.connection.deviceId != connection.deviceId) { + return null; + } return MediaBrowserAccountPreferencesSource(client); case MediaBackend.plex: - final token = await _resolvePlexToken(ref); + final token = selected.plexToken; if (token == null || token.isEmpty) return null; return PlexAccountPreferencesSource(authToken: token, serviceFactory: _plexServiceFactory); } } - /// The token for [ref], re-resolved rather than read off the cached account - /// list so a freshly minted Home-user token is picked up immediately. - Future _resolvePlexToken(AccountRef ref) async { - for (final account in await _readAccounts()) { - if (account.ref == ref) return account.plexToken; - } - return null; - } - @override void dispose() { + _generation++; _activeProfile?.removeListener(_onActiveProfileChanged); _connectionsSubscription?.cancel(); _profileConnectionsSubscription?.cancel(); diff --git a/lib/screens/settings/account_preferences_detail_screen.dart b/lib/screens/settings/account_preferences_detail_screen.dart index 0d38dffd9..fc5e6a130 100644 --- a/lib/screens/settings/account_preferences_detail_screen.dart +++ b/lib/screens/settings/account_preferences_detail_screen.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; import '../../media/account_preferences.dart'; import '../../media/account_preferences_target.dart'; +import '../../media/account_ref.dart'; import '../../media/media_server_user_profile.dart'; import '../../services/account_preferences_repository.dart'; import '../../utils/app_logger.dart'; @@ -56,7 +57,7 @@ class AccountPreferencesDetailScreen extends StatelessWidget { Widget build(BuildContext context) { return SettingsPage.slivers( title: Text(target.label), - slivers: [AccountPreferencesBody(target: target)], + slivers: [AccountPreferencesBody(key: ValueKey(target.ref), target: target)], ); } } @@ -75,6 +76,8 @@ class AccountPreferencesBody extends StatefulWidget { class _AccountPreferencesBodyState extends State { late final AccountPreferencesRepository _repository; + late final StreamSubscription _repositoryChanges; + int _loadGeneration = 0; /// Loaded values; null while the first (or a retried) read is outstanding. AccountPreferences? _preferences; @@ -87,20 +90,62 @@ class _AccountPreferencesBodyState extends State { void initState() { super.initState(); _repository = context.read(); + _repositoryChanges = _repository.changes.listen(_onRepositoryChanged); unawaited(_load()); } + @override + void didUpdateWidget(covariant AccountPreferencesBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.target.ref == widget.target.ref) return; + _preferences = null; + _error = null; + unawaited(_load()); + } + + @override + void dispose() { + _loadGeneration++; + unawaited(_repositoryChanges.cancel()); + super.dispose(); + } + + void _onRepositoryChanged(AccountRef ref) { + if (!mounted || ref != widget.target.ref) return; + final preferences = _repository.cached(ref); + if (preferences == null) { + setState(() { + _preferences = null; + _error = null; + }); + unawaited(_load()); + } else if (!identical(preferences, _preferences) || _error != null) { + _loadGeneration++; + setState(() { + _preferences = preferences; + _error = null; + }); + } + } + Future _load({bool forceRefresh = false}) async { + final ref = widget.target.ref; + final generation = ++_loadGeneration; try { - final preferences = await _repository.load(widget.target.ref, forceRefresh: forceRefresh); - if (!mounted) return; + final preferences = await _repository.load(ref, forceRefresh: forceRefresh); + if (!mounted || + widget.target.ref != ref || + generation != _loadGeneration || + !identical(_repository.cached(ref), preferences)) { + return; + } setState(() { _preferences = preferences; _error = null; }); } on Exception catch (error, stackTrace) { appLogger.e('Account preferences load failed', error: error, stackTrace: stackTrace); - if (!mounted) return; + if (!mounted || widget.target.ref != ref || generation != _loadGeneration) return; setState(() => _error = error); } } @@ -116,8 +161,15 @@ class _AccountPreferencesBodyState extends State { /// Writes one key and adopts the account's state afterwards. Failures /// propagate so the row that started the write reverts and reports them. Future _write(AccountPreferenceKey key, Object? value) async { - final updated = await _repository.update(widget.target.ref, AccountPreferencesPatch.of(key, value)); - if (!mounted) return; + final ref = widget.target.ref; + final generation = _loadGeneration; + final updated = await _repository.update(ref, AccountPreferencesPatch.of(key, value)); + if (!mounted || + widget.target.ref != ref || + generation != _loadGeneration || + !identical(_repository.cached(ref), updated)) { + return; + } setState(() => _preferences = updated); } diff --git a/lib/screens/settings/account_preferences_screen.dart b/lib/screens/settings/account_preferences_screen.dart index 85bfb5038..901fb057b 100644 --- a/lib/screens/settings/account_preferences_screen.dart +++ b/lib/screens/settings/account_preferences_screen.dart @@ -46,17 +46,10 @@ class AccountPreferencesScreen extends StatelessWidget { if (targets.length == 1) { return SettingsPage.slivers( title: title, - slivers: [AccountPreferencesBody(target: targets.first)], + slivers: [AccountPreferencesBody(key: ValueKey(targets.first.ref), target: targets.first)], ); } - // The account the user is browsing with is the one they came here for; - // that is what AccountPreferenceTarget.isActiveProfileAccount is for. Both - // halves keep the caller's order. - final ordered = [ - ...targets.where((target) => target.isActiveProfileAccount), - ...targets.where((target) => !target.isActiveProfileAccount), - ]; final theme = Theme.of(context); return SettingsPage( @@ -71,7 +64,7 @@ class AccountPreferencesScreen extends StatelessWidget { ), SettingsGroup( children: [ - for (final target in ordered) + for (final target in targets) FocusableListTile( key: ValueKey(target.ref.key), leading: BackendBadge(backend: target.backend, size: 24), diff --git a/lib/services/account_preferences_accounts.dart b/lib/services/account_preferences_accounts.dart index b86f42d59..3c1597385 100644 --- a/lib/services/account_preferences_accounts.dart +++ b/lib/services/account_preferences_accounts.dart @@ -22,6 +22,14 @@ class AccountPreferenceAccount { AccountRef get ref => target.ref; } +/// Picker ordering and playback authority are separate profile policies. +class AccountPreferenceResolution { + const AccountPreferenceResolution({this.accounts = const [], this.playbackRef}); + + final List accounts; + final AccountRef? playbackRef; +} + /// Resolve the accounts whose preferences the active profile may edit. /// /// Scoped to [profile]'s own connection rows: the section edits the signed-in @@ -33,52 +41,57 @@ class AccountPreferenceAccount { /// back to the account-owner token would read and *write* the owner's /// preferences — and apply them to playback — while the user is in a managed /// profile. -List resolveAccountPreferenceAccounts({ +AccountPreferenceResolution resolveAccountPreferenceAccounts({ required Profile? profile, required List profileConnections, required List connections, }) { - if (profile == null || profileConnections.isEmpty) return const []; + if (profile == null || profileConnections.isEmpty) return const AccountPreferenceResolution(); final byId = {for (final connection in connections) connection.id: connection}; final isPlexHomeProfile = profile.kind == ProfileKind.plexHome; final accounts = []; + AccountRef? playbackRef; for (final row in profileConnections) { + if (row.profileId != profile.id) continue; final connection = byId[row.connectionId]; - switch (connection) { - case JellyfinConnection(): - accounts.add( - AccountPreferenceAccount( - target: AccountPreferenceTarget( - ref: AccountRef.mediaBrowser(backend: connection.kind, connectionId: connection.id), - label: connection.displayLabel, - subtitle: connection.displaySubtitle, - isActiveProfileAccount: row.isDefault, - ), - connection: connection, - ), - ); - case PlexAccountConnection(): - final resolved = _resolvePlexAccount( - profile: profile, - isPlexHomeProfile: isPlexHomeProfile, - row: row, - connection: connection, - ); - if (resolved != null) accounts.add(resolved); - case null: - continue; - } + final account = switch (connection) { + JellyfinConnection() => AccountPreferenceAccount( + target: AccountPreferenceTarget( + ref: AccountRef.mediaBrowser(backend: connection.kind, connectionId: connection.id), + label: connection.displayLabel, + subtitle: connection.displaySubtitle, + isDefaultConnection: row.isDefault, + ), + connection: connection, + ), + PlexAccountConnection() => _resolvePlexAccount( + profile: profile, + isPlexHomeProfile: isPlexHomeProfile, + row: row, + connection: connection, + ), + null => null, + }; + if (account == null) continue; + accounts.add(account); + // Home authority belongs to its exact parent, even when a borrowed + // connection is the editable picker's default. Locals use their designated + // row; a missing account never promotes another reachable connection. + final isPlaybackRow = isPlexHomeProfile + ? connection is PlexAccountConnection && row.connectionId == profile.parentConnectionId + : row.isDefault; + if (isPlaybackRow) playbackRef = account.ref; } accounts.sort((a, b) { - if (a.target.isActiveProfileAccount != b.target.isActiveProfileAccount) { - return a.target.isActiveProfileAccount ? -1 : 1; + if (a.target.isDefaultConnection != b.target.isDefaultConnection) { + return a.target.isDefaultConnection ? -1 : 1; } return a.target.label.toLowerCase().compareTo(b.target.label.toLowerCase()); }); - return accounts; + return AccountPreferenceResolution(accounts: accounts, playbackRef: playbackRef); } AccountPreferenceAccount? _resolvePlexAccount({ @@ -95,12 +108,12 @@ AccountPreferenceAccount? _resolvePlexAccount({ if (profile.parentConnectionId != connection.id) return null; homeUserUuid = profile.plexHomeUserUuid; if (homeUserUuid == null || homeUserUuid.isEmpty) return null; - if (!row.hasToken) return null; + if (row.userIdentifier != homeUserUuid || !row.hasToken) return null; token = row.userToken; } else { - // Local profile: the row's own minted token is already user-scoped; the - // account token is correct only when the profile signed in as the owner. - homeUserUuid = connection.activeProfile?.uuid; + // Preserve the local tokenless fallback policy. A switched row's principal + // comes from that row, never mutable account-level activeProfile metadata. + homeUserUuid = row.hasToken ? row.userIdentifier : connection.activeProfile?.uuid; token = row.hasToken ? row.userToken : connection.accountToken; if (token == null || token.isEmpty) return null; } @@ -112,7 +125,7 @@ AccountPreferenceAccount? _resolvePlexAccount({ ref: AccountRef.plex(accountConnectionId: connection.id, homeUserUuid: homeUserUuid), label: connection.accountLabel, subtitle: (homeUserTitle != null && homeUserTitle.isNotEmpty) ? homeUserTitle : null, - isActiveProfileAccount: row.isDefault, + isDefaultConnection: row.isDefault, ), connection: connection, plexToken: token, diff --git a/lib/services/account_preferences_repository.dart b/lib/services/account_preferences_repository.dart index db8cc80f3..9dbb5447b 100644 --- a/lib/services/account_preferences_repository.dart +++ b/lib/services/account_preferences_repository.dart @@ -42,6 +42,9 @@ class AccountPreferencesRepository { final Map _cache = {}; final Map> _inFlight = {}; + // Removal revokes publication ownership as well as deduplication. A later + // load of the same ref gets a new identity, including after clear/ABA. + final Map _revisions = {}; final Set _unreachable = {}; final StreamController _changes = StreamController.broadcast(); bool _disposed = false; @@ -77,17 +80,18 @@ class AccountPreferencesRepository { final pending = _inFlight[ref]; if (pending != null) return pending; - final future = _read(ref); + final revision = _revisions.putIfAbsent(ref, Object.new); + final future = _read(ref, revision); _inFlight[ref] = future; return future.whenComplete(() { if (identical(_inFlight[ref], future)) _inFlight.remove(ref); }); } - Future _read(AccountRef ref) async { - final source = await _requireSource(ref); + Future _read(AccountRef ref, Object revision) async { + final source = await _requireSource(ref, revision); final prefs = await source.read(); - if (_disposed) return prefs; + if (!_isCurrent(ref, revision)) return prefs; _cache[ref] = prefs; _emit(ref); return prefs; @@ -99,7 +103,8 @@ class AccountPreferencesRepository { /// hides those rows, so reaching here with one means a caller built a patch /// generically, and sending it would either 4xx or silently no-op. Future update(AccountRef ref, AccountPreferencesPatch patch) async { - final source = await _requireSource(ref); + final revision = _revisions.putIfAbsent(ref, Object.new); + final source = await _requireSource(ref, revision); final capabilities = source.capabilities; for (final key in patch.keys) { @@ -112,10 +117,12 @@ class AccountPreferencesRepository { for (final entry in patch.values.entries) if (capabilities.supports(entry.key)) entry.key: entry.value, }); - if (supported.isEmpty) return _cache[ref] ?? AccountPreferences.empty; + if (supported.isEmpty) { + return _isCurrent(ref, revision) ? _cache[ref] ?? AccountPreferences.empty : AccountPreferences.empty; + } final updated = await source.write(supported); - if (_disposed) return updated; + if (!_isCurrent(ref, revision)) return updated; _cache[ref] = updated; _emit(ref); return updated; @@ -123,29 +130,35 @@ class AccountPreferencesRepository { /// Drop [ref]'s cached values, e.g. after the account's token is re-minted. void invalidate(AccountRef ref) { + final hadState = _revisions.remove(ref) != null; + _inFlight.remove(ref); _unreachable.remove(ref); - if (_cache.remove(ref) != null) _emit(ref); + _cache.remove(ref); + if (hadState) _emit(ref); } /// Drop everything. Called on profile switch and sign-out so one user's /// preferences never answer for another. void clear() { + final refs = _revisions.keys.toList(); + _revisions.clear(); + _inFlight.clear(); _unreachable.clear(); - if (_cache.isEmpty) return; - final refs = _cache.keys.toList(); _cache.clear(); for (final ref in refs) { _emit(ref); } } - Future _requireSource(AccountRef ref) async { + bool _isCurrent(AccountRef ref, Object revision) => !_disposed && identical(_revisions[ref], revision); + + Future _requireSource(AccountRef ref, Object revision) async { final source = await _sourceFor(ref); if (source == null) { - _unreachable.add(ref); + if (_isCurrent(ref, revision)) _unreachable.add(ref); throw AccountPreferencesUnavailableException(ref); } - _unreachable.remove(ref); + if (_isCurrent(ref, revision)) _unreachable.remove(ref); return source; } @@ -158,6 +171,7 @@ class AccountPreferencesRepository { _disposed = true; _cache.clear(); _inFlight.clear(); + _revisions.clear(); _unreachable.clear(); _changes.close(); } diff --git a/test/providers/account_preferences_controller_test.dart b/test/providers/account_preferences_controller_test.dart index 5d3c5b5a2..1348c35b4 100644 --- a/test/providers/account_preferences_controller_test.dart +++ b/test/providers/account_preferences_controller_test.dart @@ -5,15 +5,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; -import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/media/account_preferences.dart'; +import 'package:plezy/media/media_browser_dialect.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/providers/account_preferences_controller.dart'; import 'package:plezy/services/plex_auth_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/utils/media_server_http_client.dart'; +import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/profile_stack.dart'; @@ -125,57 +128,169 @@ void main() { var notified = 0; fixture.controller.addListener(() => notified++); fixture.audioLanguage = 'deu'; - await fixture.controller.repository.load(ref, forceRefresh: true); + await fixture.controller.repository.update( + ref, + AccountPreferencesPatch.of(AccountPreferenceKey.preferredAudioLanguage, 'deu'), + ); expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'deu'); expect(notified, 1); }); - test('accounts resolve from the registry watcher rows instead of one-shot re-queries', () async { - final stack = await ProfileStack.create(); - final connections = _CountingConnectionRegistry(stack.db); - final profileConnections = _CountingProfileConnectionRegistry(stack.db); - final fixture = await _Fixture.local( - stack: stack, - connections: connections, - profileConnections: profileConnections, - ); + for (final dialect in MediaBrowserDialect.values) { + test('Home jpn survives a borrowed $dialect default, inverse changes and profile switches', () async { + final fixture = await _Fixture.plexHome(switchedToken: 'home-token'); + addTearDown(fixture.dispose); + await fixture.controller.ensureActiveLoaded(); + final home = fixture.stack.active.active!; + final borrowed = testJellyfinConnection(dialect: dialect); + await fixture.bindBorrowed(borrowed); + await pumpEventQueue(); + await fixture.controller.ensureActiveLoaded(); + expect(fixture.controller.accounts.first.ref.connectionId, borrowed.id); + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'jpn'); + + fixture.registerBorrowedClient(borrowed, language: 'deu'); + final borrowedRef = fixture.controller.accounts.first.ref; + await fixture.controller.repository.load(borrowedRef); + expect(fixture.controller.repository.cached(borrowedRef)?.defaultAudioLanguage, 'deu'); + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'jpn'); + + await fixture.stack.profileConnections.setDefault(home.id, _Fixture.parentAccountId); + await pumpEventQueue(); + await fixture.controller.ensureActiveLoaded(); + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'jpn'); + await fixture.stack.profileConnections.setDefault(home.id, borrowed.id); + await pumpEventQueue(); + final other = Profile.local(id: 'other', displayName: 'Other', createdAt: DateTime(2026)); + await fixture.stack.profiles.upsert(other); + await fixture.stack.active.activate(other); + expect(fixture.controller.activePreferences, isNull); + await fixture.stack.active.activate(home); + await fixture.controller.ensureActiveLoaded(); + expect(fixture.controller.accounts.first.ref.connectionId, borrowed.id); + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'jpn'); + await fixture.stack.profileConnections.remove(home.id, borrowed.id); + await pumpEventQueue(); + await fixture.controller.ensureActiveLoaded(); + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'jpn'); + expect(fixture.requests.every((request) => request.headers['X-Plex-Token'] == 'home-token'), isTrue); + }); + + test('local $dialect default selects its own preferences without reachable-account substitution', () async { + final fixture = await _Fixture.local(); + addTearDown(fixture.dispose); + await fixture.controller.ensureActiveLoaded(); + final borrowed = testJellyfinConnection(dialect: dialect); + await fixture.bindBorrowed(borrowed); + await pumpEventQueue(); + await fixture.controller.ensureActiveLoaded(); + expect(fixture.controller.activePreferences, isNull); + fixture.registerBorrowedClient(borrowed, language: 'deu'); + await fixture.controller.ensureActiveLoaded(); + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'deu'); + await fixture.stack.profileConnections.setDefault(fixture.stack.active.activeId!, 'plex-b'); + await pumpEventQueue(); + await fixture.controller.ensureActiveLoaded(); + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'fra'); + }); + } + + test('a borrowed default before Home token mint never supplies playback preferences', () async { + final fixture = await _Fixture.plexHome(); addTearDown(fixture.dispose); - - await fixture.controller.ensureActiveLoaded(); - expect(fixture.controller.accounts.single.ref.connectionId, 'plex-b'); - // The load's own token re-resolution is the only one-shot read; attach - // and both watcher replays resolved from the delivered rows. - expect(connections.listCalls, 1); - expect(profileConnections.listCalls, 1); - - // Mutations that leave this profile's accounts unchanged still make the - // watchers emit; nothing re-queries on top of that payload. - final other = Profile.local(id: 'local-other', displayName: 'Other', createdAt: DateTime(2026, 1, 2)); - await stack.profiles.upsert(other); - await stack.profileConnections.upsert( - ProfileConnection(profileId: other.id, connectionId: 'plex-a', userIdentifier: 'home-user-a', isDefault: true), - makeDefault: true, - ); + final borrowed = testJellyfinConnection(); + fixture.registerBorrowedClient(borrowed, language: 'deu'); + await fixture.bindBorrowed(borrowed); await pumpEventQueue(); - expect(fixture.controller.accounts.single.ref.connectionId, 'plex-b'); - expect(connections.listCalls, 1); - expect(profileConnections.listCalls, 1); - expect(fixture.requests, hasLength(1)); - - // A switch resolves against the new profile's rows only — never the - // previous profile's retained payload. - await stack.active.activate(other); await fixture.controller.ensureActiveLoaded(); - expect(fixture.controller.accounts.single.ref.connectionId, 'plex-a'); - expect(fixture.requests, hasLength(2)); - expect(fixture.requests.last.headers['X-Plex-Token'], 'wrong-owner-token'); + expect(fixture.controller.accounts.single.ref.connectionId, borrowed.id); + expect(fixture.controller.activePreferences, isNull); + expect(fixture.requests, isEmpty); + final loaded = fixture.nextLoad(); + await fixture.stack.profileConnections.upsert( + ProfileConnection( + profileId: fixture.stack.active.activeId!, + connectionId: _Fixture.parentAccountId, + userIdentifier: _Fixture.homeUserUuid, + userToken: 'late-home-token', + ), + ); + await loaded; + expect(fixture.controller.accounts.first.ref.connectionId, borrowed.id); + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'jpn'); + }); + + test('removing Home credentials publishes null rather than the previous cache or borrowed identity', () async { + final fixture = await _Fixture.plexHome(switchedToken: 'home-token'); + addTearDown(fixture.dispose); + await fixture.controller.ensureActiveLoaded(); + final borrowed = testJellyfinConnection(); + await fixture.bindBorrowed(borrowed); + await pumpEventQueue(); + final observed = []; + fixture.controller.addListener(() => observed.add(fixture.controller.activePreferences?.defaultAudioLanguage)); + await fixture.stack.profileConnections.recordToken(fixture.stack.active.activeId!, _Fixture.parentAccountId, ''); + await pumpEventQueue(); + await fixture.controller.ensureActiveLoaded(); + expect(fixture.controller.activePreferences, isNull); + expect(observed, isNotEmpty); + expect(observed, everyElement(isNull)); + }); + + test('an old load cannot repopulate preferences after A to B to A', () async { + final fixture = await _Fixture.local(); + addTearDown(fixture.dispose); + await fixture.controller.ensureActiveLoaded(); + final original = fixture.stack.active.active!; + final oldGate = Completer(); + final started = Completer(); + fixture.responseGate = oldGate.future; + fixture.requestStarted = started; + final oldLoad = fixture.controller.repository.load(fixture.controller.accounts.single.ref, forceRefresh: true); + await started.future; + final other = Profile.local(id: 'other', displayName: 'Other', createdAt: DateTime(2026)); + await fixture.stack.profiles.upsert(other); + await fixture.stack.active.activate(other); + fixture.responseGate = null; + fixture.audioLanguage = 'deu'; + await fixture.stack.active.activate(original); + await fixture.controller.ensureActiveLoaded(); + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'deu'); + oldGate.complete(); + await oldLoad; + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'deu'); + }); + + test('same-principal token refresh detaches an old load before publishing the replacement', () async { + final fixture = await _Fixture.plexHome(switchedToken: 'old-token'); + addTearDown(fixture.dispose); + await fixture.controller.ensureActiveLoaded(); + final oldGate = Completer(); + final started = Completer(); + fixture.responseGate = oldGate.future; + fixture.requestStarted = started; + final oldLoad = fixture.controller.repository.load(fixture.controller.accounts.single.ref, forceRefresh: true); + await started.future; + fixture.responseGate = null; + fixture.audioLanguage = 'deu'; + final replacement = fixture.nextLoad(); + await fixture.stack.profileConnections.recordToken( + fixture.stack.active.activeId!, + _Fixture.parentAccountId, + 'new-token', + ); + await replacement; + expect(fixture.requests.last.headers['X-Plex-Token'], 'new-token'); + oldGate.complete(); + await oldLoad; + expect(fixture.controller.activePreferences?.defaultAudioLanguage, 'deu'); }); }); } class _Fixture { - _Fixture._(this.stack, this.controller, this.requests); + _Fixture._(this.stack, this.controller, this.requests, this.serverManager); static const parentAccountId = 'plex-parent'; static const homeUserUuid = 'home-user-a'; @@ -184,6 +299,38 @@ class _Fixture { final AccountPreferencesController controller; final List requests; String audioLanguage = 'jpn'; + final MultiServerManager serverManager; + Future? responseGate; + Completer? requestStarted; + + Future bindBorrowed(JellyfinConnection connection) async { + await stack.connections.upsert(connection); + await stack.profileConnections.upsert( + ProfileConnection( + profileId: stack.active.activeId!, + connectionId: connection.id, + userIdentifier: connection.userId, + userToken: connection.accessToken, + isDefault: true, + ), + makeDefault: true, + ); + } + + void registerBorrowedClient(JellyfinConnection connection, {required String language}) { + serverManager.debugRegisterJellyfinClientForTesting( + testJellyfinClient( + connection: connection, + handler: (request) async => jsonResponse( + request.url.path.contains('/DisplayPreferences/') + ? {'CustomPrefs': {}} + : { + 'Configuration': {'AudioLanguagePreference': language}, + }, + ), + ), + ); + } /// Resolves when the repository next publishes a value for the active /// account. @@ -230,12 +377,8 @@ class _Fixture { return _attach(stack, audioLanguage: 'jpn'); } - static Future<_Fixture> local({ - ProfileStack? stack, - ConnectionRegistry? connections, - ProfileConnectionRegistry? profileConnections, - }) async { - stack ??= await ProfileStack.create(); + static Future<_Fixture> local() async { + final stack = await ProfileStack.create(); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); final accountA = PlexAccountConnection( id: 'plex-a', @@ -265,59 +408,41 @@ class _Fixture { ); await stack.storage.setActiveProfileId(profile.id); await stack.active.initialize(); - return _attach(stack, audioLanguage: 'fra', connections: connections, profileConnections: profileConnections); + return _attach(stack, audioLanguage: 'fra'); } - static _Fixture _attach( - ProfileStack stack, { - required String audioLanguage, - ConnectionRegistry? connections, - ProfileConnectionRegistry? profileConnections, - }) { + static _Fixture _attach(ProfileStack stack, {required String audioLanguage}) { final requests = []; + final serverManager = MultiServerManager(); late final _Fixture fixture; final controller = AccountPreferencesController( - plexServiceFactory: () async => _recordingAuth(requests, audioLanguage: () => fixture.audioLanguage), + plexServiceFactory: () async => _recordingAuth( + requests, + audioLanguage: () => fixture.audioLanguage, + responseGate: () => fixture.responseGate, + onRequest: () { + final started = fixture.requestStarted; + if (started != null && !started.isCompleted) started.complete(); + }, + ), )..attach( - connections: connections ?? stack.connections, - profileConnections: profileConnections ?? stack.profileConnections, + connections: stack.connections, + profileConnections: stack.profileConnections, activeProfile: stack.active, + serverManager: serverManager, ); - fixture = _Fixture._(stack, controller, requests)..audioLanguage = audioLanguage; + fixture = _Fixture._(stack, controller, requests, serverManager)..audioLanguage = audioLanguage; return fixture; } Future dispose() async { controller.dispose(); + serverManager.dispose(); await stack.dispose(); } } -class _CountingConnectionRegistry extends ConnectionRegistry { - _CountingConnectionRegistry(super.db); - - int listCalls = 0; - - @override - Future> list() { - listCalls++; - return super.list(); - } -} - -class _CountingProfileConnectionRegistry extends ProfileConnectionRegistry { - _CountingProfileConnectionRegistry(super.db); - - int listCalls = 0; - - @override - Future> listForProfile(String profileId) { - listCalls++; - return super.listForProfile(profileId); - } -} - PlexHomeUser _homeUser({required String uuid, required String title}) { return PlexHomeUser( id: 1, @@ -333,12 +458,20 @@ PlexHomeUser _homeUser({required String uuid, required String title}) { ); } -PlexAuthService _recordingAuth(List requests, {required String Function() audioLanguage}) { +PlexAuthService _recordingAuth( + List requests, { + required String Function() audioLanguage, + required Future? Function() responseGate, + required void Function() onRequest, +}) { return PlexAuthService.forTesting( http: MediaServerHttpClient( client: MockClient((request) async { requests.add(request); final language = audioLanguage(); + final gate = responseGate(); + onRequest(); + await gate; return http.Response( jsonEncode({ 'profile': { diff --git a/test/screens/settings/account_preferences_screen_test.dart b/test/screens/settings/account_preferences_screen_test.dart index 90c2bc334..5200191dd 100644 --- a/test/screens/settings/account_preferences_screen_test.dart +++ b/test/screens/settings/account_preferences_screen_test.dart @@ -30,7 +30,7 @@ void main() { ref: jellyfinRef, label: 'Basement', subtitle: 'agent · 192.168.1.3', - isActiveProfileAccount: true, + isDefaultConnection: true, ); const plexTarget = AccountPreferenceTarget(ref: plexRef, label: 'Plex', subtitle: 'Kids'); @@ -50,6 +50,7 @@ void main() { WidgetTester tester, { required AccountPreferencesRepository repository, required List targets, + bool settle = true, }) async { // Tall enough that every group is laid out: SliverList only builds the // rows in the viewport, so an absent row must mean "gated out", never @@ -70,7 +71,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + if (settle) await tester.pumpAndSettle(); } bool switchValueFor(WidgetTester tester, String title) => @@ -99,7 +100,7 @@ void main() { expect(find.text('Preferred audio language'), findsOneWidget); }); - testWidgets('the account the profile browses with is offered first', (tester) async { + testWidgets('the picker preserves the resolved account order', (tester) async { final repository = repositoryOf({ jellyfinRef: _FakeAccountPreferencesSource(capabilities: AccountPreferencesCapabilities.jellyfin), plexRef: _FakeAccountPreferencesSource(capabilities: AccountPreferencesCapabilities.plex), @@ -107,7 +108,7 @@ void main() { await pumpSection(tester, repository: repository, targets: const [plexTarget, jellyfinTarget]); - expect(tester.getTopLeft(find.text('Basement')).dy, lessThan(tester.getTopLeft(find.text('Plex')).dy)); + expect(tester.getTopLeft(find.text('Plex')).dy, lessThan(tester.getTopLeft(find.text('Basement')).dy)); }); testWidgets('a single account skips the picker and edits in place', (tester) async { @@ -249,6 +250,65 @@ void main() { expect(find.text('Could not save changes. Try again.'), findsNothing); }); + testWidgets('switching the inline account during a write keeps the new account on screen', (tester) async { + final gate = Completer(); + final original = _FakeAccountPreferencesSource( + capabilities: AccountPreferencesCapabilities.jellyfin, + values: const {AccountPreferenceKey.hidePlayedInLatest: true}, + writeGate: gate, + ); + final sources = { + jellyfinRef: original, + plexRef: _FakeAccountPreferencesSource( + capabilities: AccountPreferencesCapabilities.plex, + values: const {AccountPreferenceKey.preferredAudioLanguage: 'fra'}, + ), + }; + final repository = repositoryOf(sources); + await pumpSection(tester, repository: repository, targets: const [jellyfinTarget]); + await tester.tap(find.text('Hide watched items in Latest')); + await tester.pump(); + sources.remove(jellyfinRef); + repository.clear(); + await pumpSection(tester, repository: repository, targets: const [plexTarget]); + expect(find.text('French'), findsOneWidget); + expect(find.text('Hide watched items in Latest'), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('French'), findsOneWidget); + expect(repository.cached(jellyfinRef), isNull); + expect(repository.cached(plexRef)?.preferredAudioLanguage, 'fra'); + }); + + testWidgets('same-account credential replacement reloads a pending preferences body', (tester) async { + final gate = Completer(); + final sources = { + plexRef: _FakeAccountPreferencesSource( + capabilities: AccountPreferencesCapabilities.plex, + values: const {AccountPreferenceKey.preferredAudioLanguage: 'eng'}, + readGate: gate, + ), + }; + final repository = repositoryOf(sources); + await pumpSection(tester, repository: repository, targets: const [plexTarget], settle: false); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + sources[plexRef] = _FakeAccountPreferencesSource( + capabilities: AccountPreferencesCapabilities.plex, + values: const {AccountPreferenceKey.preferredAudioLanguage: 'fra'}, + ); + repository.invalidate(plexRef); + await tester.pumpAndSettle(); + expect(find.text('French'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('French'), findsOneWidget); + expect(find.text('English'), findsNothing); + }); + testWidgets('an unreachable account offers a retry instead of rows', (tester) async { // No source for the ref: the repository reports the account unreachable. final repository = repositoryOf(const {}); @@ -269,6 +329,7 @@ class _FakeAccountPreferencesSource implements AccountPreferencesSource { required this.capabilities, Map values = const {}, this.writeGate, + this.readGate, this.rejectWrites = false, }) : _values = {...values}; @@ -277,6 +338,7 @@ class _FakeAccountPreferencesSource implements AccountPreferencesSource { /// Held open to observe the row while a write is in flight. final Completer? writeGate; + final Completer? readGate; /// Writes fail instead of applying, for the revert path. final bool rejectWrites; @@ -285,7 +347,10 @@ class _FakeAccountPreferencesSource implements AccountPreferencesSource { final List writes = []; @override - Future read() async => _snapshot(); + Future read() async { + await readGate?.future; + return _snapshot(); + } @override Future write(AccountPreferencesPatch patch) async { diff --git a/test/services/account_preferences_accounts_test.dart b/test/services/account_preferences_accounts_test.dart new file mode 100644 index 000000000..f0aaacf13 --- /dev/null +++ b/test/services/account_preferences_accounts_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/media/account_ref.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/profiles/profile_connection.dart'; +import 'package:plezy/services/account_preferences_accounts.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; + +void main() { + final home = PlexHomeProfile( + id: 'home', + displayName: 'Home user', + parentConnectionId: 'parent', + plexHomeUserUuid: 'home-user', + createdAt: DateTime(2026), + ); + final parent = PlexAccountConnection( + id: 'parent', + accountToken: 'owner-token', + clientIdentifier: 'client', + accountLabel: 'Plex parent', + createdAt: DateTime(2026), + ); + final borrowed = testJellyfinConnection(); + final borrowedRow = ProfileConnection( + profileId: home.id, + connectionId: borrowed.id, + userIdentifier: borrowed.userId, + isDefault: true, + ); + final parentRow = ProfileConnection( + profileId: home.id, + connectionId: parent.id, + userIdentifier: 'home-user', + userToken: 'switched-token', + ); + + test('a persisted borrowed default remains editable but Home authority is its exact parent', () { + final resolution = resolveAccountPreferenceAccounts( + profile: home, + profileConnections: [borrowedRow, parentRow], + connections: [borrowed, parent], + ); + expect(resolution.accounts.first.ref.connectionId, borrowed.id); + expect(resolution.playbackRef, const AccountRef.plex(accountConnectionId: 'parent', homeUserUuid: 'home-user')); + expect(resolution.accounts.last.plexToken, 'switched-token'); + }); + + test('missing Home parent, row, principal or token never selects the borrowed default or owner', () { + for (final (profile, rows, connections) in <(Profile, List, List)>[ + (home, [borrowedRow, parentRow], [borrowed]), + (home, [borrowedRow], [borrowed, parent]), + (home, [borrowedRow, parentRow.copyWith(userToken: null)], [borrowed, parent]), + (home, [borrowedRow, parentRow.copyWith(userToken: '')], [borrowed, parent]), + (home, [borrowedRow, parentRow.copyWith(userIdentifier: 'someone-else')], [borrowed, parent]), + (home.copyWith(plexHomeUserUuid: null), [borrowedRow, parentRow], [borrowed, parent]), + (home.copyWith(parentConnectionId: null), [borrowedRow, parentRow], [borrowed, parent]), + ]) { + final resolution = resolveAccountPreferenceAccounts( + profile: profile, + profileConnections: rows, + connections: connections, + ); + expect(resolution.playbackRef, isNull); + expect(resolution.accounts.map((account) => account.ref.connectionId), [borrowed.id]); + } + }); + + test('local switched principals come from each row rather than account activeProfile metadata', () { + final local = Profile.local(id: 'local', displayName: 'Local', createdAt: DateTime(2026)); + final account = parent.copyWith( + activeProfile: PlexHomeUser( + id: 2, + uuid: 'account-wide-user', + title: 'Other user', + thumb: '', + hasPassword: false, + restricted: false, + updatedAt: null, + admin: false, + guest: false, + protected: false, + ), + ); + final resolution = resolveAccountPreferenceAccounts( + profile: local, + profileConnections: [parentRow.copyWith(profileId: local.id, isDefault: true)], + connections: [account], + ); + expect(resolution.playbackRef, const AccountRef.plex(accountConnectionId: 'parent', homeUserUuid: 'home-user')); + expect(resolution.accounts.single.plexToken, 'switched-token'); + }); + + test('a missing local designated account does not promote another editable account', () { + final local = Profile.local(id: 'local', displayName: 'Local', createdAt: DateTime(2026)); + final resolution = resolveAccountPreferenceAccounts( + profile: local, + profileConnections: [ + parentRow.copyWith(profileId: local.id, isDefault: true), + borrowedRow.copyWith(profileId: local.id, isDefault: false), + ], + connections: [borrowed], + ); + expect(resolution.playbackRef, isNull); + expect(resolution.accounts.single.ref.connectionId, borrowed.id); + }); + + test('two MediaBrowser users on one server retain separate designated identities', () { + final local = Profile.local(id: 'local', displayName: 'Local', createdAt: DateTime(2026)); + final other = testJellyfinConnection(userId: 'other-user'); + final resolution = resolveAccountPreferenceAccounts( + profile: local, + profileConnections: [ + borrowedRow.copyWith(profileId: local.id, isDefault: false), + ProfileConnection(profileId: local.id, connectionId: other.id, userIdentifier: other.userId, isDefault: true), + ], + connections: [borrowed, other], + ); + expect(resolution.playbackRef?.connectionId, other.id); + expect(resolution.accounts.map((account) => account.ref.connectionId).toSet(), {borrowed.id, other.id}); + }); +} diff --git a/test/services/account_preferences_repository_test.dart b/test/services/account_preferences_repository_test.dart new file mode 100644 index 000000000..aec8f3f63 --- /dev/null +++ b/test/services/account_preferences_repository_test.dart @@ -0,0 +1,158 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/account_preferences.dart'; +import 'package:plezy/media/account_preferences_source.dart'; +import 'package:plezy/media/account_ref.dart'; +import 'package:plezy/services/account_preferences_repository.dart'; + +void main() { + const ref = AccountRef.plex(accountConnectionId: 'parent', homeUserUuid: 'home'); + const oldPreferences = AccountPreferences(preferredAudioLanguage: 'jpn'); + const currentPreferences = AccountPreferences(preferredAudioLanguage: 'fra'); + + test('clear detaches a pending read and its completion cannot replace the new scope', () async { + final old = _PendingSource(); + final current = _PendingSource(); + AccountPreferencesSource source = old; + final repository = AccountPreferencesRepository(sourceFor: (_) async => source); + addTearDown(repository.dispose); + final changes = []; + repository.changes.listen(changes.add); + + final oldLoad = repository.load(ref); + await old.readStarted.future; + repository.clear(); + source = current; + final currentLoad = repository.load(ref); + await current.readStarted.future; + current.readResult.complete(currentPreferences); + await currentLoad; + await pumpEventQueue(); + changes.clear(); + + old.readResult.complete(oldPreferences); + expect((await oldLoad).defaultAudioLanguage, 'jpn'); + await pumpEventQueue(); + expect(repository.cached(ref)?.defaultAudioLanguage, 'fra'); + expect(changes, isEmpty); + }); + + test('invalidate fences source acquisition and does not deduplicate the replacement token read', () async { + final sourceGate = Completer(); + final old = _PendingSource(); + final current = _PendingSource(); + var first = true; + final repository = AccountPreferencesRepository( + sourceFor: (_) { + if (first) { + first = false; + return sourceGate.future; + } + return Future.value(current); + }, + ); + addTearDown(repository.dispose); + + final oldLoad = repository.load(ref); + repository.invalidate(ref); + final currentLoad = repository.load(ref); + await current.readStarted.future; + current.readResult.complete(currentPreferences); + await currentLoad; + sourceGate.complete(old); + await old.readStarted.future; + old.readResult.complete(oldPreferences); + await oldLoad; + + expect(repository.cached(ref)?.defaultAudioLanguage, 'fra'); + expect(repository.isAvailable(ref), isTrue); + }); + + for (final clearAll in [false, true]) { + test( + '${clearAll ? 'clear' : 'invalidate'} lets a started write finish only against its original account', + () async { + final old = _PendingSource(); + final current = _PendingSource(); + AccountPreferencesSource source = old; + final repository = AccountPreferencesRepository(sourceFor: (_) async => source); + addTearDown(repository.dispose); + + final write = repository.update( + ref, + AccountPreferencesPatch.of(AccountPreferenceKey.preferredAudioLanguage, 'jpn'), + ); + await old.writeStarted.future; + if (clearAll) { + repository.clear(); + } else { + repository.invalidate(ref); + } + source = current; + final currentLoad = repository.load(ref); + await current.readStarted.future; + current.readResult.complete(currentPreferences); + await currentLoad; + old.writeResult.complete(oldPreferences); + expect((await write).defaultAudioLanguage, 'jpn'); + + expect(old.written?.languageAt(AccountPreferenceKey.preferredAudioLanguage), 'jpn'); + expect(current.written, isNull); + expect(repository.cached(ref)?.defaultAudioLanguage, 'fra'); + }, + ); + } + + test('a revoked source lookup cannot mark a successfully reloaded account unavailable', () async { + final sourceGate = Completer(); + final current = _PendingSource(); + var first = true; + final repository = AccountPreferencesRepository( + sourceFor: (_) { + if (first) { + first = false; + return sourceGate.future; + } + return Future.value(current); + }, + ); + addTearDown(repository.dispose); + final oldLoad = repository.load(ref); + final rejected = expectLater(oldLoad, throwsA(isA())); + repository.clear(); + final currentLoad = repository.load(ref); + await current.readStarted.future; + current.readResult.complete(currentPreferences); + await currentLoad; + sourceGate.complete(null); + await rejected; + + expect(repository.cached(ref)?.defaultAudioLanguage, 'fra'); + expect(repository.isAvailable(ref), isTrue); + }); +} + +class _PendingSource extends AccountPreferencesSource { + final readStarted = Completer(); + final writeStarted = Completer(); + final readResult = Completer(); + final writeResult = Completer(); + AccountPreferencesPatch? written; + + @override + AccountPreferencesCapabilities get capabilities => AccountPreferencesCapabilities.plex; + + @override + Future read() { + readStarted.complete(); + return readResult.future; + } + + @override + Future write(AccountPreferencesPatch patch) { + written = patch; + writeStarted.complete(); + return writeResult.future; + } +}