refactor(profiles): centralize Plex token policy

This commit is contained in:
edde746
2026-07-12 08:42:24 +02:00
parent baf3c41a44
commit 2a128777f1
2 changed files with 435 additions and 229 deletions
+266 -229
View File
@@ -41,6 +41,15 @@ class _ProfileBindResult {
/// surfacing as an unhandled async error. /// surfacing as an unhandled async error.
typedef _FetchOutcome = ({List<PlexServer>? servers, Object? error, StackTrace? stackTrace}); typedef _FetchOutcome = ({List<PlexServer>? servers, Object? error, StackTrace? stackTrace});
enum _ServerFetchStatus { success, empty, authRejected, transientFailure, cancelled, failure }
typedef _ClassifiedFetch = ({
_ServerFetchStatus status,
List<PlexServer> servers,
Object? error,
StackTrace? stackTrace,
});
@visibleForTesting @visibleForTesting
bool shouldUsePlexHomeTokenCache({required bool preVerified, required bool hasBoundOnce}) { bool shouldUsePlexHomeTokenCache({required bool preVerified, required bool hasBoundOnce}) {
return preVerified || !hasBoundOnce; return preVerified || !hasBoundOnce;
@@ -88,6 +97,7 @@ class ActiveProfileBinder {
// only loops when the active id has drifted — this flag covers same-id // only loops when the active id has drifted — this flag covers same-id
// re-runs, e.g. after a borrow upserts a new join row. // re-runs, e.g. after a borrow upserts a new join row.
bool _pendingSameIdRebind = false; bool _pendingSameIdRebind = false;
int _bindGeneration = 0;
/// True after the binder has successfully bound at least one profile in /// True after the binder has successfully bound at least one profile in
/// this session. Once set, subsequent rebinds bypass the user-token /// this session. Once set, subsequent rebinds bypass the user-token
@@ -237,6 +247,7 @@ class ActiveProfileBinder {
Future<bool> _runRebindOnce() async { Future<bool> _runRebindOnce() async {
_bindingProfileId = activeProfile.activeId; _bindingProfileId = activeProfile.activeId;
final generation = ++_bindGeneration;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
var success = false; var success = false;
String? attemptedProfileId; String? attemptedProfileId;
@@ -270,6 +281,7 @@ class ActiveProfileBinder {
// registry read pays per-row CredentialVault reveals). // registry read pays per-row CredentialVault reveals).
final joinRows = await profileConnections.listForProfile(profile.id); final joinRows = await profileConnections.listForProfile(profile.id);
final connectionsById = {for (final c in await connections.list()) c.id: c}; final connectionsById = {for (final c in await connections.list()) c.id: c};
if (!_isCurrentBind(profile.id, generation)) return false;
// PIN prompts may only surface from a user-initiated bind or the // PIN prompts may only surface from a user-initiated bind or the
// session's initial bind (cold-start resume). Passive rebinds — an // session's initial bind (cold-start resume). Passive rebinds — an
@@ -290,14 +302,27 @@ class ActiveProfileBinder {
// on top of an otherwise reachable Jellyfin or borrowed-server bind. // on top of an otherwise reachable Jellyfin or borrowed-server bind.
final results = await Future.wait([ final results = await Future.wait([
if (profile.isPlexHome) if (profile.isPlexHome)
_bindPlexHome(profile, joinRows: joinRows, connectionsById: connectionsById, allowPinPrompt: allowPinPrompt), _bindPlexHome(
profile,
joinRows: joinRows,
connectionsById: connectionsById,
allowPinPrompt: allowPinPrompt,
generation: generation,
),
// Both kinds also bind borrowed/extra connections via the join table. // Both kinds also bind borrowed/extra connections via the join table.
// For plex_home this handles a Jellyfin server (or extra Plex account) // For plex_home this handles a Jellyfin server (or extra Plex account)
// that was attached to the profile via the borrow flow — the parent // that was attached to the profile via the borrow flow — the parent
// account is bound by `_bindPlexHome` above and isn't represented in // account is bound by `_bindPlexHome` above and isn't represented in
// the join table. // the join table.
_bindJoinRows(profile, joinRows: joinRows, connectionsById: connectionsById, allowPinPrompt: allowPinPrompt), _bindJoinRows(
profile,
joinRows: joinRows,
connectionsById: connectionsById,
allowPinPrompt: allowPinPrompt,
generation: generation,
),
]); ]);
if (!_isCurrentBind(profile.id, generation)) return false;
final visibleServerIds = <String>{}; final visibleServerIds = <String>{};
for (final result in results) { for (final result in results) {
visibleServerIds.addAll(result.visibleServerIds); visibleServerIds.addAll(result.visibleServerIds);
@@ -374,6 +399,7 @@ class ActiveProfileBinder {
required List<ProfileConnection> joinRows, required List<ProfileConnection> joinRows,
required Map<String, Connection> connectionsById, required Map<String, Connection> connectionsById,
required bool allowPinPrompt, required bool allowPinPrompt,
required int generation,
}) async { }) async {
final parentId = profile.parentConnectionId; final parentId = profile.parentConnectionId;
final homeUuid = profile.plexHomeUserUuid; final homeUuid = profile.plexHomeUserUuid;
@@ -390,6 +416,7 @@ class ActiveProfileBinder {
return const _ProfileBindResult.empty(); return const _ProfileBindResult.empty();
} }
final auth = await _ensureAuth(); final auth = await _ensureAuth();
if (!_isCurrentBind(profile.id, generation)) return const _ProfileBindResult.empty();
// Fast path: reuse the previously-minted user-token from the // Fast path: reuse the previously-minted user-token from the
// [ProfileConnection] row for this profile's parent connection. // [ProfileConnection] row for this profile's parent connection.
@@ -415,101 +442,54 @@ class ActiveProfileBinder {
'ActiveProfileBinder: cache lookup for ${profile.displayName} (account=${account.id}, ' 'ActiveProfileBinder: cache lookup for ${profile.displayName} (account=${account.id}, '
'uuid=$homeUuid, useCache=$useCache, preVerified=$preVerified): ${cachedToken == null ? (useCache ? "MISS" : "BYPASS") : "HIT"}', 'uuid=$homeUuid, useCache=$useCache, preVerified=$preVerified): ${cachedToken == null ? (useCache ? "MISS" : "BYPASS") : "HIT"}',
); );
if (cachedToken != null) { return _bindPlexWithTokenPolicy(
// Fire the resource refresh and the optimistic cached-metadata connect
// together: the plex.tv round-trip no longer gates server probing on
// cold start. The reconcile applies whatever the refresh learns
// (rotated tokens, changed URIs, membership) once it lands.
final fetchOutcome = _settleServerFetch(_fetchServersTimed(auth, cachedToken, profile.displayName));
final optimistic = await _bindOptimisticallyFromCache(
account: account,
userToken: cachedToken,
profileId: profile.id,
profileLabel: profile.displayName,
fetchOutcome: fetchOutcome,
onAuthRejected: () => profileConnections.recordToken(profile.id, parentId, ''),
);
if (optimistic != null && optimistic.visibleServerIds.isNotEmpty) {
return optimistic;
}
try {
final servers = await _unwrapServerFetch(fetchOutcome);
if (servers.isNotEmpty) {
appLogger.i('ActiveProfileBinder: using cached token for ${profile.displayName} (${servers.length} servers)');
unawaited(_persistRefreshedServers(account, servers));
return _connectFromServers(account, cachedToken, servers, profile.displayName);
}
appLogger.w(
'ActiveProfileBinder: cached token returned 0 servers for ${profile.displayName} — wiping and re-minting',
);
await profileConnections.recordToken(profile.id, parentId, '');
} on MediaServerHttpException catch (e) {
if (e.statusCode == 401 || e.statusCode == 403) {
appLogger.w(
'ActiveProfileBinder: cached token rejected (${e.statusCode}) for ${profile.displayName} — falling back to /switch',
);
await profileConnections.recordToken(profile.id, parentId, '');
} else {
appLogger.w(
'ActiveProfileBinder: fetchServers failed with cached token for ${profile.displayName}',
error: e,
);
if (e.isTransient) {
// The optimistic pass already probed the cached metadata —
// don't burn another race on the same endpoints.
if (optimistic != null) return optimistic;
return _connectFromCachedServers(account, cachedToken, profile.displayName, error: e);
}
return const _ProfileBindResult.empty();
}
} catch (e, st) {
appLogger.w(
'ActiveProfileBinder: fetchServers failed with cached token for ${profile.displayName}',
error: e,
stackTrace: st,
);
return const _ProfileBindResult.empty();
}
}
if (!allowPin && profile.plexProtected) {
appLogger.i('ActiveProfileBinder: suppressing PIN-gated /switch for passive rebind of ${profile.displayName}');
return const _ProfileBindResult.empty();
}
appLogger.i('ActiveProfileBinder: minting fresh user-token via /switch for ${profile.displayName}');
final result = await switchPlexHomeUserWithPin(
auth: auth, auth: auth,
accountToken: account.accountToken, account: account,
homeUserUuid: homeUuid, profileId: profile.id,
requiresPin: profile.plexProtected, profileLabel: profile.displayName,
// Plex can demand a PIN (error 1041) even when we didn't expect one; generation: generation,
// a passive rebind answers that demand with a cancel, not a dialog. cachedToken: cachedToken,
promptForPin: allowPin invalidateCachedToken: () => profileConnections.recordToken(profile.id, parentId, ''),
? ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage) mintToken: () async {
: ({String? errorMessage}) async => null, if (!allowPin && profile.plexProtected) {
logLabel: profile.displayName, appLogger.i(
'ActiveProfileBinder: suppressing PIN-gated /switch for passive rebind of ${profile.displayName}',
);
return null;
}
appLogger.i('ActiveProfileBinder: minting fresh user-token via /switch for ${profile.displayName}');
final result = await switchPlexHomeUserWithPin(
auth: auth,
accountToken: account.accountToken,
homeUserUuid: homeUuid,
requiresPin: profile.plexProtected,
// Plex can demand a PIN (error 1041) even when we didn't expect one;
// a passive rebind answers that demand with a cancel, not a dialog.
promptForPin: allowPin
? ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage)
: ({String? errorMessage}) async => null,
logLabel: profile.displayName,
);
return result.succeeded ? result.userToken : null;
},
persistMintedToken: (token) async {
// Plex Home parents normally have no join row, so create one as the
// stable home for the freshly minted profile token.
await profileConnections.upsert(
ProfileConnection(
profileId: profile.id,
connectionId: parentId,
userToken: token,
userIdentifier: homeUuid,
tokenAcquiredAt: DateTime.now(),
),
);
appLogger.i(
'ActiveProfileBinder: persisted user-token for ${profile.displayName} '
'(account=${account.id}, uuid=$homeUuid, tokenLen=${token.length})',
);
},
); );
if (!result.succeeded) return const _ProfileBindResult.empty();
// Persist the minted user-token onto the parent ProfileConnection
// row. Plex Home profiles don't normally have a join row for the
// parent (the borrow flow is for *other* connections layered onto
// the profile), so creating one here gives the token a stable home
// alongside the rest of the profile's tokens — same shape as the
// local-profile path that `_bindLocalPlexConnection` already uses.
await profileConnections.upsert(
ProfileConnection(
profileId: profile.id,
connectionId: parentId,
userToken: result.userToken,
userIdentifier: homeUuid,
tokenAcquiredAt: DateTime.now(),
),
);
appLogger.i(
'ActiveProfileBinder: persisted user-token for ${profile.displayName} '
'(account=${account.id}, uuid=$homeUuid, tokenLen=${result.userToken!.length})',
);
return _connectPlexServers(account, result.userToken!, profile.displayName);
} }
/// Bind every [ProfileConnection] row for [profile]. Used by both kinds: /// Bind every [ProfileConnection] row for [profile]. Used by both kinds:
@@ -524,6 +504,7 @@ class ActiveProfileBinder {
required List<ProfileConnection> joinRows, required List<ProfileConnection> joinRows,
required Map<String, Connection> connectionsById, required Map<String, Connection> connectionsById,
required bool allowPinPrompt, required bool allowPinPrompt,
required int generation,
}) async { }) async {
if (joinRows.isEmpty) { if (joinRows.isEmpty) {
if (profile.isLocal) { if (profile.isLocal) {
@@ -546,10 +527,18 @@ class ActiveProfileBinder {
switch (conn) { switch (conn) {
case PlexAccountConnection(): case PlexAccountConnection():
expected.addAll(conn.servers.map((server) => server.clientIdentifier)); expected.addAll(conn.servers.map((server) => server.clientIdentifier));
futures.add(_bindLocalPlexConnection(profile: profile, conn: conn, pc: pc, allowPinPrompt: allowPinPrompt)); futures.add(
_bindLocalPlexConnection(
profile: profile,
conn: conn,
pc: pc,
allowPinPrompt: allowPinPrompt,
generation: generation,
),
);
case JellyfinConnection(): case JellyfinConnection():
expected.add(conn.serverMachineId); expected.add(conn.serverMachineId);
futures.add(_bindJellyfin(conn)); futures.add(_bindJellyfin(conn, profileId: profile.id, generation: generation));
} }
} }
final results = await Future.wait(futures); final results = await Future.wait(futures);
@@ -565,102 +554,23 @@ class ActiveProfileBinder {
required PlexAccountConnection conn, required PlexAccountConnection conn,
required ProfileConnection pc, required ProfileConnection pc,
required bool allowPinPrompt, required bool allowPinPrompt,
required int generation,
}) async { }) async {
final auth = await _ensureAuth(); final auth = await _ensureAuth();
String? userToken = pc.userToken; if (!_isCurrentBind(profile.id, generation)) return const _ProfileBindResult.empty();
List<PlexServer>? servers; return _bindPlexWithTokenPolicy(
auth: auth,
if (userToken != null && userToken.isNotEmpty) { account: conn,
final cachedUserToken = userToken; profileId: profile.id,
// Same optimistic shape as the plex_home cached path: probe cached profileLabel: profile.displayName,
// metadata while the resource refresh runs alongside. generation: generation,
final fetchOutcome = _settleServerFetch(_fetchServersTimed(auth, cachedUserToken, profile.displayName)); cachedToken: pc.userToken,
final optimistic = await _bindOptimisticallyFromCache( invalidateCachedToken: () => profileConnections.recordToken(profile.id, conn.id, ''),
account: conn, mintToken: () =>
userToken: cachedUserToken, _mintLocalPlexToken(auth: auth, profile: profile, conn: conn, pc: pc, allowPinPrompt: allowPinPrompt),
profileId: profile.id, persistMintedToken: (token) => profileConnections.recordToken(profile.id, conn.id, token),
profileLabel: profile.displayName, markUsed: () => profileConnections.markUsed(profile.id, conn.id),
fetchOutcome: fetchOutcome, );
onAuthRejected: () => profileConnections.recordToken(profile.id, conn.id, ''),
);
if (optimistic != null && optimistic.visibleServerIds.isNotEmpty) {
await profileConnections.markUsed(profile.id, conn.id);
return optimistic;
}
try {
servers = await _unwrapServerFetch(fetchOutcome);
if (servers.isEmpty) {
appLogger.w(
'ActiveProfileBinder: cached local Plex token returned 0 servers for ${profile.displayName} — re-minting',
);
await profileConnections.recordToken(profile.id, conn.id, '');
userToken = null;
}
} on MediaServerHttpException catch (e) {
if (e.statusCode == 401 || e.statusCode == 403) {
appLogger.w(
'ActiveProfileBinder: cached local Plex token rejected (${e.statusCode}) for ${profile.displayName} — re-minting',
);
await profileConnections.recordToken(profile.id, conn.id, '');
userToken = null;
} else {
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e);
if (e.isTransient) {
// The optimistic pass already probed the cached metadata.
if (optimistic != null) return optimistic;
final ids = await _connectFromCachedServers(conn, cachedUserToken, profile.displayName, error: e);
if (ids.visibleServerIds.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id);
return ids;
}
return const _ProfileBindResult.empty();
}
} catch (e, st) {
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st);
return const _ProfileBindResult.empty();
}
}
if (userToken == null || userToken.isEmpty) {
if (pc.userIdentifier.isEmpty) {
appLogger.w('ActiveProfileBinder: ${profile.displayName} has no Plex Home user identifier');
return const _ProfileBindResult.empty();
}
final minted = await _mintLocalPlexToken(
auth: auth,
profile: profile,
conn: conn,
pc: pc,
allowPinPrompt: allowPinPrompt,
);
if (minted == null) return const _ProfileBindResult.empty();
userToken = minted;
try {
servers = await _fetchServersTimed(auth, userToken, profile.displayName);
} on MediaServerHttpException catch (e) {
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e);
if (e.statusCode == 401 || e.statusCode == 403) {
serverManager.markPlexConnectionAuthError(conn);
final ids = conn.servers.map((server) => server.clientIdentifier).toSet();
return _ProfileBindResult.visible(ids);
}
if (e.isTransient) {
final ids = await _connectFromCachedServers(conn, userToken, profile.displayName, error: e);
if (ids.visibleServerIds.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id);
return ids;
}
return const _ProfileBindResult.empty();
} catch (e, st) {
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st);
return const _ProfileBindResult.empty();
}
}
if (servers != null && servers.isNotEmpty) {
unawaited(_persistRefreshedServers(conn, servers));
}
final ids = await _connectFromServers(conn, userToken, servers ?? const <PlexServer>[], profile.displayName);
await profileConnections.markUsed(profile.id, conn.id);
return ids;
} }
Future<String?> _mintLocalPlexToken({ Future<String?> _mintLocalPlexToken({
@@ -670,6 +580,10 @@ class ActiveProfileBinder {
required ProfileConnection pc, required ProfileConnection pc,
required bool allowPinPrompt, required bool allowPinPrompt,
}) async { }) async {
if (pc.userIdentifier.isEmpty) {
appLogger.w('ActiveProfileBinder: ${profile.displayName} has no Plex Home user identifier');
return null;
}
final result = await switchPlexHomeUserWithPin( final result = await switchPlexHomeUserWithPin(
auth: auth, auth: auth,
accountToken: conn.accountToken, accountToken: conn.accountToken,
@@ -684,39 +598,137 @@ class ActiveProfileBinder {
logLabel: profile.displayName, logLabel: profile.displayName,
); );
if (!result.succeeded) return null; if (!result.succeeded) return null;
final userToken = result.userToken!; return result.userToken;
await profileConnections.recordToken(profile.id, conn.id, userToken);
return userToken;
} }
Future<_ProfileBindResult> _connectPlexServers( /// Apply the common Plex token policy while callers retain ownership of
PlexAccountConnection account, /// backend-specific minting, persistence, and post-bind bookkeeping.
String userToken, Future<_ProfileBindResult> _bindPlexWithTokenPolicy({
String profileLabel, required PlexAuthService auth,
) async { required PlexAccountConnection account,
final auth = await _ensureAuth(); required String profileId,
final List<PlexServer> servers; required String profileLabel,
try { required int generation,
servers = await _fetchServersTimed(auth, userToken, profileLabel); required String? cachedToken,
} on MediaServerHttpException catch (e, st) { required Future<void> Function() invalidateCachedToken,
appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st); required Future<String?> Function() mintToken,
if (e.statusCode == 401 || e.statusCode == 403) { required Future<void> Function(String token) persistMintedToken,
serverManager.markPlexConnectionAuthError(account); Future<void> Function()? markUsed,
final ids = account.servers.map((server) => server.clientIdentifier).toSet(); }) async {
return _ProfileBindResult.visible(ids); var userToken = cachedToken;
var usingCachedToken = userToken != null && userToken.isNotEmpty;
while (true) {
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
if (!usingCachedToken) {
userToken = await mintToken();
if (userToken == null || userToken.isEmpty || !_isCurrentBind(profileId, generation)) {
return const _ProfileBindResult.empty();
}
await persistMintedToken(userToken);
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
} }
if (e.isTransient) {
return _connectFromCachedServers(account, userToken, profileLabel, error: e, stackTrace: st); final token = userToken!;
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,
);
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
if (optimistic != null && optimistic.visibleServerIds.isNotEmpty) {
await markUsed?.call();
return optimistic;
}
}
final fetched = await _classifyServerFetch(fetchOutcome);
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
switch (fetched.status) {
case _ServerFetchStatus.success:
final servers = fetched.servers;
appLogger.i(
'ActiveProfileBinder: using ${usingCachedToken ? "cached" : "fresh"} token for '
'$profileLabel (${servers.length} servers)',
);
unawaited(_persistRefreshedServers(account, servers));
final result = await _connectFromServers(account, token, servers, profileLabel);
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
await markUsed?.call();
return result;
case _ServerFetchStatus.empty:
if (usingCachedToken) {
appLogger.w(
'ActiveProfileBinder: cached token returned 0 servers for $profileLabel — wiping and re-minting',
);
await invalidateCachedToken();
userToken = null;
usingCachedToken = false;
continue;
}
final result = await _connectFromServers(account, token, const <PlexServer>[], profileLabel);
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
await markUsed?.call();
return result;
case _ServerFetchStatus.authRejected:
if (usingCachedToken) {
final error = fetched.error as MediaServerHttpException;
appLogger.w(
'ActiveProfileBinder: cached token rejected (${error.statusCode}) for $profileLabel — re-minting',
);
await invalidateCachedToken();
userToken = null;
usingCachedToken = false;
continue;
}
appLogger.w(
'ActiveProfileBinder: freshly minted token rejected for $profileLabel',
error: fetched.error,
stackTrace: fetched.stackTrace,
);
serverManager.markPlexConnectionAuthError(account);
return _ProfileBindResult.visible(account.servers.map((server) => server.clientIdentifier).toSet());
case _ServerFetchStatus.transientFailure:
appLogger.w(
'ActiveProfileBinder: resource refresh failed for $profileLabel; using cached metadata',
error: fetched.error,
stackTrace: fetched.stackTrace,
);
// The optimistic pass already probed these endpoints.
if (optimistic != null) return optimistic;
final result = await _connectFromCachedServers(
account,
token,
profileLabel,
error: fetched.error,
stackTrace: fetched.stackTrace,
);
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
if (result.visibleServerIds.isNotEmpty) await markUsed?.call();
return result;
case _ServerFetchStatus.cancelled:
appLogger.d('ActiveProfileBinder: resource refresh cancelled for $profileLabel');
return const _ProfileBindResult.empty();
case _ServerFetchStatus.failure:
appLogger.w(
'ActiveProfileBinder: resource refresh failed for $profileLabel',
error: fetched.error,
stackTrace: fetched.stackTrace,
);
return const _ProfileBindResult.empty();
} }
return const _ProfileBindResult.empty();
} catch (e, st) {
appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st);
return const _ProfileBindResult.empty();
} }
if (servers.isNotEmpty) {
unawaited(_persistRefreshedServers(account, servers));
}
return _connectFromServers(account, userToken, servers, profileLabel);
} }
Future<_ProfileBindResult> _connectFromCachedServers( Future<_ProfileBindResult> _connectFromCachedServers(
@@ -792,15 +804,25 @@ class ActiveProfileBinder {
); );
} }
/// Rethrow a settled fetch with its original error/stack so existing Future<_ClassifiedFetch> _classifyServerFetch(Future<_FetchOutcome> outcome) async {
/// `on MediaServerHttpException` handlers keep working unchanged.
Future<List<PlexServer>> _unwrapServerFetch(Future<_FetchOutcome> outcome) async {
final settled = await outcome; final settled = await outcome;
final error = settled.error; final servers = settled.servers;
if (error != null) { if (servers != null) {
Error.throwWithStackTrace(error, settled.stackTrace ?? StackTrace.current); return (
status: servers.isEmpty ? _ServerFetchStatus.empty : _ServerFetchStatus.success,
servers: servers,
error: null,
stackTrace: null,
);
} }
return settled.servers!; final error = settled.error!;
final status = switch (error) {
MediaServerHttpException(isCancellation: true) => _ServerFetchStatus.cancelled,
MediaServerHttpException(statusCode: 401 || 403) => _ServerFetchStatus.authRejected,
MediaServerHttpException(isTransient: true) => _ServerFetchStatus.transientFailure,
_ => _ServerFetchStatus.failure,
};
return (status: status, servers: const <PlexServer>[], error: error, stackTrace: settled.stackTrace);
} }
/// Persist a freshly fetched resource list onto the stored account row so /// Persist a freshly fetched resource list onto the stored account row so
@@ -834,6 +856,7 @@ class ActiveProfileBinder {
required String userToken, required String userToken,
required String profileId, required String profileId,
required String profileLabel, required String profileLabel,
required int generation,
required Future<_FetchOutcome> fetchOutcome, required Future<_FetchOutcome> fetchOutcome,
required Future<void> Function() onAuthRejected, required Future<void> Function() onAuthRejected,
}) async { }) async {
@@ -850,6 +873,7 @@ class ActiveProfileBinder {
account: account, account: account,
profileId: profileId, profileId: profileId,
profileLabel: profileLabel, profileLabel: profileLabel,
generation: generation,
onAuthRejected: onAuthRejected, onAuthRejected: onAuthRejected,
); );
return result; return result;
@@ -868,6 +892,7 @@ class ActiveProfileBinder {
required PlexAccountConnection account, required PlexAccountConnection account,
required String profileId, required String profileId,
required String profileLabel, required String profileLabel,
required int generation,
required Future<void> Function() onAuthRejected, required Future<void> Function() onAuthRejected,
}) { }) {
unawaited( unawaited(
@@ -884,7 +909,7 @@ class ActiveProfileBinder {
// Wipe the bad token regardless of the active profile (DB hygiene), // Wipe the bad token regardless of the active profile (DB hygiene),
// but only surface the auth banner while this profile is active. // but only surface the auth banner while this profile is active.
await onAuthRejected(); await onAuthRejected();
if (activeProfile.activeId == profileId) { if (_isCurrentBind(profileId, generation)) {
serverManager.markPlexConnectionAuthError(account); serverManager.markPlexConnectionAuthError(account);
} }
} else { } else {
@@ -907,7 +932,7 @@ class ActiveProfileBinder {
return; return;
} }
await _persistRefreshedServers(account, fresh); await _persistRefreshedServers(account, fresh);
if (activeProfile.activeId != profileId) return; if (!_isCurrentBind(profileId, generation)) return;
final freshIds = fresh.map((server) => server.clientIdentifier).toSet(); final freshIds = fresh.map((server) => server.clientIdentifier).toSet();
final cachedIds = account.servers.map((server) => server.clientIdentifier).toSet(); final cachedIds = account.servers.map((server) => server.clientIdentifier).toSet();
if (!setEquals(freshIds, cachedIds)) { if (!setEquals(freshIds, cachedIds)) {
@@ -936,8 +961,15 @@ class ActiveProfileBinder {
); );
} }
Future<_ProfileBindResult> _bindJellyfin(JellyfinConnection conn) async { Future<_ProfileBindResult> _bindJellyfin(
JellyfinConnection conn, {
required String profileId,
required int generation,
}) async {
final ok = await serverManager.addJellyfinConnection(conn); final ok = await serverManager.addJellyfinConnection(conn);
if (!_isCurrentBind(profileId, generation)) {
return _ProfileBindResult(visibleServerIds: const {}, expectedServerIds: {conn.serverMachineId});
}
// `addJellyfinConnection` registers the client even when the health probe // `addJellyfinConnection` registers the client even when the health probe
// returns authError. Keep that server in the active profile's visibility // returns authError. Keep that server in the active profile's visibility
// filter so the re-auth banner can surface it instead of hiding it as if // filter so the re-auth banner can surface it instead of hiding it as if
@@ -948,6 +980,10 @@ class ActiveProfileBinder {
return _ProfileBindResult(visibleServerIds: const {}, expectedServerIds: {conn.serverMachineId}); return _ProfileBindResult(visibleServerIds: const {}, expectedServerIds: {conn.serverMachineId});
} }
bool _isCurrentBind(String profileId, int generation) {
return _bindGeneration == generation && activeProfile.activeId == profileId;
}
Future<PlexAuthService> _ensureAuth() async { Future<PlexAuthService> _ensureAuth() async {
return _plexAuth ??= await PlexAuthService.create(); return _plexAuth ??= await PlexAuthService.create();
} }
@@ -972,6 +1008,7 @@ class ActiveProfileBinder {
} }
void dispose() { void dispose() {
_bindGeneration++;
if (!_started) return; if (!_started) return;
activeProfile.removeListener(_onActiveProfileChanged); activeProfile.removeListener(_onActiveProfileChanged);
_plexHomePreVerified.clear(); _plexHomePreVerified.clear();
@@ -579,6 +579,175 @@ void main() {
expect(multiServerProvider.onlineServerIds, ['srv-1']); expect(multiServerProvider.onlineServerIds, ['srv-1']);
}); });
group('shared Plex server fetch policy', () {
Future<({Profile profile, PlexAccountConnection account})> prepareLocalPlex({
required http.Client httpClient,
required String? cachedToken,
List<PlexServer> cachedServers = const [],
MultiServerManager? testManager,
}) async {
binder.dispose();
multiServerProvider.dispose();
manager = testManager ?? _CapturingMultiServerManager();
multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
binder = ActiveProfileBinder(
activeProfile: activeProfile,
connections: connections,
profileConnections: profileConnections,
serverManager: manager,
multiServerProvider: multiServerProvider,
pinPrompt: (_, {String? errorMessage}) async => null,
shouldDeferInitialBind: (_) async => false,
plexAuth: PlexAuthService.forTesting(http: MediaServerHttpClient(client: httpClient)),
);
final account = PlexAccountConnection(
id: 'plex.policy',
accountToken: 'account-token',
clientIdentifier: 'client-id',
accountLabel: 'Owner',
servers: cachedServers,
createdAt: DateTime(2026, 1, 1),
);
await connections.upsert(account);
final profile = await createActiveLocalProfile('local-policy');
await profileConnections.upsert(
ProfileConnection(
profileId: profile.id,
connectionId: account.id,
userToken: cachedToken,
userIdentifier: 'home-user-uuid',
),
);
return (profile: profile, account: account);
}
test('cached token success connects the fetched servers without minting', () async {
var switchCalls = 0;
final capturing = _CapturingMultiServerManager();
final prepared = await prepareLocalPlex(
cachedToken: 'cached-user-token',
testManager: capturing,
httpClient: MockClient((request) async {
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++;
return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'});
}),
);
await binder.rebindActive();
expect(switchCalls, 0);
expect(capturing.refreshCalls, 1);
expect(capturing.lastConnection?.servers.single.accessToken, 'server-token');
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token');
expect(activeProfile.lastBindingSucceeded, isTrue);
});
test('missing token mints once before fetching and persists the fresh token', () async {
var switchCalls = 0;
var resourceCalls = 0;
final prepared = await prepareLocalPlex(
cachedToken: null,
httpClient: MockClient((request) async {
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) {
switchCalls++;
return http.Response(
jsonEncode({'authToken': 'fresh-user-token'}),
201,
headers: {'content-type': 'application/json'},
);
}
resourceCalls++;
return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'});
}),
);
await binder.rebindActive();
expect(switchCalls, 1);
expect(resourceCalls, 1);
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'fresh-user-token');
expect(activeProfile.lastBindingSucceeded, isTrue);
});
test('cached auth rejection invalidates and remints, while a fatal error does not', () async {
var resourceCalls = 0;
var switchCalls = 0;
final prepared = await prepareLocalPlex(
cachedToken: 'rejected-token',
httpClient: MockClient((request) async {
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) {
switchCalls++;
return http.Response(
jsonEncode({'authToken': 'replacement-token'}),
201,
headers: {'content-type': 'application/json'},
);
}
resourceCalls++;
if (resourceCalls == 1) {
return http.Response('{}', 401, headers: {'content-type': 'application/json'});
}
return http.Response('{}', 500, headers: {'content-type': 'application/json'});
}),
);
await binder.rebindActive();
expect(resourceCalls, 2);
expect(switchCalls, 1);
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'replacement-token');
expect(activeProfile.lastBindingSucceeded, isFalse);
});
test('cancelled fetch does not invalidate or remint the cached token', () async {
var switchCalls = 0;
final prepared = await prepareLocalPlex(
cachedToken: 'cached-user-token',
httpClient: MockClient((request) async {
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++;
throw http.RequestAbortedException(request.url);
}),
);
await binder.rebindActive();
expect(switchCalls, 0);
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token');
expect(activeProfile.lastBindingSucceeded, isFalse);
});
test('profile switch cancels stale zero-server handling before invalidation or remint', () async {
final requestStarted = Completer<void>();
final releaseRequest = Completer<void>();
var switchCalls = 0;
final prepared = await prepareLocalPlex(
cachedToken: 'cached-user-token',
httpClient: MockClient((request) async {
if (request.url.path.endsWith('/home/users/home-user-uuid/switch')) switchCalls++;
if (!requestStarted.isCompleted) requestStarted.complete();
await releaseRequest.future;
return http.Response('[]', 200, headers: {'content-type': 'application/json'});
}),
);
final nextProfile = Profile.local(id: 'local-next', displayName: 'Next', createdAt: DateTime(2026, 1, 2));
await profiles.upsert(nextProfile);
await pumpUntil(() async => activeProfile.profiles.any((profile) => profile.id == nextProfile.id));
binder.start();
await requestStarted.future;
await activeProfile.activate(nextProfile);
releaseRequest.complete();
await activeProfile.awaitBindingSettle();
expect(switchCalls, 0);
expect((await profileConnections.get(prepared.profile.id, prepared.account.id))?.userToken, 'cached-user-token');
expect(binder.debugLastBoundProfileId, nextProfile.id);
expect(activeProfile.lastBindingSucceeded, isTrue);
});
});
group('rebind cycle semantics', () { group('rebind cycle semantics', () {
test('queued same-id rebind settles once, after the last pass', () async { test('queued same-id rebind settles once, after the last pass', () async {
binder.dispose(); binder.dispose();