diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index 5ba018d0..fbaef91d 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -641,19 +641,22 @@ class ActiveProfileBinder { final fetchOutcome = _settleServerFetch(_fetchServersTimed(auth, token, profileLabel)); _ProfileBindResult? optimistic; if (usingCachedToken) { - // Probe cached metadata while plex.tv refreshes resources. A live - // cached bind settles immediately and reconciles the fetch later. - optimistic = await _bindOptimisticallyFromCache( - account: account, - userToken: token, - profileId: profileId, - profileLabel: profileLabel, - generation: generation, - fetchOutcome: fetchOutcome, - onAuthRejected: invalidateCachedToken, - ); + // Probe only cache entries whose PMS token is already the active + // profile token. A partial pass stays on the splash and awaits the + // per-server tokens from plex.tv instead of reporting shared servers + // offline with a token that cannot authenticate to them. + optimistic = await _bindOptimisticallyFromCache(account: account, userToken: token, profileLabel: profileLabel); if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty(); - if (optimistic != null && optimistic.visibleServerIds.isNotEmpty) { + final cachedServerIds = account.servers.map((server) => server.clientIdentifier).toSet(); + if (optimistic != null && setEquals(optimistic.visibleServerIds, cachedServerIds)) { + _reconcileWhenFetchLands( + fetchOutcome: fetchOutcome, + account: account, + profileId: profileId, + profileLabel: profileLabel, + generation: generation, + onAuthRejected: invalidateCachedToken, + ); await markUsed?.call(); return optimistic; } @@ -712,8 +715,13 @@ class ActiveProfileBinder { error: fetched.error, stackTrace: fetched.stackTrace, ); - // The optimistic pass already probed these endpoints. - if (optimistic != null) return optimistic; + // The optimistic pass already probed every cache entry that was + // safe for this profile. Preserve any compatible servers that + // connected when plex.tv itself is temporarily unavailable. + if (optimistic != null) { + if (optimistic.visibleServerIds.isNotEmpty) await markUsed?.call(); + return optimistic; + } final result = await _connectFromCachedServers( account, token, @@ -738,6 +746,28 @@ class ActiveProfileBinder { } } + /// Account-level server metadata can outlive the Plex Home profile that + /// fetched it, but the PMS access tokens returned by `/resources` are + /// user-scoped. Reuse a cached server only when its persisted PMS token is + /// exactly the active profile token. Replacing a distinct PMS token with + /// [userToken] produces HTTP 401 on shared servers; reusing that distinct + /// token could instead expose the prior profile's access. + List _cachedServersCompatibleWithUserToken( + PlexAccountConnection account, + String userToken, + String profileLabel, + ) { + final compatible = account.servers.where((server) => server.accessToken == userToken).toList(growable: false); + final deferred = account.servers.length - compatible.length; + if (deferred > 0) { + appLogger.i( + 'ActiveProfileBinder: deferring $deferred cached Plex server${deferred == 1 ? '' : 's'} ' + 'for $profileLabel until resource tokens refresh', + ); + } + return compatible; + } + Future<_ProfileBindResult> _connectFromCachedServers( PlexAccountConnection account, String userToken, @@ -751,7 +781,8 @@ class ActiveProfileBinder { error: error, stackTrace: stackTrace, ); - final servers = account.servers.map((server) => server.withAccessToken(userToken)).toList(growable: false); + final servers = _cachedServersCompatibleWithUserToken(account, userToken, profileLabel); + if (servers.isEmpty) return const _ProfileBindResult.empty(); return _connectFromServers(account, userToken, servers, profileLabel); } @@ -847,43 +878,27 @@ class ActiveProfileBinder { } } - /// Cold-start fast path for cached-token binds: connect from the cached - /// server metadata immediately while the plex.tv resource refresh - /// ([fetchOutcome]) runs alongside, then reconcile in the background once - /// it lands. Returns `null` when there is no cached metadata to connect - /// from, and a 0-bound result when every cached endpoint was unreachable — - /// callers fall back to awaiting the fetch in both cases. + /// Cold-start fast path for cached-token binds: connect from cached server + /// metadata while the plex.tv resource refresh runs alongside. Only servers + /// whose cached PMS token equals the active profile token are safe to probe; + /// shared-server resource tokens normally differ and must await the refresh. /// - /// Trade-off: when the cached metadata is entirely stale (every URI - /// changed since last launch), the failed optimistic pass delays the - /// fresh connect by up to the race budget. The reconcile persists fresh - /// metadata so the next launch recovers. + /// The caller settles optimistically only when every cached server binds. + /// A partial pass remains on the splash until fresh per-server tokens arrive. Future<_ProfileBindResult?> _bindOptimisticallyFromCache({ required PlexAccountConnection account, required String userToken, - required String profileId, required String profileLabel, - required int generation, - required Future<_FetchOutcome> fetchOutcome, - required Future Function() onAuthRejected, }) async { if (account.servers.isEmpty) return null; + final cachedServers = _cachedServersCompatibleWithUserToken(account, userToken, profileLabel); + if (cachedServers.isEmpty) return const _ProfileBindResult.empty(); appLogger.i( - 'ActiveProfileBinder: connecting $profileLabel from cached server metadata while resources refresh', - error: {'servers': account.servers.length}, + 'ActiveProfileBinder: connecting $profileLabel from compatible cached server metadata ' + 'while resources refresh', + error: {'servers': cachedServers.length, 'totalServers': account.servers.length}, ); - final cachedServers = account.servers.map((server) => server.withAccessToken(userToken)).toList(growable: false); - final result = await _connectFromServers(account, userToken, cachedServers, profileLabel); - if (result.visibleServerIds.isEmpty) return result; - _reconcileWhenFetchLands( - fetchOutcome: fetchOutcome, - account: account, - profileId: profileId, - profileLabel: profileLabel, - generation: generation, - onAuthRejected: onAuthRejected, - ); - return result; + return _connectFromServers(account, userToken, cachedServers, profileLabel); } /// Apply the background resource refresh after an optimistic cached bind: diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 54e8643a..a6a46fa1 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -410,20 +410,6 @@ class PlexServer { }; } - PlexServer withAccessToken(String token) { - return PlexServer( - name: name, - clientIdentifier: clientIdentifier, - accessToken: token, - connections: connections, - owned: owned, - product: product, - platform: platform, - lastSeenAt: lastSeenAt, - presence: presence, - ); - } - /// Check if server is online using the presence field bool get isOnline => presence; diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index 87adf207..fb9ff523 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -234,9 +234,9 @@ void main() { await binder.rebindActive(); expect(activeProfile.lastBindingSucceeded, isFalse); - // Two connect passes: the optimistic cached-metadata pass binds nothing, - // so the bind falls back to the freshly fetched resource list. - expect(failingManager.refreshCalls, 2); + // The account-scoped cached PMS token differs from this profile token, + // so only the freshly fetched resource list reaches the manager. + expect(failingManager.refreshCalls, 1); expect(multiServerProvider.serverIds, isEmpty); expect(multiServerProvider.expectedServerIds, ['srv-1']); }); @@ -342,6 +342,7 @@ void main() { Future<({String profileId, _CapturingMultiServerManager manager})> preparePlexHomeBind({ required bool protected, required http.Client httpClient, + List? cachedServers, }) async { binder.dispose(); multiServerProvider.dispose(); @@ -365,7 +366,7 @@ void main() { accountToken: 'account-token', clientIdentifier: 'client-id', accountLabel: 'Owner', - servers: [_server(accessToken: 'account-server-token')], + servers: cachedServers ?? [_server(accessToken: 'home-user-token')], createdAt: DateTime(2026, 1, 1), ); await connections.upsert(account); @@ -475,6 +476,55 @@ void main() { expect(account?.servers.single.accessToken, 'server-token'); }); + test('defers distinct PMS resource tokens until plex.tv refreshes them', () async { + final fetchStarted = Completer(); + final fetchGate = Completer(); + final prepared = await preparePlexHomeBind( + protected: false, + cachedServers: [ + _server(clientIdentifier: 'srv-direct', accessToken: 'home-user-token'), + _server(clientIdentifier: 'srv-shared', accessToken: 'cached-shared-pms-token', owned: false, local: false), + ], + httpClient: MockClient((request) async { + if (!fetchStarted.isCompleted) fetchStarted.complete(); + await fetchGate.future; + return http.Response( + jsonEncode([ + _serverJson(clientIdentifier: 'srv-direct', accessToken: 'fresh-direct-pms-token'), + _serverJson( + clientIdentifier: 'srv-shared', + accessToken: 'fresh-shared-pms-token', + owned: false, + local: false, + ), + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + + final bind = binder.rebindActive(); + await fetchStarted.future.timeout(const Duration(seconds: 2)); + await pumpUntil(() async => prepared.manager.refreshCalls == 1); + + // The account-scoped cached token for the shared PMS may belong to a + // different Plex Home profile. Do not replace it with the active + // plex.tv token and probe the PMS: shared servers reject that as 401. + expect(prepared.manager.lastConnection?.servers.map((server) => server.clientIdentifier), ['srv-direct']); + expect(activeProfile.isBinding, isTrue); + + fetchGate.complete(); + await bind.timeout(const Duration(seconds: 2)); + + expect(prepared.manager.refreshCalls, 2); + final refreshedServers = { + for (final server in prepared.manager.lastConnection!.servers) server.clientIdentifier: server.accessToken, + }; + expect(refreshedServers, {'srv-direct': 'fresh-direct-pms-token', 'srv-shared': 'fresh-shared-pms-token'}); + expect(activeProfile.lastBindingSucceeded, isTrue); + }); + test('membership change in the background refresh triggers a full rebind', () async { final fetchGate = Completer(); final prepared = await preparePlexHomeBind( @@ -504,11 +554,12 @@ void main() { expect(account?.servers.single.clientIdentifier, 'srv-2'); expect(binder.debugLastBoundProfileId, prepared.profileId); - // The rebind's own reconcile sees identical membership and converges - // with one final in-place token pass — no rebind loop. - await pumpUntil(() async => prepared.manager.refreshCalls >= 3); + // The rebind sees that the account-level PMS token differs from the + // active profile token, so it waits for `/resources` and binds once + // with the refreshed token instead of doing another optimistic pass. + await pumpUntil(() async => prepared.manager.refreshCalls >= 2); await Future.delayed(const Duration(milliseconds: 50)); - expect(prepared.manager.refreshCalls, 3); + expect(prepared.manager.refreshCalls, 2); }); }); @@ -535,7 +586,7 @@ void main() { fail('Unexpected request: ${request.method} ${request.url}'); }); - final recoveringManager = _FailThenSucceedPlexManager(); + final recoveringManager = _RecordingPlexManager(); manager = recoveringManager; multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); binder = ActiveProfileBinder( @@ -574,7 +625,7 @@ void main() { expect(resourceCalls, 2); expect(switchCalls, 1); expect(row?.userToken, 'fresh-user-token'); - expect(recoveringManager.calls, 2); + expect(recoveringManager.calls, 1); expect(activeProfile.lastBindingSucceeded, isTrue); expect(multiServerProvider.onlineServerIds, ['srv-1']); }); @@ -939,10 +990,15 @@ void main() { }); } -PlexServer _server({required String accessToken}) { +PlexServer _server({ + required String accessToken, + String clientIdentifier = 'srv-1', + bool owned = true, + bool local = true, +}) { return PlexServer( name: 'Home Server', - clientIdentifier: 'srv-1', + clientIdentifier: clientIdentifier, accessToken: accessToken, connections: [ PlexConnection( @@ -950,21 +1006,26 @@ PlexServer _server({required String accessToken}) { address: '192.168.1.3', port: 32400, uri: 'https://192-168-1-3.machine.plex.direct:32400', - local: true, + local: local, relay: false, ipv6: false, ), ], - owned: true, + owned: owned, presence: true, ); } -Map _serverJson({String clientIdentifier = 'srv-1'}) => { +Map _serverJson({ + String clientIdentifier = 'srv-1', + String accessToken = 'server-token', + bool owned = true, + bool local = true, +}) => { 'name': 'Home Server', 'clientIdentifier': clientIdentifier, - 'accessToken': 'server-token', - 'owned': true, + 'accessToken': accessToken, + 'owned': owned, 'provides': 'server', 'connections': [ { @@ -972,7 +1033,7 @@ Map _serverJson({String clientIdentifier = 'srv-1'}) => { 'address': '192.168.1.3', 'port': 32400, 'uri': 'https://192-168-1-3.machine.plex.direct:32400', - 'local': true, + 'local': local, 'relay': false, 'IPv6': false, }, @@ -1062,7 +1123,7 @@ class _FailingPlexMultiServerManager extends MultiServerManager { } } -class _FailThenSucceedPlexManager extends MultiServerManager { +class _RecordingPlexManager extends MultiServerManager { int calls = 0; @override @@ -1071,7 +1132,6 @@ class _FailThenSucceedPlexManager extends MultiServerManager { Duration timeout = MediaServerTimeouts.perServerConnect, }) async { calls++; - if (calls == 1) return const {}; final ids = connection.servers.map((server) => server.clientIdentifier).toSet(); for (final id in ids) { updateServerStatus(ServerId(id), true);