Drops dead code across services, models, utils and widgets, including the connection auth service, which had no implementer, and the Live TV DVR provisioning models, which had no caller. Tests that only covered deleted behaviour are removed or trimmed. No behaviour change.
1057 lines
44 KiB
Dart
1057 lines
44 KiB
Dart
import 'dart:async';
|
|
import '../media/ids.dart';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../connection/connection.dart';
|
|
import '../connection/connection_registry.dart';
|
|
import '../exceptions/media_server_exceptions.dart';
|
|
import '../providers/multi_server_provider.dart';
|
|
import '../services/multi_server_manager.dart';
|
|
import '../services/plex_auth_service.dart';
|
|
import '../utils/app_logger.dart';
|
|
import 'active_profile_provider.dart';
|
|
import 'plex_home_switch.dart';
|
|
import 'profile.dart';
|
|
import 'profile_connection.dart';
|
|
import 'profile_connection_registry.dart';
|
|
|
|
/// Callback invoked when a Plex Home user PIN is required mid-activation.
|
|
/// Returns the entered PIN, or `null` to abort. The implementation
|
|
/// (typically in `main_screen.dart`) should call `showPinEntryDialog`.
|
|
typedef PlexHomePinPrompt = Future<String?> Function(Profile profile, {String? errorMessage});
|
|
|
|
typedef ShouldDeferInitialBind = FutureOr<bool> Function(Profile profile);
|
|
|
|
class _ProfileBindResult {
|
|
const _ProfileBindResult({required this.visibleServerIds, required this.expectedServerIds});
|
|
|
|
const _ProfileBindResult.empty() : visibleServerIds = const {}, expectedServerIds = const {};
|
|
|
|
_ProfileBindResult.visible(Set<String> ids)
|
|
: visibleServerIds = Set.unmodifiable(ids),
|
|
expectedServerIds = Set.unmodifiable(ids);
|
|
|
|
final Set<String> visibleServerIds;
|
|
final Set<String> expectedServerIds;
|
|
}
|
|
|
|
/// Settled outcome of a `fetchServers` call, so the resource refresh can run
|
|
/// alongside an optimistic cached-metadata bind without an early failure
|
|
/// surfacing as an unhandled async error.
|
|
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
|
|
bool shouldUsePlexHomeTokenCache({required bool preVerified, required bool hasBoundOnce}) {
|
|
return preVerified || !hasBoundOnce;
|
|
}
|
|
|
|
/// An empty bound set is propagated as `{}` so a profile with no connections
|
|
/// shows nothing — falling back to "all visible" would leak servers attached
|
|
/// to other profiles.
|
|
class ActiveProfileBinder {
|
|
ActiveProfileBinder({
|
|
required this.activeProfile,
|
|
required this.connections,
|
|
required this.profileConnections,
|
|
required this.serverManager,
|
|
required this.multiServerProvider,
|
|
required this.pinPrompt,
|
|
this.shouldDeferInitialBind,
|
|
this._plexAuth,
|
|
});
|
|
|
|
final ActiveProfileProvider activeProfile;
|
|
final ConnectionRegistry connections;
|
|
final ProfileConnectionRegistry profileConnections;
|
|
final MultiServerManager serverManager;
|
|
final MultiServerProvider multiServerProvider;
|
|
final PlexHomePinPrompt pinPrompt;
|
|
final ShouldDeferInitialBind? shouldDeferInitialBind;
|
|
|
|
PlexAuthService? _plexAuth;
|
|
|
|
bool _started = false;
|
|
bool _isSwitching = false;
|
|
String? _lastBoundProfileId;
|
|
String? _bindingProfileId;
|
|
|
|
/// Profile whose most recent bind failed (PIN cancel, offline, error).
|
|
/// Passive provider notifications must not retry it — mid-session retries
|
|
/// bypass the token cache, so a protected Plex Home profile would pop a
|
|
/// PIN dialog with no user action. Explicit paths ([rebindActive], a
|
|
/// user-initiated activation, a pre-verified switch) clear the marker.
|
|
String? _lastFailedProfileId;
|
|
bool _pendingRebind = false;
|
|
// Set when something asks for a rebind of the *currently-active* profile
|
|
// while a rebind is already in flight. The normal `_pendingRebind` path
|
|
// 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.
|
|
bool _pendingSameIdRebind = false;
|
|
int _bindGeneration = 0;
|
|
|
|
/// True after the binder has successfully bound at least one profile in
|
|
/// this session. Once set, subsequent rebinds bypass the user-token
|
|
/// cache and always call `/home/users/{uuid}/switch` — that round-trip
|
|
/// is the only way Plex re-validates the user's PIN. Cold-start auto-resume
|
|
/// still uses the cache unless the user enabled profile selection on open.
|
|
bool _hasBoundOnce = false;
|
|
|
|
/// Plex Home profile ids whose PIN was just verified by the activation
|
|
/// UI via a successful `/home/users/{uuid}/switch` round-trip. Consumed
|
|
/// once by [_bindPlexHome] to permit the freshly cached user-token for
|
|
/// that single rebind and avoid a duplicate PIN prompt.
|
|
final Set<String> _plexHomePreVerified = {};
|
|
final Set<String> _userInitiatedActivations = {};
|
|
|
|
@visibleForTesting
|
|
String? get debugLastBoundProfileId => _lastBoundProfileId;
|
|
|
|
void markPlexHomePreVerified(String profileId) {
|
|
_plexHomePreVerified.add(profileId);
|
|
if (_lastFailedProfileId == profileId) _lastFailedProfileId = null;
|
|
}
|
|
|
|
void markUserInitiatedActivation(String profileId) {
|
|
_userInitiatedActivations.add(profileId);
|
|
if (_lastFailedProfileId == profileId) _lastFailedProfileId = null;
|
|
}
|
|
|
|
@visibleForTesting
|
|
bool consumePlexHomePreVerified(String profileId) {
|
|
return _plexHomePreVerified.remove(profileId);
|
|
}
|
|
|
|
@visibleForTesting
|
|
bool consumeUserInitiatedActivation(String profileId) {
|
|
return _userInitiatedActivations.remove(profileId);
|
|
}
|
|
|
|
void start() {
|
|
if (_started) return;
|
|
_started = true;
|
|
// Flip `isBinding` before anything else: callers navigate right after
|
|
// start(), and screens (DiscoverScreen's no-servers gate) read the flag
|
|
// synchronously during their first build. Deferring the mark to the
|
|
// microtask below leaves a started-but-flag-false gap in which the gate
|
|
// throws "No servers available" on fresh login. Marking before
|
|
// addListener keeps the binder's own listener from reacting to this
|
|
// notification — the microtask stays the single initial-rebind entry.
|
|
activeProfile.markBindingStarted();
|
|
activeProfile.addListener(_onActiveProfileChanged);
|
|
// Callers invoke start() from async contexts after the offline decision
|
|
// has been made (SetupScreen, MainScreen post-frame, AuthScreen). The
|
|
// microtask keeps the initial rebind — and any PIN prompt it pops — out
|
|
// of the caller's current frame.
|
|
scheduleMicrotask(() {
|
|
if (!_started) {
|
|
// Disposed before the rebind could run — settle the flag we set
|
|
// above so awaitBindingSettle callers aren't stranded.
|
|
activeProfile.markBindingFinished(success: true);
|
|
return;
|
|
}
|
|
unawaited(_rebind());
|
|
});
|
|
}
|
|
|
|
void _onActiveProfileChanged() {
|
|
final id = activeProfile.activeId;
|
|
if (_isSwitching) {
|
|
// Ignore our own markBindingStarted/markBindingFinished
|
|
// notifications. They don't mean the active profile changed, and a
|
|
// failed bind intentionally leaves `_lastBoundProfileId` unset so the
|
|
// same profile can be retried later.
|
|
if (id == _bindingProfileId) {
|
|
// The active id can briefly move away and back while this pass is
|
|
// awaiting multiple connection binds. Any component that observed the
|
|
// intermediate id may already have returned an empty stale result, so
|
|
// the current pass cannot be committed as the final same-id bind.
|
|
if (_pendingRebind) _pendingSameIdRebind = true;
|
|
return;
|
|
}
|
|
// A rebind is already in flight — flag a follow-up so the loop in
|
|
// [_rebind] picks up the new active id once the current pass settles.
|
|
// Otherwise the switch is silently dropped (the early-return on
|
|
// `_isSwitching` would leave storage saying B is active while the
|
|
// binder is still wired to A).
|
|
_pendingRebind = true;
|
|
return;
|
|
}
|
|
if (id == _lastBoundProfileId) return;
|
|
// Don't retry a failed profile from a passive notification — see
|
|
// [_lastFailedProfileId]. A different profile id still rebinds.
|
|
if (id != null && id == _lastFailedProfileId) return;
|
|
unawaited(_rebind());
|
|
}
|
|
|
|
/// Force the binder to re-run for the currently-active profile, even
|
|
/// when the active id hasn't changed. Used by flows that mutate the
|
|
/// active profile's connection set in-place — e.g. the borrow screen
|
|
/// upserts a new join row and needs the binder to pick it up so the new
|
|
/// server's libraries appear without an app restart.
|
|
///
|
|
/// Safe to call while a rebind is in flight; the request is queued and
|
|
/// the loop runs an extra pass when the current one settles.
|
|
Future<void> rebindActive() async {
|
|
_lastFailedProfileId = null;
|
|
if (_isSwitching) {
|
|
_pendingSameIdRebind = true;
|
|
return;
|
|
}
|
|
await _rebind();
|
|
}
|
|
|
|
/// Convenience: rebind only when [profileId] matches the active profile.
|
|
/// No-op otherwise — the change will be picked up on next activation.
|
|
/// Use this from screens that mutate a specific profile's connections.
|
|
Future<void> rebindIfActive(String profileId) async {
|
|
if (activeProfile.activeId != profileId) return;
|
|
await rebindActive();
|
|
}
|
|
|
|
Future<void> _rebind() async {
|
|
if (_isSwitching) return;
|
|
_isSwitching = true;
|
|
// Binding is marked per CYCLE, not per pass: `awaitBindingSettle`
|
|
// waiters must observe the FINAL outcome. Settling between passes hands
|
|
// a caller who activated profile B mid-pass the outcome of profile A's
|
|
// pass — reporting a switch as succeeded/failed before B's bind ran.
|
|
_bindingProfileId = activeProfile.activeId;
|
|
activeProfile.markBindingStarted();
|
|
var success = false;
|
|
try {
|
|
do {
|
|
_pendingRebind = false;
|
|
_pendingSameIdRebind = false;
|
|
success = await _runRebindOnce();
|
|
// Loop only when the active id has drifted to something we haven't
|
|
// bound yet, OR when an explicit same-id rebind was queued (borrow
|
|
// / connection-list mutation while a rebind was in flight). Bare
|
|
// `_pendingRebind` would spin forever if the user taps the active
|
|
// profile while we're binding (id matches, no work to do, flag
|
|
// re-asserts).
|
|
} while (_pendingSameIdRebind || (_pendingRebind && activeProfile.activeId != _lastBoundProfileId));
|
|
} finally {
|
|
// Notify while `_isSwitching`/`_bindingProfileId` still attribute the
|
|
// notification to this cycle — otherwise the binder's own listener
|
|
// would treat it as an external change and immediately re-rebind.
|
|
activeProfile.markBindingFinished(success: success);
|
|
_bindingProfileId = null;
|
|
_isSwitching = false;
|
|
}
|
|
}
|
|
|
|
Future<bool> _runRebindOnce() async {
|
|
_bindingProfileId = activeProfile.activeId;
|
|
final generation = ++_bindGeneration;
|
|
final stopwatch = Stopwatch()..start();
|
|
var success = false;
|
|
String? attemptedProfileId;
|
|
try {
|
|
final profile = activeProfile.active;
|
|
if (profile == null) {
|
|
// No active profile is a valid quiescent state (e.g. fresh sign-in
|
|
// before the picker fires) — report success so the picker, if it's
|
|
// waiting, doesn't surface a spurious "switch failed" error. Also
|
|
// clear the runtime filter so stale clients from the previous
|
|
// profile cannot leak into the no-selection state.
|
|
_clearBoundServers();
|
|
success = true;
|
|
return success;
|
|
}
|
|
attemptedProfileId = profile.id;
|
|
|
|
final userInitiated = consumeUserInitiatedActivation(profile.id);
|
|
if (!userInitiated && !_hasBoundOnce && await _shouldDeferInitialBind(profile)) {
|
|
appLogger.i('ActiveProfileBinder: deferring initial bind for ${profile.displayName} until profile selection');
|
|
_clearBoundServers();
|
|
attemptedProfileId = null;
|
|
success = true;
|
|
return success;
|
|
}
|
|
|
|
appLogger.i('ActiveProfileBinder: rebinding for ${profile.displayName} (${profile.id})');
|
|
|
|
// One snapshot of the join rows + connections per pass — every
|
|
// downstream helper reads from these instead of re-querying (each
|
|
// registry read pays per-row CredentialVault reveals).
|
|
final joinRows = await profileConnections.listForProfile(profile.id);
|
|
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
|
|
// session's initial bind (cold-start resume). Passive rebinds — an
|
|
// hourly Plex Home refresh, an unrelated table write — must never pop
|
|
// a modal PIN dialog over whatever the user is doing.
|
|
final allowPinPrompt = userInitiated || !_hasBoundOnce;
|
|
|
|
final expectedServerIds = _expectedServerIdsForProfile(
|
|
profile,
|
|
joinRows: joinRows,
|
|
connectionsById: connectionsById,
|
|
);
|
|
multiServerProvider.setExpectedVisibleServerIds(expectedServerIds);
|
|
final localProfileHasJoinRows = profile.isLocal && joinRows.isNotEmpty;
|
|
|
|
// Bind the implicit Plex Home parent and borrowed/extra join rows in
|
|
// parallel. A slow/offline Plex parent should not add its timeout budget
|
|
// on top of an otherwise reachable Jellyfin or borrowed-server bind.
|
|
final results = await Future.wait([
|
|
if (profile.isPlexHome)
|
|
_bindPlexHome(
|
|
profile,
|
|
joinRows: joinRows,
|
|
connectionsById: connectionsById,
|
|
allowPinPrompt: allowPinPrompt,
|
|
generation: generation,
|
|
),
|
|
// Both kinds also bind borrowed/extra connections via the join table.
|
|
// For plex_home this handles a Jellyfin server (or extra Plex account)
|
|
// that was attached to the profile via the borrow flow — the parent
|
|
// account is bound by `_bindPlexHome` above and isn't represented in
|
|
// the join table.
|
|
_bindJoinRows(
|
|
profile,
|
|
joinRows: joinRows,
|
|
connectionsById: connectionsById,
|
|
allowPinPrompt: allowPinPrompt,
|
|
generation: generation,
|
|
),
|
|
]);
|
|
if (!_isCurrentBind(profile.id, generation)) return false;
|
|
final visibleServerIds = <String>{};
|
|
for (final result in results) {
|
|
visibleServerIds.addAll(result.visibleServerIds);
|
|
expectedServerIds.addAll(result.expectedServerIds);
|
|
}
|
|
|
|
// Remove servers the profile no longer has access to. Always set the
|
|
// filter to the bound set (even when empty) so a profile with no
|
|
// connections shows nothing — falling back to "all visible" on empty
|
|
// would leak servers attached to other profiles.
|
|
for (final serverId in serverManager.serverIds.toList()) {
|
|
if (!visibleServerIds.contains(serverId)) {
|
|
serverManager.removeServer(ServerId(serverId));
|
|
}
|
|
}
|
|
multiServerProvider.setExpectedVisibleServerIds(expectedServerIds);
|
|
multiServerProvider.setVisibleServerIds(visibleServerIds);
|
|
success = (profile.isLocal && !localProfileHasJoinRows) || visibleServerIds.isNotEmpty;
|
|
// Once we've bound a profile with real servers in this session,
|
|
// we've crossed the cold-start boundary — every subsequent rebind
|
|
// is a user-initiated switch and must re-prompt for PIN where
|
|
// applicable. See [_hasBoundOnce] for the security rationale.
|
|
if (success) _hasBoundOnce = true;
|
|
} catch (e, st) {
|
|
appLogger.e('ActiveProfileBinder: rebind failed', error: e, stackTrace: st);
|
|
success = false;
|
|
} finally {
|
|
if (success) {
|
|
_lastBoundProfileId = attemptedProfileId;
|
|
_lastFailedProfileId = null;
|
|
} else {
|
|
if (_lastBoundProfileId == attemptedProfileId) {
|
|
_lastBoundProfileId = null;
|
|
}
|
|
_lastFailedProfileId = attemptedProfileId;
|
|
}
|
|
appLogger.i(
|
|
'ActiveProfileBinder: rebind settled',
|
|
error: {'profileId': attemptedProfileId, 'success': success, 'elapsedMs': stopwatch.elapsedMilliseconds},
|
|
);
|
|
}
|
|
return success;
|
|
}
|
|
|
|
Set<String> _expectedServerIdsForProfile(
|
|
Profile profile, {
|
|
required List<ProfileConnection> joinRows,
|
|
required Map<String, Connection> connectionsById,
|
|
}) {
|
|
final expected = <String>{};
|
|
final parentId = profile.parentConnectionId;
|
|
if (profile.isPlexHome && parentId != null) {
|
|
if (connectionsById[parentId] case PlexAccountConnection(:final servers)) {
|
|
expected.addAll(servers.map((server) => server.clientIdentifier));
|
|
}
|
|
}
|
|
|
|
for (final pc in joinRows) {
|
|
if (parentId != null && pc.connectionId == parentId) continue;
|
|
switch (connectionsById[pc.connectionId]) {
|
|
case PlexAccountConnection(:final servers):
|
|
expected.addAll(servers.map((server) => server.clientIdentifier));
|
|
case JellyfinConnection(:final serverMachineId):
|
|
expected.add(serverMachineId);
|
|
case null:
|
|
break;
|
|
}
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
Future<_ProfileBindResult> _bindPlexHome(
|
|
Profile profile, {
|
|
required List<ProfileConnection> joinRows,
|
|
required Map<String, Connection> connectionsById,
|
|
required bool allowPinPrompt,
|
|
required int generation,
|
|
}) async {
|
|
final parentId = profile.parentConnectionId;
|
|
final homeUuid = profile.plexHomeUserUuid;
|
|
if (parentId == null || homeUuid == null) {
|
|
appLogger.w('ActiveProfileBinder: ${profile.displayName} missing parent/uuid metadata');
|
|
return const _ProfileBindResult.empty();
|
|
}
|
|
final account = switch (connectionsById[parentId]) {
|
|
final PlexAccountConnection a => a,
|
|
_ => null,
|
|
};
|
|
if (account == null) {
|
|
appLogger.w('ActiveProfileBinder: parent connection $parentId for ${profile.displayName} not found');
|
|
return const _ProfileBindResult.empty();
|
|
}
|
|
final auth = await _ensureAuth();
|
|
if (!_isCurrentBind(profile.id, generation)) return const _ProfileBindResult.empty();
|
|
|
|
// Fast path: reuse the previously-minted user-token from the
|
|
// [ProfileConnection] row for this profile's parent connection.
|
|
// Cold-start auto-resume can use cached tokens. Once a profile is bound in
|
|
// this session, switches bypass the cache so Plex revalidates PINs where
|
|
// needed. A just-preverified activation also uses the fresh cache once to
|
|
// avoid a redundant second prompt.
|
|
final preVerified = consumePlexHomePreVerified(profile.id);
|
|
final allowPin = allowPinPrompt || preVerified;
|
|
final useCache = shouldUsePlexHomeTokenCache(preVerified: preVerified, hasBoundOnce: _hasBoundOnce);
|
|
String? cachedToken;
|
|
if (useCache) {
|
|
ProfileConnection? pc;
|
|
for (final row in joinRows) {
|
|
if (row.connectionId == parentId) {
|
|
pc = row;
|
|
break;
|
|
}
|
|
}
|
|
cachedToken = pc?.hasToken == true ? pc!.userToken : null;
|
|
}
|
|
appLogger.d(
|
|
'ActiveProfileBinder: cache lookup for ${profile.displayName} (account=${account.id}, '
|
|
'uuid=$homeUuid, useCache=$useCache, preVerified=$preVerified): ${cachedToken == null ? (useCache ? "MISS" : "BYPASS") : "HIT"}',
|
|
);
|
|
return _bindPlexWithTokenPolicy(
|
|
auth: auth,
|
|
account: account,
|
|
profileId: profile.id,
|
|
profileLabel: profile.displayName,
|
|
generation: generation,
|
|
cachedToken: cachedToken,
|
|
invalidateCachedToken: () => profileConnections.recordToken(profile.id, parentId, ''),
|
|
mintToken: () async {
|
|
if (!allowPin && profile.plexProtected) {
|
|
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})',
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Bind every [ProfileConnection] row for [profile]. Used by both kinds:
|
|
/// for local profiles, this is the entire bind. For plex_home profiles,
|
|
/// this handles connections borrowed on top of the parent account (the
|
|
/// parent itself is bound by [_bindPlexHome] and is implicit — not in the
|
|
/// join table). Skips Plex rows whose `connectionId` matches the parent
|
|
/// (defensive guard — sync code shouldn't insert one, but treating it as
|
|
/// a borrow would re-mint a redundant token).
|
|
Future<_ProfileBindResult> _bindJoinRows(
|
|
Profile profile, {
|
|
required List<ProfileConnection> joinRows,
|
|
required Map<String, Connection> connectionsById,
|
|
required bool allowPinPrompt,
|
|
required int generation,
|
|
}) async {
|
|
if (joinRows.isEmpty) {
|
|
if (profile.isLocal) {
|
|
appLogger.w('ActiveProfileBinder: ${profile.displayName} has no connections');
|
|
}
|
|
return const _ProfileBindResult.empty();
|
|
}
|
|
final parentId = profile.parentConnectionId;
|
|
|
|
final visible = <String>{};
|
|
final expected = <String>{};
|
|
final futures = <Future<_ProfileBindResult>>[];
|
|
for (final pc in joinRows) {
|
|
if (parentId != null && pc.connectionId == parentId) continue;
|
|
final conn = connectionsById[pc.connectionId];
|
|
if (conn == null) {
|
|
appLogger.w('ActiveProfileBinder: missing connection ${pc.connectionId} for ${profile.displayName}');
|
|
continue;
|
|
}
|
|
switch (conn) {
|
|
case PlexAccountConnection():
|
|
expected.addAll(conn.servers.map((server) => server.clientIdentifier));
|
|
futures.add(
|
|
_bindLocalPlexConnection(
|
|
profile: profile,
|
|
conn: conn,
|
|
pc: pc,
|
|
allowPinPrompt: allowPinPrompt,
|
|
generation: generation,
|
|
),
|
|
);
|
|
case JellyfinConnection():
|
|
expected.add(conn.serverMachineId);
|
|
futures.add(_bindJellyfin(conn, profileId: profile.id, generation: generation));
|
|
}
|
|
}
|
|
final results = await Future.wait(futures);
|
|
for (final result in results) {
|
|
visible.addAll(result.visibleServerIds);
|
|
expected.addAll(result.expectedServerIds);
|
|
}
|
|
return _ProfileBindResult(visibleServerIds: visible, expectedServerIds: expected);
|
|
}
|
|
|
|
Future<_ProfileBindResult> _bindLocalPlexConnection({
|
|
required Profile profile,
|
|
required PlexAccountConnection conn,
|
|
required ProfileConnection pc,
|
|
required bool allowPinPrompt,
|
|
required int generation,
|
|
}) async {
|
|
final auth = await _ensureAuth();
|
|
if (!_isCurrentBind(profile.id, generation)) return const _ProfileBindResult.empty();
|
|
return _bindPlexWithTokenPolicy(
|
|
auth: auth,
|
|
account: conn,
|
|
profileId: profile.id,
|
|
profileLabel: profile.displayName,
|
|
generation: generation,
|
|
cachedToken: pc.userToken,
|
|
invalidateCachedToken: () => profileConnections.recordToken(profile.id, conn.id, ''),
|
|
mintToken: () =>
|
|
_mintLocalPlexToken(auth: auth, profile: profile, conn: conn, pc: pc, allowPinPrompt: allowPinPrompt),
|
|
persistMintedToken: (token) => profileConnections.recordToken(profile.id, conn.id, token),
|
|
markUsed: () => profileConnections.markUsed(profile.id, conn.id),
|
|
);
|
|
}
|
|
|
|
Future<String?> _mintLocalPlexToken({
|
|
required PlexAuthService auth,
|
|
required Profile profile,
|
|
required PlexAccountConnection conn,
|
|
required ProfileConnection pc,
|
|
required bool allowPinPrompt,
|
|
}) async {
|
|
if (pc.userIdentifier.isEmpty) {
|
|
appLogger.w('ActiveProfileBinder: ${profile.displayName} has no Plex Home user identifier');
|
|
return null;
|
|
}
|
|
final result = await switchPlexHomeUserWithPin(
|
|
auth: auth,
|
|
accountToken: conn.accountToken,
|
|
homeUserUuid: pc.userIdentifier,
|
|
// Local profiles don't carry the protected flag; the loop will
|
|
// re-prompt if Plex disagrees — unless this is a passive rebind, which
|
|
// answers the demand with a cancel instead of an unsolicited dialog.
|
|
requiresPin: false,
|
|
promptForPin: allowPinPrompt
|
|
? ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage)
|
|
: ({String? errorMessage}) async => null,
|
|
logLabel: profile.displayName,
|
|
);
|
|
if (!result.succeeded) return null;
|
|
return result.userToken;
|
|
}
|
|
|
|
/// Apply the common Plex token policy while callers retain ownership of
|
|
/// backend-specific minting, persistence, and post-bind bookkeeping.
|
|
Future<_ProfileBindResult> _bindPlexWithTokenPolicy({
|
|
required PlexAuthService auth,
|
|
required PlexAccountConnection account,
|
|
required String profileId,
|
|
required String profileLabel,
|
|
required int generation,
|
|
required String? cachedToken,
|
|
required Future<void> Function() invalidateCachedToken,
|
|
required Future<String?> Function() mintToken,
|
|
required Future<void> Function(String token) persistMintedToken,
|
|
Future<void> Function()? markUsed,
|
|
}) async {
|
|
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();
|
|
}
|
|
|
|
final token = userToken!;
|
|
final fetchOutcome = _settleServerFetch(_fetchServersTimed(auth, token, profileLabel));
|
|
_ProfileBindResult? optimistic;
|
|
if (usingCachedToken) {
|
|
// 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,
|
|
profileId: profileId,
|
|
profileLabel: profileLabel,
|
|
);
|
|
if (!_isCurrentBind(profileId, generation)) return const _ProfileBindResult.empty();
|
|
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;
|
|
}
|
|
}
|
|
|
|
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, profileId: profileId);
|
|
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,
|
|
profileId: profileId,
|
|
);
|
|
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 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,
|
|
profileLabel,
|
|
profileId: profileId,
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
String profileLabel, {
|
|
required String profileId,
|
|
Object? error,
|
|
StackTrace? stackTrace,
|
|
}) async {
|
|
if (account.servers.isEmpty) return const _ProfileBindResult.empty();
|
|
appLogger.w(
|
|
'ActiveProfileBinder: using cached Plex server metadata for $profileLabel after resource refresh failed',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
final servers = _cachedServersCompatibleWithUserToken(account, userToken, profileLabel);
|
|
if (servers.isEmpty) return const _ProfileBindResult.empty();
|
|
return _connectFromServers(account, userToken, servers, profileLabel, profileId: profileId);
|
|
}
|
|
|
|
Future<_ProfileBindResult> _connectFromServers(
|
|
PlexAccountConnection account,
|
|
String userToken,
|
|
List<PlexServer> servers,
|
|
String profileLabel, {
|
|
required String profileId,
|
|
}) async {
|
|
if (servers.isEmpty) {
|
|
appLogger.w('ActiveProfileBinder: no servers for $profileLabel on ${account.accountLabel}');
|
|
return const _ProfileBindResult.empty();
|
|
}
|
|
final stopwatch = Stopwatch()..start();
|
|
final updatedConn = account.copyWith(servers: servers);
|
|
final boundIds = await serverManager.refreshTokensForProfile(updatedConn, profileId: profileId);
|
|
appLogger.i(
|
|
'ActiveProfileBinder: bound ${boundIds.length}/${servers.length} Plex servers for $profileLabel',
|
|
error: {'elapsedMs': stopwatch.elapsedMilliseconds},
|
|
);
|
|
// Return only the ids that actually connected — the visibility filter
|
|
// pushed downstream must not include unreachable servers, otherwise
|
|
// the UI lists them and downstream calls 404/timeout per interaction.
|
|
return _ProfileBindResult(
|
|
visibleServerIds: boundIds,
|
|
expectedServerIds: servers.map((server) => server.clientIdentifier).toSet(),
|
|
);
|
|
}
|
|
|
|
/// Run [PlexAuthService.fetchServers] with a timing log so cold-start
|
|
/// slowness is attributable from logs. Throws through to the caller.
|
|
Future<List<PlexServer>> _fetchServersTimed(PlexAuthService auth, String token, String profileLabel) async {
|
|
final stopwatch = Stopwatch()..start();
|
|
try {
|
|
final servers = await auth.fetchServers(token);
|
|
appLogger.i(
|
|
'ActiveProfileBinder: resource refresh completed for $profileLabel',
|
|
error: {'servers': servers.length, 'elapsedMs': stopwatch.elapsedMilliseconds},
|
|
);
|
|
return servers;
|
|
} catch (_) {
|
|
appLogger.d(
|
|
'ActiveProfileBinder: resource refresh failed for $profileLabel',
|
|
error: {'elapsedMs': stopwatch.elapsedMilliseconds},
|
|
);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Capture a fetch's outcome as a value so the resource refresh can run
|
|
/// alongside the optimistic cached bind — an early failure must not
|
|
/// surface as an unhandled async error while nothing is awaiting it yet.
|
|
Future<_FetchOutcome> _settleServerFetch(Future<List<PlexServer>> fetch) {
|
|
return fetch.then<_FetchOutcome>(
|
|
(servers) => (servers: servers, error: null, stackTrace: null),
|
|
onError: (Object error, StackTrace stackTrace) => (servers: null, error: error, stackTrace: stackTrace),
|
|
);
|
|
}
|
|
|
|
Future<_ClassifiedFetch> _classifyServerFetch(Future<_FetchOutcome> outcome) async {
|
|
final settled = await outcome;
|
|
final servers = settled.servers;
|
|
if (servers != null) {
|
|
return (
|
|
status: servers.isEmpty ? _ServerFetchStatus.empty : _ServerFetchStatus.success,
|
|
servers: servers,
|
|
error: null,
|
|
stackTrace: null,
|
|
);
|
|
}
|
|
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
|
|
/// later cold starts (and the cached-metadata fallbacks) work from current
|
|
/// URIs instead of the sign-in-day snapshot. Best-effort.
|
|
Future<void> _persistRefreshedServers(PlexAccountConnection account, List<PlexServer> servers) async {
|
|
try {
|
|
await connections.upsert(account.copyWith(servers: servers));
|
|
} catch (e, st) {
|
|
appLogger.w(
|
|
'ActiveProfileBinder: failed to persist refreshed servers for ${account.accountLabel}',
|
|
error: e,
|
|
stackTrace: st,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// 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,
|
|
}) 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 compatible cached server metadata '
|
|
'while resources refresh',
|
|
error: {'servers': cachedServers.length, 'totalServers': account.servers.length},
|
|
);
|
|
return _connectFromServers(account, userToken, cachedServers, profileLabel, profileId: profileId);
|
|
}
|
|
|
|
/// Apply the background resource refresh after an optimistic cached bind:
|
|
/// persist fresh metadata, rotate per-server tokens in place, retry servers
|
|
/// the optimistic pass left offline, and pick up membership changes.
|
|
///
|
|
/// Waits for the in-flight rebind to settle first (applying mid-rebind
|
|
/// would race the visibility sweep in [_runRebindOnce]) and no-ops when the
|
|
/// active profile has changed — applying a stale profile's clients would
|
|
/// leak its servers into another profile's session.
|
|
void _reconcileWhenFetchLands({
|
|
required Future<_FetchOutcome> fetchOutcome,
|
|
required PlexAccountConnection account,
|
|
required String profileId,
|
|
required String profileLabel,
|
|
required int generation,
|
|
required Future<void> Function() onAuthRejected,
|
|
}) {
|
|
unawaited(
|
|
() async {
|
|
final settled = await fetchOutcome;
|
|
await activeProfile.awaitBindingSettle();
|
|
final error = settled.error;
|
|
if (error != null) {
|
|
if (error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) {
|
|
appLogger.w(
|
|
'ActiveProfileBinder: cached token rejected (${error.statusCode}) during background refresh '
|
|
'for $profileLabel — flagging re-auth',
|
|
);
|
|
// Wipe the bad token regardless of the active profile (DB hygiene),
|
|
// but only surface the auth banner while this profile is active.
|
|
await onAuthRejected();
|
|
if (_isCurrentBind(profileId, generation)) {
|
|
serverManager.markPlexConnectionAuthError(account);
|
|
}
|
|
} else {
|
|
appLogger.w(
|
|
'ActiveProfileBinder: background resource refresh failed for $profileLabel; staying on cached metadata',
|
|
error: error,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
final fresh = settled.servers!;
|
|
if (fresh.isEmpty) {
|
|
// A previously-populated account answering with zero servers is
|
|
// almost always transient plex.tv weirdness. Re-minting from a
|
|
// background task could pop a PIN prompt out of nowhere — keep the
|
|
// cache and let the next explicit bind sort it out.
|
|
appLogger.w(
|
|
'ActiveProfileBinder: background refresh returned 0 servers for $profileLabel; keeping cached metadata',
|
|
);
|
|
return;
|
|
}
|
|
await _persistRefreshedServers(account, fresh);
|
|
if (!_isCurrentBind(profileId, generation)) return;
|
|
final freshIds = fresh.map((server) => server.clientIdentifier).toSet();
|
|
final cachedIds = account.servers.map((server) => server.clientIdentifier).toSet();
|
|
if (!setEquals(freshIds, cachedIds)) {
|
|
appLogger.i(
|
|
'ActiveProfileBinder: server membership changed for $profileLabel — rebinding',
|
|
error: {'cached': cachedIds.length, 'fresh': freshIds.length},
|
|
);
|
|
// The refresh just round-trip-validated this profile's token; let
|
|
// the rebind reuse the cache instead of re-prompting through /switch.
|
|
markPlexHomePreVerified(profileId);
|
|
await rebindIfActive(profileId);
|
|
return;
|
|
}
|
|
// Same membership: rotate tokens/URIs in place and retry anything the
|
|
// optimistic pass left offline. Newly-online expected servers are
|
|
// promoted into the visibility filter by MultiServerProvider when the
|
|
// status emission this triggers lands.
|
|
await serverManager.refreshTokensForProfile(account.copyWith(servers: fresh), profileId: profileId);
|
|
}().catchError((Object error, StackTrace stackTrace) {
|
|
appLogger.w(
|
|
'ActiveProfileBinder: background reconcile failed for $profileLabel',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<_ProfileBindResult> _bindJellyfin(
|
|
JellyfinConnection conn, {
|
|
required String profileId,
|
|
required int generation,
|
|
}) async {
|
|
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
|
|
// 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
|
|
// the profile had no server.
|
|
if (ok || serverManager.authErrorServerIds.contains(conn.serverMachineId)) {
|
|
return _ProfileBindResult.visible({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 {
|
|
return _plexAuth ??= await PlexAuthService.create();
|
|
}
|
|
|
|
Future<bool> _shouldDeferInitialBind(Profile profile) async {
|
|
final shouldDefer = shouldDeferInitialBind;
|
|
if (shouldDefer == null) return false;
|
|
try {
|
|
return await shouldDefer(profile);
|
|
} catch (e, st) {
|
|
appLogger.w('ActiveProfileBinder: defer check failed; continuing with bind', error: e, stackTrace: st);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void _clearBoundServers() {
|
|
for (final serverId in serverManager.serverIds.toList()) {
|
|
serverManager.removeServer(ServerId(serverId));
|
|
}
|
|
multiServerProvider.setExpectedVisibleServerIds(<String>{});
|
|
multiServerProvider.setVisibleServerIds(<String>{});
|
|
}
|
|
|
|
void dispose() {
|
|
_bindGeneration++;
|
|
if (!_started) return;
|
|
activeProfile.removeListener(_onActiveProfileChanged);
|
|
_plexHomePreVerified.clear();
|
|
_userInitiatedActivations.clear();
|
|
_lastFailedProfileId = null;
|
|
_plexAuth?.dispose();
|
|
_plexAuth = null;
|
|
_started = false;
|
|
}
|
|
}
|