import 'dart:async'; 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 Function(Profile profile, {String? errorMessage}); typedef ShouldDeferInitialBind = FutureOr Function(Profile profile); @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, PlexAuthService? plexAuth, }) : _plexAuth = 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; 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; /// 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 _plexHomePreVerified = {}; final Set _userInitiatedActivations = {}; bool get isSwitching => _isSwitching; @visibleForTesting String? get debugLastBoundProfileId => _lastBoundProfileId; void markPlexHomePreVerified(String profileId) { _plexHomePreVerified.add(profileId); } void markUserInitiatedActivation(String profileId) { _userInitiatedActivations.add(profileId); } @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; activeProfile.addListener(_onActiveProfileChanged); // Defer the first rebind: _runRebindOnce calls markBindingStarted → // notifyListeners on ActiveProfileProvider, and start() is invoked from // Provider's create callback during the build phase. // Notifying synchronously there re-enters the widget tree before this // provider's value has been assigned and crashes the inspector. scheduleMicrotask(() { if (!_started) 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) 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; 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 rebindActive() async { 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 rebindIfActive(String profileId) async { if (activeProfile.activeId != profileId) return; await rebindActive(); } Future _rebind() async { if (_isSwitching) return; _isSwitching = true; try { do { _pendingRebind = false; _pendingSameIdRebind = false; 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 { _isSwitching = false; } } Future _runRebindOnce() async { _bindingProfileId = activeProfile.activeId; activeProfile.markBindingStarted(); 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; } 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; } appLogger.i('ActiveProfileBinder: rebinding for ${profile.displayName} (${profile.id})'); final visibleServerIds = {}; final localProfileHasJoinRows = profile.isLocal && (await profileConnections.listForProfile(profile.id)).isNotEmpty; if (profile.isPlexHome) { visibleServerIds.addAll(await _bindPlexHome(profile)); } // 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. visibleServerIds.addAll(await _bindJoinRows(profile)); // 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); } } 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; } else if (_lastBoundProfileId == attemptedProfileId) { _lastBoundProfileId = null; } activeProfile.markBindingFinished(success: success); _bindingProfileId = null; } } Future> _bindPlexHome(Profile profile) 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 {}; } final account = await connections.getPlexAccount(parentId); if (account == null) { appLogger.w('ActiveProfileBinder: parent connection $parentId for ${profile.displayName} not found'); return const {}; } final auth = await _ensureAuth(); // 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 useCache = shouldUsePlexHomeTokenCache(preVerified: preVerified, hasBoundOnce: _hasBoundOnce); String? cachedToken; if (useCache) { final pc = await profileConnections.get(profile.id, parentId); 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"}', ); if (cachedToken != null) { try { final servers = await auth.fetchServers(cachedToken); if (servers.isNotEmpty) { appLogger.i('ActiveProfileBinder: using cached token for ${profile.displayName} (${servers.length} 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, ); return const {}; } } catch (e, st) { appLogger.w( 'ActiveProfileBinder: fetchServers failed with cached token for ${profile.displayName}', error: e, stackTrace: st, ); return const {}; } } 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, promptForPin: ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage), logLabel: profile.displayName, ); if (!result.succeeded) return const {}; // 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: /// 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> _bindJoinRows(Profile profile) async { final pcs = await profileConnections.listForProfile(profile.id); if (pcs.isEmpty) { if (profile.isLocal) { appLogger.w('ActiveProfileBinder: ${profile.displayName} has no connections'); } return const {}; } final all = await connections.list(); final byId = {for (final c in all) c.id: c}; final parentId = profile.parentConnectionId; final visible = {}; for (final pc in pcs) { if (parentId != null && pc.connectionId == parentId) continue; final conn = byId[pc.connectionId]; if (conn == null) { appLogger.w('ActiveProfileBinder: missing connection ${pc.connectionId} for ${profile.displayName}'); continue; } switch (conn) { case PlexAccountConnection(): visible.addAll(await _bindLocalPlexConnection(profile: profile, conn: conn, pc: pc)); case JellyfinConnection(): final id = await _bindJellyfin(conn); if (id != null) visible.add(id); } } return visible; } Future> _bindLocalPlexConnection({ required Profile profile, required PlexAccountConnection conn, required ProfileConnection pc, }) async { final auth = await _ensureAuth(); String? userToken = pc.userToken; List? servers; if (userToken != null && userToken.isNotEmpty) { try { servers = await auth.fetchServers(userToken); } 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); return const {}; } } catch (e, st) { appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st); return const {}; } } if (userToken == null || userToken.isEmpty) { if (pc.userIdentifier.isEmpty) { appLogger.w('ActiveProfileBinder: ${profile.displayName} has no Plex Home user identifier'); return const {}; } final minted = await _mintLocalPlexToken(auth: auth, profile: profile, conn: conn, pc: pc); if (minted == null) return const {}; userToken = minted; try { servers = await auth.fetchServers(userToken); } catch (e, st) { appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st); return const {}; } } final ids = await _connectFromServers(conn, userToken, servers ?? const [], profile.displayName); await profileConnections.markUsed(profile.id, conn.id); return ids; } Future _mintLocalPlexToken({ required PlexAuthService auth, required Profile profile, required PlexAccountConnection conn, required ProfileConnection pc, }) async { 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. requiresPin: false, promptForPin: ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage), logLabel: profile.displayName, ); if (!result.succeeded) return null; final userToken = result.userToken!; await profileConnections.recordToken(profile.id, conn.id, userToken); return userToken; } Future> _connectPlexServers(PlexAccountConnection account, String userToken, String profileLabel) async { final auth = await _ensureAuth(); final List servers; try { servers = await auth.fetchServers(userToken); } catch (e, st) { appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st); return const {}; } return _connectFromServers(account, userToken, servers, profileLabel); } Future> _connectFromServers( PlexAccountConnection account, String userToken, List servers, String profileLabel, ) async { if (servers.isEmpty) { appLogger.w('ActiveProfileBinder: no servers for $profileLabel on ${account.accountLabel}'); return const {}; } final updatedConn = account.copyWith(servers: servers); final boundIds = await serverManager.refreshTokensForProfile(updatedConn); appLogger.i('ActiveProfileBinder: bound ${boundIds.length}/${servers.length} Plex servers for $profileLabel'); // 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 boundIds; } Future _bindJellyfin(JellyfinConnection conn) async { final ok = await serverManager.addJellyfinConnection(conn); // `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 conn.serverMachineId; } return null; } Future _ensureAuth() async { return _plexAuth ??= await PlexAuthService.create(); } Future _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); } multiServerProvider.setVisibleServerIds({}); } void dispose() { if (!_started) return; activeProfile.removeListener(_onActiveProfileChanged); _plexHomePreVerified.clear(); _userInitiatedActivations.clear(); _plexAuth?.dispose(); _plexAuth = null; _started = false; } }