fix(plex): wait for shared server resource tokens
This commit is contained in:
@@ -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<PlexServer> _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<void> 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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<PlexServer>? 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<void>();
|
||||
final fetchGate = Completer<void>();
|
||||
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<void>();
|
||||
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<void>.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<String, dynamic> _serverJson({String clientIdentifier = 'srv-1'}) => {
|
||||
Map<String, dynamic> _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<String, dynamic> _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);
|
||||
|
||||
Reference in New Issue
Block a user