From ede80d225f5793ffedf7a7d33597ebff3ac9ff5f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:05:35 +0200 Subject: [PATCH] refactor: shared coalescer, profile-scoped prefs key, and form-error helper --- lib/profiles/profile.dart | 17 ++++++ lib/screens/settings/add_jellyfin_screen.dart | 14 +---- .../settings/add_plex_account_screen.dart | 9 +--- .../settings/async_form_state_mixin.dart | 15 +++++- .../edit_jellyfin_connection_screen.dart | 5 +- lib/services/storage_service.dart | 4 +- lib/services/trackers/future_coalescer.dart | 26 +++++++++ .../trackers/tracker_account_store.dart | 3 +- .../tracker_account_store_scope_test.dart | 54 +++++++++++++++++++ 9 files changed, 119 insertions(+), 28 deletions(-) create mode 100644 test/services/trackers/tracker_account_store_scope_test.dart diff --git a/lib/profiles/profile.dart b/lib/profiles/profile.dart index 6138c0ca..350371c6 100644 --- a/lib/profiles/profile.dart +++ b/lib/profiles/profile.dart @@ -223,6 +223,23 @@ final RegExp _trailingHomeUserUuidPattern = RegExp( r'-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16})$', ); +/// Prefs scope for a profile id: Plex Home profiles scope by their bare +/// home-user uuid (`user_{uuid}_*`), other profiles by the full id. +/// +/// Every `user_{scope}_{key}` prefs-key builder must apply this — +/// [StorageService]'s `_migratePlexHomeUserScopes` relocates full-profile-id +/// keys onto the uuid scope at every launch, so a builder that skips the +/// normalization writes keys that vanish on the next restart (Trakt +/// "unlinking" on hot restart was exactly this). +String profileUserScope(String profileId) => parsePlexHomeProfileId(profileId)?.homeUserUuid ?? profileId; + +/// THE builder for profile-scoped prefs keys: `user_{scope}_{baseKey}`, or +/// the bare [baseKey] for the empty (signed-out/account-level) scope. +/// Pairs the `user_` prefix with [profileUserScope] in one place so no key +/// builder can skip the normalization (see the warning above). +String profileScopedPrefsKey(String userUuid, String baseKey) => + userUuid.isEmpty ? baseKey : 'user_${profileUserScope(userUuid)}_$baseKey'; + /// Inverse of [plexHomeProfileId]. Returns `null` if [id] doesn't match the /// `plex-home-{accountConnectionId}-{homeUserUuid}` shape. ({String accountConnectionId, String homeUserUuid})? parsePlexHomeProfileId(String id) { diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index ea627731..ad35ae4b 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -574,10 +574,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta ), ], ], - if (errorText != null) ...[ - const SizedBox(height: 12), - Text(errorText!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)), - ], + ...buildInlineError(theme), ]; } @@ -738,14 +735,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta label: Text(t.auth.quickConnectCancel), ), ), - if (errorText != null) ...[ - const SizedBox(height: 16), - Text( - errorText!, - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), - ), - ], + ...buildInlineError(theme, gap: 16, center: true), ], ), ); diff --git a/lib/screens/settings/add_plex_account_screen.dart b/lib/screens/settings/add_plex_account_screen.dart index 122e7f5a..0207fd97 100644 --- a/lib/screens/settings/add_plex_account_screen.dart +++ b/lib/screens/settings/add_plex_account_screen.dart @@ -175,14 +175,7 @@ class _AddPlexAccountScreenState extends State with AsyncF ], ), ), - if (errorText != null) ...[ - const SizedBox(height: 16), - Text( - errorText!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), - textAlign: TextAlign.center, - ), - ], + ...buildInlineError(theme, gap: 16, center: true), ], ), ), diff --git a/lib/screens/settings/async_form_state_mixin.dart b/lib/screens/settings/async_form_state_mixin.dart index aa3ceb5e..ef813c4f 100644 --- a/lib/screens/settings/async_form_state_mixin.dart +++ b/lib/screens/settings/async_form_state_mixin.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import '../../utils/app_logger.dart'; @@ -34,6 +34,19 @@ mixin AsyncFormStateMixin on State { setState(() => _busy = value); } + /// Inline error text under a form. Spread into a children list: + /// `...buildInlineError(theme)`. + List buildInlineError(ThemeData theme, {double gap = 12, bool center = false}) => [ + if (errorText != null) ...[ + SizedBox(height: gap), + Text( + errorText!, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), + textAlign: center ? TextAlign.center : null, + ), + ], + ]; + /// Set the error text directly (e.g. for synchronous validation failures /// or post-success rejections like a duplicate-account guard). void setErrorText(String? value) { diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index e1615e1e..7cb961fb 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -124,10 +124,7 @@ class _EditJellyfinConnectionScreenState extends State _activeUserScope(); - String userScopeForProfileId(String profileId) => parsePlexHomeProfileId(profileId)?.homeUserUuid ?? profileId; + String userScopeForProfileId(String profileId) => profileUserScope(profileId); String? _activeUserScope() { final id = getActiveProfileId(); if (id == null) return null; - return parsePlexHomeProfileId(id)?.homeUserUuid ?? id; + return profileUserScope(id); } /// Returns `'user_{scope}_'` for the active profile, or `''` if no diff --git a/lib/services/trackers/future_coalescer.dart b/lib/services/trackers/future_coalescer.dart index 041aa6ab..e2fbf83a 100644 --- a/lib/services/trackers/future_coalescer.dart +++ b/lib/services/trackers/future_coalescer.dart @@ -12,4 +12,30 @@ class FutureCoalescer { _inFlight = future; return future; } + + /// Detach the in-flight future (it keeps running, but the next [run] + /// starts fresh instead of joining it). The identical-guard above keeps + /// the detached future's completion from clearing a newer slot. + void reset() { + _inFlight = null; + } +} + +/// Keyed [FutureCoalescer]: one in-flight future per key. Used for the +/// static per-identity re-auth/refresh maps (Trakt refresh-by-token, Seerr +/// re-auth-by-instance) so concurrent 401s trigger one login each. +class KeyedFutureCoalescer { + final Map> _inFlight = {}; + + Future run(K key, Future Function() create) { + final existing = _inFlight[key]; + if (existing != null) return existing; + + late final Future future; + future = create().whenComplete(() { + if (identical(_inFlight[key], future)) _inFlight.remove(key); + }); + _inFlight[key] = future; + return future; + } } diff --git a/lib/services/trackers/tracker_account_store.dart b/lib/services/trackers/tracker_account_store.dart index 8131c07c..fc45fd02 100644 --- a/lib/services/trackers/tracker_account_store.dart +++ b/lib/services/trackers/tracker_account_store.dart @@ -1,3 +1,4 @@ +import '../../profiles/profile.dart'; import '../base_shared_preferences_service.dart'; import 'tracker_constants.dart'; import 'tracker_session.dart'; @@ -25,7 +26,7 @@ class TrackerAccountStore { TrackerAccountStore._(this.service, this._baseKey); - String _scopedKey(String userUuid) => userUuid.isEmpty ? _baseKey : 'user_${userUuid}_$_baseKey'; + String _scopedKey(String userUuid) => profileScopedPrefsKey(userUuid, _baseKey); Future load(String userUuid) async { final prefs = await BaseSharedPreferencesService.sharedCache(); diff --git a/test/services/trackers/tracker_account_store_scope_test.dart b/test/services/trackers/tracker_account_store_scope_test.dart new file mode 100644 index 00000000..280f8507 --- /dev/null +++ b/test/services/trackers/tracker_account_store_scope_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/trackers/tracker_account_store.dart'; +import 'package:plezy/services/trackers/tracker_constants.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; + +import '../../test_helpers/prefs.dart'; + +const _fullProfileId = 'plex-home-plex.e443d57860076fc3-e443d57860076fc3'; +const _homeUserUuid = 'e443d57860076fc3'; + +TrackerSession _session() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return TrackerSession(accessToken: 'a', refreshToken: 'r', expiresAt: now + 86400, createdAt: now); +} + +void main() { + setUp(resetSharedPreferencesForTest); + + group('profile user scoping', () { + test('profileUserScope reduces Plex Home ids to the bare home-user uuid', () { + expect(profileUserScope(_fullProfileId), _homeUserUuid); + expect(profileUserScope(_homeUserUuid), _homeUserUuid); + expect(profileUserScope('local-abc'), 'local-abc'); + }); + + test('profileScopedPrefsKey normalizes full profile ids to the uuid scope', () { + expect(profileScopedPrefsKey(_fullProfileId, 'trakt_session'), 'user_${_homeUserUuid}_trakt_session'); + expect(profileScopedPrefsKey(_homeUserUuid, 'trakt_session'), 'user_${_homeUserUuid}_trakt_session'); + expect(profileScopedPrefsKey('', 'trakt_session'), 'trakt_session'); + }); + + /// Regression: sessions saved under the full profile id were relocated to + /// the uuid scope by StorageService's launch-time repair, so the next + /// hydrate (same full id) missed them — Trakt "unlinked" on every + /// restart. Save and load must agree on the uuid scope regardless of + /// which id form the caller passes. + test('store writes the uuid-scoped key and loads it from either id form', () async { + final store = trackerAccountStore(TrackerService.trakt); + await store.save(_fullProfileId, _session()); + + final prefs = await BaseSharedPreferencesService.sharedCache(); + expect(prefs.getString('user_${_homeUserUuid}_trakt_session'), isNotNull); + expect(prefs.getString('user_${_fullProfileId}_trakt_session'), isNull); + + expect(await store.load(_fullProfileId), isNotNull); + expect(await store.load(_homeUserUuid), isNotNull); + + await store.clear(_fullProfileId); + expect(await store.load(_homeUserUuid), isNull); + }); + }); +}