From cf0dd0607e0a3a7acceec25e28a9f9d89e9b669c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:21:11 +0200 Subject: [PATCH] fix(profiles): keep playback preferences bound to their authenticated account Picker ordering could select another account for playback preferences, and delayed operations could publish data after the active account changed. Resolve playback authority independently of the account picker and fence repository, controller, and inline settings operations to their original authenticated account. --- lib/media/account_preferences_target.dart | 8 +- lib/profiles/active_plex_token.dart | 10 +- .../account_preferences_controller.dart | 152 ++++++--- .../account_preferences_detail_screen.dart | 64 +++- .../settings/account_preferences_screen.dart | 11 +- .../account_preferences_accounts.dart | 81 +++-- .../account_preferences_repository.dart | 40 ++- .../account_preferences_controller_test.dart | 297 +++++++++++++----- .../account_preferences_screen_test.dart | 75 ++++- .../account_preferences_accounts_test.dart | 124 ++++++++ .../account_preferences_repository_test.dart | 158 ++++++++++ 11 files changed, 819 insertions(+), 201 deletions(-) create mode 100644 test/services/account_preferences_accounts_test.dart create mode 100644 test/services/account_preferences_repository_test.dart 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; + } +}