Files
plezy/lib/services/multi_server_manager.dart
T
edde746 436fd7d2d5 fix(desktop): hide the window before exit teardown and keep shutdown out of live UI
Closing the window now runs a graceful exit (so trackers get their
terminal scrobble), but the teardown kept the window on screen for
seconds while screens visibly emptied as servers disconnected.

Hide the window as the first step of exit teardown, bounded so a
stalled platform channel cannot hold an already-accepted exit open, and
cover every graceful-exit entry (window X, Cmd+Q, OS-initiated), not
just the close button. Add a terminal MultiServerManager.shutdown() that
closes the status streams before draining clients: the still-mounted
widget tree must never observe an "all servers gone" snapshot mid-exit,
which would flip the app into offline UI and dismantle screens (and
with it the teardown-driven AppLifecycleListener used-after-dispose
assert). A health result landing after shutdown is a silent no-op.

Supersedes the flag-based approach in #1999.
2026-08-18 19:38:30 +02:00

1480 lines
62 KiB
Dart

import 'dart:async';
import '../media/ids.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import '../connection/connection.dart';
import '../i18n/app_locale_utils.dart';
import '../media/media_server_client.dart';
import '../exceptions/media_server_exceptions.dart';
import 'jellyfin_client.dart';
import 'jellyfin_endpoint_discovery.dart';
import 'plex_client.dart';
import '../models/plex/plex_config.dart';
import '../utils/app_logger.dart';
import '../utils/media_server_timeouts.dart';
import '../utils/active_client_scope.dart';
import '../utils/future_extensions.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'plex_auth_service.dart';
import 'settings_service.dart';
import 'storage_service.dart';
typedef PlexClientFactory =
Future<PlexClient> Function(
PlexConfig config, {
required ServerId serverId,
required PlexProfileScopeId profileScopeId,
String? serverName,
List<String>? prioritizedEndpoints,
Future<void> Function(String newBaseUrl)? onEndpointChanged,
VoidCallback? onAllEndpointsExhausted,
bool? seedTranscoderVideoSupport,
});
bool _isMediaServerAuthFailure(Object error) =>
error is MediaServerAuthException ||
error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403);
/// Manages multiple media-server connections simultaneously.
///
/// The internal map and public accessors are typed against the
/// [MediaServerClient] interface so consumers don't depend on the concrete
/// backend. Onboarding helpers branch on backend (Plex `PlexServer`,
/// MediaBrowser `JellyfinConnection`) and instantiate the matching client.
class MultiServerManager {
MultiServerManager({
PlexClientFactory plexClientFactory = PlexClient.create,
Stream<List<ConnectivityResult>> Function()? connectivityChanges,
Duration connectivityDebounceDuration = const Duration(seconds: 2),
}) : this._(plexClientFactory, connectivityChanges ?? _defaultConnectivityChanges, connectivityDebounceDuration);
MultiServerManager._(this._plexClientFactory, this._connectivityChanges, this._connectivityDebounceDuration);
static Stream<List<ConnectivityResult>> _defaultConnectivityChanges() => Connectivity().onConnectivityChanged;
final PlexClientFactory _plexClientFactory;
final Stream<List<ConnectivityResult>> Function() _connectivityChanges;
final Duration _connectivityDebounceDuration;
FutureOr<void> Function(JellyfinConnection connection)? onJellyfinConnectionUpdated;
final Map<String, MediaServerClient> _clients = {};
final Map<String, PlexServer> _plexServers = {};
final Map<String, bool> _serverStatus = {};
/// Servers whose last health probe rejected the auth token (HTTP 401/403).
/// These rows also have `_serverStatus[serverId] == false` — auth errors are
/// a *kind* of offline. Surfaces through [authErrorServerIds] so UI can
/// show a "Sign in again" banner instead of a generic offline state.
final Set<String> _authErrorServers = {};
/// Stream controller for server status changes
final _statusController = StreamController<Map<String, bool>>.broadcast();
Stream<Map<String, bool>> get statusStream => _statusController.stream;
/// Publish a snapshot of the per-server online map — subscribers must never
/// receive the live [_serverStatus] instance. A no-op once [shutdown] or
/// [dispose] closed the controller, so a health probe or reconnect landing
/// mid-exit cannot throw into a closed stream.
void _emitStatus() {
if (_statusController.isClosed) return;
_statusController.add(Map.from(_serverStatus));
}
/// Per-server connect progress during a bind. Unlike [statusStream] — whose
/// first emission means "the binder's first connect pass finished" and which
/// triggers libraries/live-tv work per emission — this fires as each
/// individual server lands so the startup splash can flip its checkmarks
/// incrementally without disturbing those contracts.
final _connectProgressController = StreamController<({String serverId, bool online})>.broadcast();
Stream<({String serverId, bool online})> get connectProgressStream => _connectProgressController.stream;
/// Servers whose authentication has failed (token rejected). A re-auth flow
/// should be offered for these — they will remain "offline" until the user
/// signs in again. Cleared once a probe succeeds.
Set<String> get authErrorServerIds => Set.unmodifiable(_authErrorServers);
/// Connectivity subscription for network monitoring
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
/// Map of serverId to active optimization futures
final Map<String, Future<void>> _activeOptimizations = {};
/// Per-server clientIdentifier. Plex servers added via
/// [refreshTokensForProfile] register their owning account's
/// clientIdentifier here so reconnects + endpoint optimization use the
/// right identity (each account has its own device row on plex.tv).
final Map<String, String> _clientIdByServer = {};
final Map<String, PlexProfileScopeId> _plexScopeByServer = {};
String? _resolveClientIdentifier(ServerId serverId) => _clientIdByServer[serverId];
/// Record the Plex identity a server is bound under — the single writer for
/// all three per-server Plex registrations. A null [scope] (only
/// [markPlexConnectionAuthError], which has no profile yet) leaves any
/// previously recorded scope in place.
void _registerPlexServer(
String serverId,
PlexServer server, {
required String clientIdentifier,
PlexProfileScopeId? scope,
}) {
_clientIdByServer[serverId] = clientIdentifier;
_plexServers[serverId] = server;
if (scope != null) _plexScopeByServer[serverId] = scope;
}
/// Whether [compoundId] is still the client bound as the active user for
/// [machineId]. Async MediaBrowser work must re-check this before publishing
/// a result — a profile switch can rebind the machine mid-probe.
bool _isActiveJellyfin(String machineId, String compoundId) => _activeJellyfinMachine[machineId] == compoundId;
/// All MediaBrowser clients ever added, keyed by the compound connection id
/// (`{serverMachineId}/{userId}`). This lets users and dialects coexist
/// without tearing down another connection's in-flight operations. [_clients]
/// holds the currently "active" entry per machineId for consumers that pass
/// the public machine id as the server id.
///
/// These private members retain their Jellyfin-era names because one
/// [JellyfinClient] implements both the Jellyfin and Emby dialects.
final Map<String, JellyfinClient> _jellyfinByCompoundId = {};
final Map<String, String> _activeJellyfinMachine = {};
final Map<String, HealthStatus> _jellyfinHealthByCompoundId = {};
/// Debounce timers for endpoint-exhaustion-triggered reconnection (per server)
final Map<String, Timer> _reconnectDebounce = {};
/// Servers whose endpoint-exhaustion signal is being confirmed by an
/// auth-required health probe. Exhaustion callbacks raised by that probe
/// are ignored so a failed confirmation cannot recursively schedule itself.
final Set<String> _endpointHealthChecks = {};
/// Coalescing guard for checkServerHealth — prevents concurrent health checks
Future<void>? _activeHealthCheck;
/// Coalescing guard for reconnectOfflineServers — prevents concurrent reconnect sweeps
Future<void>? _activeReconnect;
int _profileRefreshEpoch = 0;
final Map<String, int> _profileRefreshGenerations = {};
/// Debounce timer for connectivity events — collapses rapid network flapping
Timer? _connectivityDebounce;
/// Per-server relay-escape probe timers and attempt counts
/// (see [_syncRelayEscape]).
final Map<String, Timer> _relayEscapeTimers = {};
final Map<String, int> _relayEscapeAttempts = {};
static const _relayEscapeBaseDelay = Duration(seconds: 30);
/// Get all registered server IDs (Plex + MediaBrowser).
///
/// Sourced from [_clients] rather than [_plexServers] because
/// [_plexServers] only holds the Plex-specific [PlexServer] structs
/// (host/port metadata used for connection-racing). MediaBrowser connections
/// are registered as clients only — falling back to [_plexServers] would
/// silently exclude them and callers (the active-profile binder, library
/// refresh gates) would behave as if the manager were empty for
/// MediaBrowser-only profiles.
List<String> get serverIds => _clients.keys.toList();
List<String> get onlineServerIds => _serverStatus.entries.where((e) => e.value).map((e) => e.key).toList();
List<String> get offlineServerIds => _serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList();
/// Get client for specific server.
MediaServerClient? getClient(ServerId serverId) => _clients[serverId];
/// Resolve an exact private client namespace without falling back to a
/// different active user on the same public server.
MediaServerClient? getClientByScope(String clientScopeId) {
final jellyfin = getJellyfinClientByCompoundId(clientScopeId);
if (jellyfin != null) return jellyfin;
final plexScope = PlexProfileScopeId.tryParse(clientScopeId);
if (plexScope == null || _plexScopeByServer[plexScope.publicServerId] != plexScope) return null;
final client = _clients[plexScope.publicServerId];
return client is PlexClient && client.profileScopeId == plexScope ? client : null;
}
/// Server ids visible to the active profile; `null` means no restriction.
/// Owned here rather than on `MultiServerProvider` so non-UI consumers
/// (the download client resolver) apply the same filter the UI does —
/// the provider delegates its filter state to this field.
Set<String>? _visibleServerIds;
Set<String>? get visibleServerIds => _visibleServerIds;
void setVisibleServerIds(Set<String>? ids) => _visibleServerIds = ids;
bool isServerVisible(ServerId serverId) => _visibleServerIds?.contains(serverId) ?? true;
/// Resolve the client for a queued download. A supplied private namespace
/// must match exactly; falling back to another active user would run work
/// under the wrong authenticated identity.
MediaServerClient? resolveDownloadClient(ServerId serverId, {String? clientScopeId}) {
if (!isServerVisible(serverId)) return null;
if (clientScopeId != null && clientScopeId.isNotEmpty) {
final scoped = getClientByScope(clientScopeId);
return scoped?.serverId == serverId ? scoped : null;
}
return getClient(serverId);
}
/// Get the [PlexClient] for a server, or `null` if the server uses the
/// MediaBrowser API (or is not registered). Use for Plex-only flows that
/// don't yet have a backend-neutral equivalent on [MediaServerClient].
PlexClient? getPlexClient(ServerId serverId) {
final client = _clients[serverId];
return client is PlexClient ? client : null;
}
void updatePlexLanguage(String languageCode) {
for (final client in _clients.values) {
if (client is PlexClient) {
client.applyLanguageUpdate(languageCode);
}
}
}
String? get _currentPlexLanguageCode =>
SettingsService.instanceOrNull?.read(SettingsService.appLocale).plexLanguageCode;
@visibleForTesting
void debugRegisterJellyfinClientForTesting(JellyfinClient client, {bool online = true}) {
_wireJellyfinConnectionUpdates(client);
final compoundId = client.connection.id;
final machineId = client.connection.serverMachineId;
_jellyfinByCompoundId[compoundId] = client;
_jellyfinHealthByCompoundId[compoundId] = online ? HealthStatus.online : HealthStatus.offline;
_clients[machineId] = client;
_activeJellyfinMachine[machineId] = compoundId;
_serverStatus[machineId] = online;
}
@visibleForTesting
void debugRegisterClientForTesting(MediaServerClient client, {bool online = true}) {
_clients[client.serverId] = client;
_serverStatus[client.serverId] = online;
if (client is PlexClient) _plexScopeByServer[client.serverId] = client.profileScopeId;
}
@visibleForTesting
void debugMarkAuthErrorForTesting(ServerId serverId) {
_serverStatus[serverId] = false;
_authErrorServers.add(serverId);
_emitStatus();
}
/// Mark every cached Plex server on [connection] as auth-rejected without
/// requiring a live client. Startup auth failures happen before a client can
/// exist, but the UI still needs a server id/name for the re-auth banner.
void markPlexConnectionAuthError(PlexAccountConnection connection) {
for (final server in connection.servers) {
final id = server.clientIdentifier;
_registerPlexServer(id, server, clientIdentifier: connection.clientIdentifier);
_serverStatus[id] = false;
_authErrorServers.add(id);
}
_emitStatus();
}
String serverDisplayName(ServerId serverId) =>
_clients[serverId]?.serverName ?? _plexServers[serverId]?.name ?? serverId;
/// Backend-neutral "is this user an owner/admin on [serverId]?" probe used
/// by UI gates that hide destructive admin entries (delete, edit metadata,
/// match/unmatch). Returns:
/// - Plex: `PlexServer.owned` for the server (the matching profile-level
/// `plexAdmin` check stays at the call site so it can fold in
/// `ActiveProfileProvider`).
/// - Jellyfin: `JellyfinConnection.isAdministrator` captured at sign-in.
/// - Unknown server: `false`.
bool isOwnerOrAdmin(ServerId serverId) {
final client = _clients[serverId];
if (client is PlexClient) {
return _plexServers[serverId]?.owned == true;
}
if (client is JellyfinClient) {
return client.connection.isAdministrator;
}
return false;
}
/// Get all online clients
Map<String, MediaServerClient> get onlineClients {
final result = <String, MediaServerClient>{};
for (final serverId in onlineServerIds) {
final client = _clients[serverId];
if (client != null) {
result[serverId] = client;
}
}
return result;
}
/// Check if a server is online
bool isServerOnline(ServerId serverId) => _serverStatus[serverId] ?? false;
/// Check whether the active or exact scoped client for [serverId] is online.
bool isClientOnline(ServerId serverId, {String? clientScopeId}) {
if (clientScopeId != null && clientScopeId.isNotEmpty) {
final client = getClientByScope(clientScopeId);
if (client == null || client.serverId != serverId) return false;
if (client is JellyfinClient) {
return _jellyfinHealthByCompoundId[clientScopeId] == HealthStatus.online;
}
}
return isServerOnline(serverId);
}
/// Creates and initializes a PlexClient for a given server
///
/// Handles finding working connection, loading cached endpoint,
/// creating config, and building client with failover support.
Future<PlexClient> _createClientForServer({
required PlexServer server,
required String clientIdentifier,
required PlexProfileScopeId profileScopeId,
}) async {
final serverId = server.clientIdentifier;
final stopwatch = Stopwatch()..start();
// Get storage and load cached endpoint for this server
final storage = await StorageService.getInstance();
final cachedEndpoint = storage.getServerEndpoint(ServerId(serverId));
// The connection race already hits `/` on the winning endpoint — capture
// `transcoderVideo` from that response so PlexClient.create can skip the
// redundant warm-up probe.
bool? observedTranscoderVideo;
// Find best working connection, passing cached endpoint for fast-path
final streamIterator = StreamIterator(
server.findBestWorkingConnection(
preferredUri: cachedEndpoint,
clientIdentifier: clientIdentifier,
onTranscoderCapability: (b) => observedTranscoderVideo = b,
),
);
if (!await streamIterator.moveNext()) {
throw Exception('No working connection found');
}
final workingConnection = streamIterator.current;
final baseUrl = workingConnection.uri;
final firstConnectionMs = stopwatch.elapsedMilliseconds;
// Create PlexClient with failover support
final prioritizedEndpoints = server.prioritizedEndpointUrls(preferredFirst: baseUrl);
final config = await PlexConfig.create(
baseUrl: baseUrl,
token: server.accessToken,
clientIdentifier: clientIdentifier,
languageCode: _currentPlexLanguageCode,
);
final client = await _plexClientFactory(
config,
serverId: ServerId(serverId),
profileScopeId: profileScopeId,
serverName: server.name,
prioritizedEndpoints: prioritizedEndpoints,
onEndpointChanged: (newUrl) async {
appLogger.i('Endpoint changed for ${server.name} after failover: $newUrl');
await _savePreferredEndpoint(ServerId(serverId), storage, newUrl, capturedServer: server);
_syncRelayEscape(ServerId(serverId));
},
onAllEndpointsExhausted: () => _onServerEndpointsExhausted(ServerId(serverId)),
seedTranscoderVideoSupport: observedTranscoderVideo,
);
// Save the initial endpoint (relay is refused — see _savePreferredEndpoint)
await _savePreferredEndpoint(ServerId(serverId), storage, baseUrl, capturedServer: server);
appLogger.i(
'Connected ${server.name}',
error: {
'uri': baseUrl,
'hadCachedEndpoint': cachedEndpoint != null,
'firstConnectionMs': firstConnectionMs,
'totalMs': stopwatch.elapsedMilliseconds,
},
);
// Drain remaining stream values in background to apply better connections
_drainOptimizationStream(streamIterator, client: client, server: server, storage: storage);
return client;
}
/// Persist [url] as [serverId]'s preferred endpoint unless it is a relay.
///
/// The preferred endpoint gets a deterministic head start at the next bind,
/// so persisting a relay URL would pin future sessions to plex.tv's
/// bandwidth-capped relay even after direct connectivity returns (#1974).
/// The client may still *use* a relay endpoint — only persistence is
/// refused; [_syncRelayEscape] owns getting off it. Classified against both
/// the live registered server and the connect-time [capturedServer]: after
/// a profile refresh rotates relay URIs only one of the two may still list
/// the URL, and a URL absent from a server's connection list falls through
/// to the custom-hostname classifier, which cannot recognize relays.
Future<void> _savePreferredEndpoint(
ServerId serverId,
StorageService storage,
String url, {
PlexServer? capturedServer,
}) async {
bool isRelay(PlexServer? server) => server?.networkClassForUrl(url) == PlexNetworkClass.relay;
if (isRelay(_plexServers[serverId]) || isRelay(capturedServer)) {
appLogger.d('Refusing to persist relay endpoint as preferred for $serverId');
return;
}
await storage.saveServerEndpoint(serverId, url);
}
/// Persists a new endpoint, rebuilds the failover list, and switches the
/// client only while it is still the registered client for this server.
Future<bool> _promoteEndpoint({
required PlexClient client,
required PlexServer server,
required StorageService storage,
required String newUrl,
}) async {
final serverId = ServerId(server.clientIdentifier);
bool isCurrent() {
final registered = _clients[serverId];
return identical(_plexServers[serverId], server) && (registered == null || identical(registered, client));
}
if (!isCurrent()) return false;
await _savePreferredEndpoint(serverId, storage, newUrl, capturedServer: server);
if (!isCurrent()) return false;
final newEndpoints = server.prioritizedEndpointUrls(preferredFirst: newUrl);
await client.updateEndpointPreferences(newEndpoints, switchToFirst: true);
final current = isCurrent();
if (current) _syncRelayEscape(serverId);
return current;
}
/// Continues draining the connection optimization stream in the background,
/// switching the client to any better endpoint found.
void _drainOptimizationStream(
StreamIterator<PlexConnection> streamIterator, {
required PlexClient client,
required PlexServer server,
required StorageService storage,
}) {
() async {
try {
while (await streamIterator.moveNext()) {
final serverId = ServerId(server.clientIdentifier);
final registered = _clients[serverId];
if (!identical(_plexServers[serverId], server) || (registered != null && !identical(registered, client))) {
appLogger.d('Stopping stale endpoint optimization for ${server.name}');
break;
}
final connection = streamIterator.current;
final newUrl = connection.uri;
if (newUrl == client.config.baseUrl) {
appLogger.d('Background optimization confirmed current endpoint for ${server.name}');
continue;
}
appLogger.i(
'Background optimization found better endpoint for ${server.name}',
error: {'from': client.config.baseUrl, 'to': newUrl, 'type': connection.displayType},
);
await _promoteEndpoint(client: client, server: server, storage: storage, newUrl: newUrl);
}
} catch (e, stackTrace) {
appLogger.w('Background connection optimization failed for ${server.name}', error: e, stackTrace: stackTrace);
} finally {
await streamIterator.cancel();
}
}();
}
/// Remove a server connection
void removeServer(ServerId serverId) {
final jellyfinCompoundIds = _jellyfinByCompoundId.entries
.where((entry) => entry.value.connection.serverMachineId == serverId)
.map((entry) => entry.key)
.toList();
final activeClient = _forgetServer(serverId);
if (jellyfinCompoundIds.isNotEmpty) {
final closed = <JellyfinClient>{};
for (final compoundId in jellyfinCompoundIds) {
final client = _jellyfinByCompoundId.remove(compoundId);
_jellyfinHealthByCompoundId.remove(compoundId);
if (client != null && closed.add(client)) {
unawaited(_closeClientGracefully(client));
}
}
} else if (activeClient != null) {
// Jellyfin's clients were all closed above.
unawaited(_closeClientGracefully(activeClient));
}
_emitStatus();
appLogger.i('Removed server: $serverId');
}
/// Drop every registration keyed by [serverId], cancel its pending exhaustion
/// retry, and return the client that was bound (the caller closes it). The
/// single teardown for both removal paths, so they cannot drift apart again.
/// The in-flight guards ([_activeOptimizations], [_endpointHealthChecks]) are
/// deliberately left alone — they are owned by the futures that set them.
MediaServerClient? _forgetServer(String serverId) {
_reconnectDebounce.remove(serverId)?.cancel();
_stopRelayEscape(serverId);
final client = _clients.remove(serverId);
_activeJellyfinMachine.remove(serverId);
_plexServers.remove(serverId);
_clientIdByServer.remove(serverId);
_plexScopeByServer.remove(serverId);
_serverStatus.remove(serverId);
_authErrorServers.remove(serverId);
return client;
}
/// Close [client], draining in-flight requests when it supports it. Callers
/// that do not need to wait wrap the call in `unawaited(...)`.
Future<void> _closeClientGracefully(
MediaServerClient client, {
Duration drainTimeout = const Duration(seconds: 2),
}) async {
if (client case final GracefullyCloseable graceful) {
await graceful.closeGracefully(drainTimeout: drainTimeout);
} else {
client.close();
}
}
/// Apply a freshly-fetched [PlexAccountConnection] to the manager,
/// rotating per-server access tokens in place when possible.
///
/// Used by [ActiveProfileBinder] on profile switch: after Plex hands us
/// the new home-user-scoped per-server tokens, we swap the [PlexConfig]
/// on existing healthy [PlexClient]s instead of tearing them down and
/// reconnecting. Auth-error clients can also be reused because the failure
/// was the old token; other offline servers fall through to the standard
/// [_createClientForServer] path so they get a fresh handshake.
///
/// Returns the [clientIdentifier]s that ended up actually bound (token
/// reused or freshly connected). Failed servers are excluded so the
/// caller's visibility filter doesn't surface unreachable servers.
Future<Set<String>> refreshTokensForProfile(
PlexAccountConnection connection, {
required String profileId,
Duration timeout = MediaServerTimeouts.perServerConnect,
}) async {
final accountId = connection.id;
final epoch = _profileRefreshEpoch;
final generation = (_profileRefreshGenerations[accountId] ?? 0) + 1;
_profileRefreshGenerations[accountId] = generation;
bool isStale() => epoch != _profileRefreshEpoch || _profileRefreshGenerations[accountId] != generation;
if (connection.servers.isEmpty) {
if (!isStale()) _profileRefreshGenerations.remove(accountId);
return const {};
}
final bound = <String>{};
final futures = connection.servers.map((server) async {
final serverId = server.clientIdentifier;
final profileScopeId = buildPlexProfileScopeId(serverId: ServerId(serverId), profileId: profileId);
final existing = _clients[serverId];
if (existing is PlexClient && ((_serverStatus[serverId] ?? false) || _authErrorServers.contains(serverId))) {
try {
final applied = await existing.applyProfileUpdate(
newToken: server.accessToken,
newProfileScopeId: profileScopeId,
);
if (!applied || isStale() || !identical(_clients[serverId], existing)) return;
_registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId);
_authErrorServers.remove(serverId);
_serverStatus[serverId] = true;
bound.add(serverId);
_connectProgressController.add((serverId: serverId, online: true));
} catch (e, stackTrace) {
if (isStale() || !identical(_clients[serverId], existing)) return;
appLogger.e('refreshTokensForProfile: failed to refresh ${server.name}', error: e, stackTrace: stackTrace);
_serverStatus[serverId] = false;
if (_isMediaServerAuthFailure(e)) _authErrorServers.add(serverId);
_connectProgressController.add((serverId: serverId, online: false));
}
return;
}
_registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId);
try {
final client = await _createClientForServer(
server: server,
clientIdentifier: connection.clientIdentifier,
profileScopeId: profileScopeId,
).namedTimeout(timeout, operation: 'connect to ${server.name}');
if (isStale() || !identical(_plexServers[serverId], server)) {
unawaited(_closeClientGracefully(client));
return;
}
final oldClient = _clients[serverId];
if (oldClient != null) unawaited(_closeClientGracefully(oldClient));
_clients[serverId] = client;
_serverStatus[serverId] = true;
_authErrorServers.remove(serverId);
bound.add(serverId);
_syncRelayEscape(ServerId(serverId));
_connectProgressController.add((serverId: serverId, online: true));
} catch (e, stackTrace) {
if (isStale() || !identical(_plexServers[serverId], server)) return;
appLogger.e('refreshTokensForProfile: failed to connect ${server.name}', error: e, stackTrace: stackTrace);
_serverStatus[serverId] = false;
if (_isMediaServerAuthFailure(e)) _authErrorServers.add(serverId);
_connectProgressController.add((serverId: serverId, online: false));
}
});
await Future.wait(futures);
if (isStale()) return const {};
_emitStatus();
if (bound.isNotEmpty && _connectivitySubscription == null) {
_startNetworkMonitoring();
}
_profileRefreshGenerations.remove(accountId);
return bound;
}
/// Add a MediaBrowser server backed by an authenticated
/// [JellyfinConnection]. Returns true on success.
///
/// When a live client already exists for the same compound id and the
/// connection is equivalent (see [canReuseJellyfinClient]), that client is
/// reused instead of recreated — profile rebinds re-add unchanged
/// connections routinely, and tearing the client down would abort its
/// in-flight requests. A material change (dialect, token, deviceId, URL set)
/// still replaces the client. This mirrors the Plex rebind path, where
/// [refreshTokensForProfile] reuses the online client via an in-place
/// token update.
///
/// MediaBrowser clients use the shared endpoint-racing flow when multiple
/// URLs are configured, then instantiate the client against the
/// lowest-latency reachable URL.
///
/// Two users on the same MediaBrowser server are tracked separately in
/// [_jellyfinByCompoundId]; only one is "active" per machineId at a time.
/// Adding the second user's connection doesn't close the first user's
/// client (preserves any in-flight operations on the prior profile).
Future<bool> addJellyfinConnection(JellyfinConnection connection) async {
try {
// Every close path detaches the client from [_jellyfinByCompoundId]
// before closing it, so a client found here is never mid-close.
final existing = _jellyfinByCompoundId[connection.id];
if (existing != null && canReuseJellyfinClient(live: existing.connection, incoming: connection)) {
return _reuseJellyfinClient(existing);
}
var resolvedConnection = connection;
var endpointSelectionValidated = false;
if (connection.baseUrls.length > 1) {
try {
final endpoint = await JellyfinEndpointDiscovery(dialect: connection.dialect).raceEndpoints(
connection.baseUrls,
preferredUrl: connection.baseUrl,
expectedMachineId: connection.serverMachineId,
// Historic alternates are independent retry candidates, not one
// atomic user-entered group. Reconcile each probe outcome below
// instead of rejecting the whole stored connection.
baseUrlsToValidate: const [],
);
resolvedConnection = connection.copyWith(
baseUrl: endpoint.activeBaseUrl,
baseUrls: endpoint.reconcilePreviouslyStoredBaseUrls(connection.baseUrls),
serverName: endpoint.serverInfo.serverName,
);
endpointSelectionValidated = true;
} catch (e, st) {
appLogger.w(
'${connection.dialect.productName} endpoint race failed; using only the stored active endpoint',
error: e.runtimeType,
stackTrace: st,
);
resolvedConnection = connection.copyWith(baseUrl: connection.baseUrl, baseUrls: [connection.baseUrl]);
}
}
final exhaustedMachineId = resolvedConnection.serverMachineId;
final exhaustedCompoundId = resolvedConnection.id;
final client = await JellyfinClient.create(
resolvedConnection,
onAllEndpointsExhausted: () => _onJellyfinEndpointsExhausted(exhaustedMachineId, exhaustedCompoundId),
);
// Admin status can change server-side; re-broadcast and persist so
// admin-gated UI survives app restarts without requiring re-auth.
_wireJellyfinConnectionUpdates(
client,
baseUrlsForPersistence: endpointSelectionValidated ? null : connection.baseUrls,
);
if (endpointSelectionValidated &&
(resolvedConnection.baseUrl != connection.baseUrl ||
!listEquals(resolvedConnection.baseUrls, connection.baseUrls))) {
try {
await onJellyfinConnectionUpdated?.call(resolvedConnection);
} catch (e, st) {
// Persistence failure does not alter the already-reconciled
// in-memory client.
appLogger.w('Failed to persist reconciled Jellyfin endpoints', error: e.runtimeType, stackTrace: st);
}
}
final compoundId = resolvedConnection.id;
final machineId = resolvedConnection.serverMachineId;
// Replace the prior client for this compound id — reaching here means
// the connection materially changed (token refresh, URL-set edit); an
// unchanged re-add was already handled by the reuse branch above.
final oldClient = _jellyfinByCompoundId[compoundId];
if (oldClient != null) unawaited(_closeClientGracefully(oldClient));
_jellyfinByCompoundId[compoundId] = client;
// Bind this user as the active client for its machine. A previously
// active client for a *different* compound id stays alive in
// [_jellyfinByCompoundId] so a future profile switch can re-bind it.
_clients[machineId] = client;
_activeJellyfinMachine[machineId] = compoundId;
final health = await client.checkHealth();
final healthy = health == HealthStatus.online;
_jellyfinHealthByCompoundId[compoundId] = health;
_applyHealth(ServerId(machineId), health);
appLogger.i(
'Added ${resolvedConnection.dialect.productName} server: '
'${resolvedConnection.serverName}${healthy ? '' : ' (unhealthy)'}',
);
if (_connectivitySubscription == null && healthy) {
_startNetworkMonitoring();
}
return healthy;
} catch (e, stackTrace) {
appLogger.e(
'Failed to add ${connection.dialect.productName} server ${connection.serverName}',
error: e,
stackTrace: stackTrace,
);
return false;
}
}
/// Whether the live client bound to [live] can serve [incoming] without
/// being recreated. Recreation is required when a field baked into the
/// client at construction time changes:
/// - `dialect` controls route and capability behavior;
/// - `accessToken` / `deviceId` are embedded in the auth headers when the
/// HTTP client is built;
/// - `baseUrls` fixes the failover candidate set. Compared as a set: both
/// the client and the add-path endpoint race reorder the list as
/// endpoints are promoted, so ordering drifts on an unchanged server.
///
/// Everything else is deliberately ignored: the active `baseUrl` drifts as
/// the client rotates endpoints, `isAdministrator` self-refreshes on health
/// checks, and the remaining fields are display metadata. `userId` and
/// `serverMachineId` equality is implied by the compound-id lookup that
/// precedes this check.
@visibleForTesting
static bool canReuseJellyfinClient({required JellyfinConnection live, required JellyfinConnection incoming}) {
return live.dialect == incoming.dialect &&
live.accessToken == incoming.accessToken &&
live.deviceId == incoming.deviceId &&
setEquals(live.baseUrls.toSet(), incoming.baseUrls.toSet());
}
/// Re-add of an unchanged connection: keep the live client (preserving its
/// in-flight requests and settled endpoint choice), re-bind it as the
/// machine's active user, and run a fresh health probe so callers still
/// get a current result. Skips the endpoint race ([JellyfinClient] has
/// per-request failover plus exhaustion-triggered reconnect), the
/// connection-update wiring (already attached when the client was first
/// added), and the connection persist (the client persists its own
/// endpoint rotations).
Future<bool> _reuseJellyfinClient(JellyfinClient client) async {
final compoundId = client.connection.id;
final machineId = client.connection.serverMachineId;
final rebound = !_isActiveJellyfin(machineId, compoundId);
_clients[machineId] = client;
_activeJellyfinMachine[machineId] = compoundId;
final health = await client.checkHealth();
_jellyfinHealthByCompoundId[compoundId] = health;
if (!_isActiveJellyfin(machineId, compoundId)) {
// A concurrent remove/re-add won while the probe was in flight.
appLogger.d('Ignoring stale Jellyfin reuse result for ${client.connection.serverName}');
return health == HealthStatus.online;
}
_applyHealth(ServerId(machineId), health);
if (rebound) {
// The machine's active user changed even if its online status didn't;
// client-map consumers need to observe the swap.
_emitStatus();
}
final healthy = health == HealthStatus.online;
appLogger.i(
'Reusing existing Jellyfin client for ${client.connection.serverName}'
'${healthy ? '' : ' (unhealthy)'} (connection unchanged)',
);
if (_connectivitySubscription == null && healthy) {
_startNetworkMonitoring();
}
return healthy;
}
void _wireJellyfinConnectionUpdates(JellyfinClient client, {List<String>? baseUrlsForPersistence}) {
client.onConnectionUpdated = (updated) async {
if (_jellyfinByCompoundId[updated.id] != client) {
appLogger.d('Ignoring stale Jellyfin connection update for ${updated.serverName}');
return;
}
final persist = onJellyfinConnectionUpdated;
if (persist != null) {
try {
final connectionToPersist = baseUrlsForPersistence == null
? updated
: updated.copyWith(baseUrls: baseUrlsForPersistence);
await Future.sync(() => persist(connectionToPersist));
} catch (e, st) {
appLogger.w('Failed to persist Jellyfin connection update', error: e, stackTrace: st);
}
}
_emitStatus();
};
}
/// Look up a tracked Jellyfin client by its compound id
/// (`{serverMachineId}/{userId}`). Returns `null` if no Jellyfin
/// connection with that id has been added. Useful for callers that need
/// the *specific* user's client, not whichever is currently active for
/// the machine.
JellyfinClient? getJellyfinClientByCompoundId(String compoundId) => _jellyfinByCompoundId[compoundId];
/// Tear down a specific Jellyfin user's client. If it was the active one
/// for its machine, the machine slot is cleared.
void removeJellyfinConnection(JellyfinConnection connection) {
final compoundId = connection.id;
final machineId = connection.serverMachineId;
final client = _jellyfinByCompoundId.remove(compoundId);
_jellyfinHealthByCompoundId.remove(compoundId);
if (client != null) unawaited(_closeClientGracefully(client));
if (_isActiveJellyfin(machineId, compoundId)) {
_forgetServer(machineId);
_emitStatus();
}
}
/// Update server status (used for health monitoring).
///
/// Clears the auth-error flag — callers that observed an auth failure
/// should use [_applyHealth] instead.
void updateServerStatus(ServerId serverId, bool isOnline) =>
_applyHealth(serverId, isOnline ? HealthStatus.online : HealthStatus.offline);
/// Apply a health-probe outcome to both online state and auth-error
/// tracking. Used by the manager's own health checks; external callers
/// without an auth-distinct signal should use [updateServerStatus].
void _applyHealth(ServerId serverId, HealthStatus status) {
final isOnline = status == HealthStatus.online;
final isAuthError = status == HealthStatus.authError;
final prevOnline = _serverStatus[serverId];
final hadAuthError = _authErrorServers.contains(serverId);
_serverStatus[serverId] = isOnline;
if (isAuthError) {
_authErrorServers.add(serverId);
} else {
_authErrorServers.remove(serverId);
}
final changed = prevOnline != isOnline || hadAuthError != isAuthError;
if (changed) {
_emitStatus();
if (isAuthError) {
appLogger.w('Server $serverId auth rejected — token expired or revoked');
} else {
appLogger.d('Server $serverId status changed to: $isOnline');
}
}
}
/// Test connection health for all servers. The probe is backend-defined:
/// Plex hits `/identity`; MediaBrowser uses the dialect's current-user route.
/// Both are auth-required so a revoked token is reported as offline.
Future<void> checkServerHealth() async {
// Coalesce concurrent calls — return the in-flight future if one exists
if (_activeHealthCheck != null) return _activeHealthCheck!;
_activeHealthCheck = _doCheckServerHealth();
try {
await _activeHealthCheck;
} finally {
_activeHealthCheck = null;
}
}
Future<void> _doCheckServerHealth() async {
appLogger.d('Checking health for ${_clients.length} servers');
final healthChecks = _clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
final expectedJellyfinCompoundId = client is JellyfinClient ? client.connection.id : null;
final status = await client.checkHealth();
if (client is JellyfinClient) {
final compoundId = expectedJellyfinCompoundId ?? client.connection.id;
_jellyfinHealthByCompoundId[compoundId] = status;
if (!_isActiveJellyfin(serverId, compoundId)) {
appLogger.d('Ignoring stale Jellyfin health result for ${client.connection.serverName}');
return;
}
}
_applyHealth(ServerId(serverId), status);
if (status != HealthStatus.online) {
appLogger.w('Server $serverId health check failed: ${status.name}');
}
});
await Future.wait(healthChecks);
}
/// Start monitoring network connectivity for all servers
void _startNetworkMonitoring() {
if (_connectivitySubscription != null) {
appLogger.d('Network monitoring already active');
return;
}
appLogger.i('Starting network monitoring for all servers');
try {
_connectivitySubscription = _connectivityChanges().listen(
(results) {
final status = results.isNotEmpty ? results.first : ConnectivityResult.none;
if (status == ConnectivityResult.none) {
appLogger.w('Connectivity lost, pausing optimization until network returns');
return;
}
// Debounce rapid connectivity events (e.g. WiFi flapping) into a single trigger
_connectivityDebounce?.cancel();
_connectivityDebounce = Timer(_connectivityDebounceDuration, () {
_connectivityDebounce = null;
appLogger.d(
'Connectivity change detected, re-optimizing all servers',
error: {
'status': status.name,
'interfaces': results.map((r) => r.name).toList(),
'serverCount': _plexServers.length,
},
);
// Re-optimize all servers and re-probe offline ones
_reoptimizeAllServers(reason: 'connectivity:${status.name}');
checkServerHealth();
});
},
onError: (error, stackTrace) {
appLogger.w('Connectivity listener error', error: error, stackTrace: stackTrace);
},
);
} catch (e) {
appLogger.w('Connectivity monitoring unavailable', error: e);
}
}
/// Stop monitoring network connectivity
void _stopNetworkMonitoring() {
_connectivitySubscription?.cancel();
_connectivitySubscription = null;
_connectivityDebounce?.cancel();
_connectivityDebounce = null;
appLogger.i('Stopped network monitoring');
}
/// Run [taskBuilder] as the single in-flight optimize/reconnect task for
/// [serverId] — the sole owner of the [_activeOptimizations] invariant.
///
/// While an entry exists the builder is never invoked and a completed future
/// is returned, so a caller awaiting a batch never waits on work it did not
/// start. The registered future always clears its own entry. [timeout] bounds
/// the task, logging `<timeoutLabel> timed out for <serverId>` when it fires.
Future<void> _runServerTask(
String serverId,
Future<void> Function() taskBuilder, {
Duration? timeout,
String? timeoutLabel,
}) {
if (_activeOptimizations.containsKey(serverId)) return Future<void>.value();
var task = taskBuilder();
if (timeout != null) {
task = task.timeout(timeout, onTimeout: () => appLogger.d('$timeoutLabel timed out for $serverId'));
}
// Must not *return* the removed entry — whenComplete would then await this very future.
final registered = task.whenComplete(() {
_activeOptimizations.remove(serverId);
});
_activeOptimizations[serverId] = registered;
return registered;
}
/// Re-optimize all connected servers and attempt reconnection for offline ones
void _reoptimizeAllServers({required String reason}) {
for (final entry in _plexServers.entries) {
final serverId = entry.key;
final server = entry.value;
// Skip if optimization/reconnection already running for this server
if (_activeOptimizations.containsKey(serverId)) {
appLogger.d('Optimization already running for ${server.name}, skipping', error: {'reason': reason});
continue;
}
// Online servers get their endpoints re-raced; offline ones a full reconnect.
unawaited(
_runServerTask(
serverId,
() => isServerOnline(ServerId(serverId))
? _reoptimizeServer(serverId: ServerId(serverId), server: server, reason: reason)
: _reconnectServer(ServerId(serverId), server),
),
);
}
// Jellyfin re-probes offline servers here. Online clients keep their current
// endpoint and can still fail over per request through JellyfinClient.
for (final entry in _activeJellyfinMachine.entries) {
final serverId = entry.key;
if (isServerOnline(ServerId(serverId))) continue;
final client = _jellyfinByCompoundId[entry.value];
if (client == null) continue;
unawaited(_runServerTask(serverId, () => _reconnectJellyfinServer(serverId, client)));
}
}
/// Re-optimize connection for a specific server.
///
/// Today this only runs against Plex servers — the connection-racing logic
/// is built around [PlexServer.findBestWorkingConnection]. Non-Plex
/// clients short-circuit until a backend-agnostic equivalent lands.
Future<void> _reoptimizeServer({
required ServerId serverId,
required PlexServer server,
required String reason,
}) async {
final storage = await StorageService.getInstance();
final raw = _clients[serverId];
final client = raw is PlexClient ? raw : null;
if (raw != null && client == null) {
// Non-Plex client registered for this serverId — no Plex-style optimizer to run.
return;
}
final cachedEndpoint = storage.getServerEndpoint(serverId);
try {
appLogger.d('Starting connection optimization for ${server.name}', error: {'reason': reason});
await for (final connection in server.findBestWorkingConnection(
preferredUri: cachedEndpoint,
clientIdentifier: _resolveClientIdentifier(serverId),
)) {
final newUrl = connection.uri;
// Check if this is actually a better connection than current
if (client != null && client.config.baseUrl == newUrl) {
appLogger.d('Already using optimal endpoint for ${server.name}: $newUrl');
continue;
}
if (client != null) {
final promoted = await _promoteEndpoint(client: client, server: server, storage: storage, newUrl: newUrl);
if (!promoted) return;
appLogger.i('Switched ${server.name} to better endpoint: $newUrl', error: {'type': connection.displayType});
} else {
if (_plexServers[serverId] != server) return;
await _savePreferredEndpoint(serverId, storage, newUrl, capturedServer: server);
if (_plexServers[serverId] != server) return;
appLogger.i('Updated optimal endpoint for ${server.name}: $newUrl', error: {'type': connection.displayType});
}
}
} catch (e, stackTrace) {
appLogger.w('Connection optimization failed for ${server.name}', error: e, stackTrace: stackTrace);
}
}
/// Whether [serverId]'s registered client is currently talking to a Plex
/// relay endpoint.
bool _isOnRelay(ServerId serverId) {
final client = _clients[serverId];
final server = _plexServers[serverId];
return client is PlexClient &&
server != null &&
server.networkClassForUrl(client.config.baseUrl) == PlexNetworkClass.relay;
}
/// Reconcile the relay-escape prober with [serverId]'s current endpoint.
///
/// A session can land on relay legitimately (direct connectivity was down
/// at connect time) or transiently (a failover walked onto it). plex.tv
/// caps relay bandwidth, and the only other re-optimization trigger is a
/// connectivity event — which never fires on a stable network — so a relay
/// session would otherwise stay capped until restart. While the active
/// endpoint classifies as relay, re-race the candidates on a bounded
/// backoff; the phase-2 selector prefers any working direct endpoint, so
/// the first successful direct probe promotes away and stops the prober.
void _syncRelayEscape(ServerId serverId) {
if (!_isOnRelay(serverId)) {
_stopRelayEscape(serverId);
return;
}
if (_relayEscapeTimers.containsKey(serverId)) return;
final attempt = _relayEscapeAttempts[serverId] ?? 0;
final delay = _relayEscapeDelay(attempt);
appLogger.i(
'Connected via relay, scheduling direct-endpoint re-probe',
error: {'serverId': serverId, 'attempt': attempt, 'delaySeconds': delay.inSeconds},
);
_relayEscapeTimers[serverId] = Timer(delay, () {
_relayEscapeTimers.remove(serverId);
unawaited(_runRelayEscape(serverId));
});
}
void _stopRelayEscape(String serverId) {
_relayEscapeTimers.remove(serverId)?.cancel();
_relayEscapeAttempts.remove(serverId);
}
/// 30s, 60s, then every 120s — a returning direct endpoint is picked up
/// quickly without re-racing a genuinely relay-only server forever at a
/// tight cadence.
Duration _relayEscapeDelay(int attempt) => _relayEscapeBaseDelay * (1 << attempt.clamp(0, 2));
/// Whether a relay-escape re-probe is scheduled for [serverId].
@visibleForTesting
bool debugHasPendingRelayEscapeForTesting(ServerId serverId) => _relayEscapeTimers.containsKey(serverId);
/// Fire [serverId]'s pending relay-escape probe immediately instead of
/// waiting out its backoff — timers armed during a real-async bind are
/// unreachable from a test's fakeAsync zone.
@visibleForTesting
Future<void> debugFireRelayEscapeForTesting(ServerId serverId) {
_relayEscapeTimers.remove(serverId)?.cancel();
return _runRelayEscape(serverId);
}
Future<void> _runRelayEscape(ServerId serverId) async {
final server = _plexServers[serverId];
if (server == null || !_isOnRelay(serverId) || !isServerOnline(serverId)) {
// Gone, promoted away, or offline (the reconnect path owns offline
// servers and re-syncs on success).
_stopRelayEscape(serverId);
return;
}
_relayEscapeAttempts[serverId] = (_relayEscapeAttempts[serverId] ?? 0) + 1;
await _runServerTask(serverId, () => _reoptimizeServer(serverId: serverId, server: server, reason: 'relay-escape'));
// Still on relay (or the optimize slot was busy): keep probing.
_syncRelayEscape(serverId);
}
/// Attempt full reconnection for a single offline server
Future<void> _reconnectServer(ServerId serverId, PlexServer server) async {
final clientId = _resolveClientIdentifier(serverId);
if (clientId == null) {
appLogger.w('Cannot reconnect ${server.name}: no client identifier cached');
return;
}
final profileScopeId = _plexScopeByServer[serverId];
if (profileScopeId == null) {
appLogger.w('Cannot reconnect ${server.name}: no Plex profile scope cached');
return;
}
try {
appLogger.d('Attempting reconnection for ${server.name}');
final client = await _createClientForServer(
server: server,
clientIdentifier: clientId,
profileScopeId: profileScopeId,
);
if (!identical(_plexServers[serverId], server) ||
_resolveClientIdentifier(serverId) != clientId ||
_plexScopeByServer[serverId] != profileScopeId) {
unawaited(_closeClientGracefully(client));
appLogger.d('Ignoring stale reconnection result for ${server.name}');
return;
}
final oldClient = _clients[serverId];
if (oldClient != null) unawaited(_closeClientGracefully(oldClient));
_clients[serverId] = client;
_syncRelayEscape(serverId);
updateServerStatus(serverId, true);
appLogger.i('Successfully reconnected to ${server.name}');
} catch (e) {
appLogger.d('Reconnection failed for ${server.name}: $e');
// Leave status as offline — will retry on next trigger
}
}
/// Attempt reconnection for a single offline MediaBrowser server.
///
/// Reuse the existing [JellyfinClient], whose request-level failover owns its
/// endpoint set, and perform an authenticated health round-trip. On success,
/// flip the machine slot back to online so MediaServer-aware UI un-greys the
/// entry.
Future<void> _reconnectJellyfinServer(String machineId, JellyfinClient client) async {
final expectedCompoundId = client.connection.id;
try {
appLogger.d(
'Attempting reconnection for ${client.connection.dialect.productName} server '
'${client.connection.serverName}',
);
final status = await client.checkHealth();
_jellyfinHealthByCompoundId[expectedCompoundId] = status;
if (!_isActiveJellyfin(machineId, expectedCompoundId)) {
appLogger.d('Ignoring stale Jellyfin reconnection result for ${client.connection.serverName}');
return;
}
_applyHealth(ServerId(machineId), status);
if (status == HealthStatus.online) {
appLogger.i('Successfully reconnected to ${client.connection.serverName}');
} else {
appLogger.d('Reconnection probe for ${client.connection.serverName} returned ${status.name}');
}
} catch (e) {
appLogger.d('Reconnection failed for ${client.connection.serverName}: $e');
// Leave status as offline — will retry on next trigger
}
}
/// Attempt reconnection for all offline servers.
///
/// When [forceRediscovery] is true, the cached endpoint is cleared before
/// reconnecting so the fast-path is skipped and a full candidate race runs.
/// Used by the manual reconnect button when the cached URL may be stale
/// (e.g. after a network change while the app was backgrounded).
Future<void> reconnectOfflineServers({bool forceRediscovery = false}) async {
// Coalesce concurrent calls — return the in-flight future if one exists
if (_activeReconnect != null) return _activeReconnect!;
_activeReconnect = _doReconnectOfflineServers(forceRediscovery: forceRediscovery);
try {
await _activeReconnect;
} finally {
_activeReconnect = null;
}
}
Future<void> _doReconnectOfflineServers({required bool forceRediscovery}) async {
final offline = offlineServerIds;
if (offline.isEmpty) return;
appLogger.d('Attempting reconnection for ${offline.length} offline servers');
unawaited(
Sentry.addBreadcrumb(
Breadcrumb(message: 'Reconnecting ${offline.length} offline server(s)', category: 'servers'),
),
);
if (forceRediscovery) {
final storage = await StorageService.getInstance();
await Future.wait(offline.map((id) => storage.clearServerEndpoint(ServerId(id))));
}
final futures = offline.map((serverId) {
final server = _plexServers[serverId];
if (server != null) {
return _runServerTask(
serverId,
() => _reconnectServer(ServerId(serverId), server),
timeout: const Duration(seconds: 15),
timeoutLabel: 'Reconnection',
);
}
// Jellyfin offline path — no `_plexServers` entry, but the active
// [JellyfinClient] is keyed by machineId in `_clients` and tracked in
// `_activeJellyfinMachine`. Run the same auth probe used at add time.
final activeCompoundId = _activeJellyfinMachine[serverId];
final jellyfinClient = activeCompoundId != null ? _jellyfinByCompoundId[activeCompoundId] : null;
if (jellyfinClient == null) return Future<void>.value();
return _runServerTask(
serverId,
() => _reconnectJellyfinServer(serverId, jellyfinClient),
timeout: const Duration(seconds: 15),
timeoutLabel: 'Jellyfin reconnection',
);
});
await Future.wait(futures);
}
/// Called when all failover endpoints are exhausted for a server.
///
/// A content route timing out does not prove the server itself is offline.
/// Debounce parallel failures, then confirm with the backend's lightweight
/// auth-required health probe before publishing an offline transition.
void _onServerEndpointsExhausted(ServerId serverId) {
if (_endpointHealthChecks.contains(serverId)) return;
_reconnectDebounce[serverId]?.cancel();
_reconnectDebounce[serverId] = Timer(const Duration(seconds: 5), () {
_reconnectDebounce.remove(serverId);
unawaited(_verifyServerEndpointsExhausted(serverId));
});
}
/// Fire-and-forget safe: both concrete clients' `checkHealth` implementations
/// catch every failure and fold it into a [HealthStatus], and the scheduled
/// reconnection guards its own errors — this future must never complete with one.
Future<void> _verifyServerEndpointsExhausted(ServerId serverId) async {
final client = _clients[serverId];
if (client == null || !_endpointHealthChecks.add(serverId)) return;
try {
final health = await client.checkHealth();
if (!identical(_clients[serverId], client)) return;
if (client is JellyfinClient) {
_jellyfinHealthByCompoundId[client.connection.id] = health;
}
if (health == HealthStatus.online) {
_applyHealth(serverId, health);
appLogger.d('Endpoint exhaustion not confirmed for $serverId; health probe succeeded');
return;
}
_applyHealth(serverId, health);
if (health == HealthStatus.authError) return;
final plexServer = _plexServers[serverId];
final jellyfinClient = client is JellyfinClient ? client : null;
if (plexServer == null && jellyfinClient == null) return;
appLogger.i('Health probe confirmed $serverId offline, triggering reconnection');
unawaited(
_runServerTask(
serverId,
() => plexServer != null
? _reconnectServer(serverId, plexServer)
: _reconnectJellyfinServer(serverId, jellyfinClient!),
),
);
} finally {
_endpointHealthChecks.remove(serverId);
}
}
/// Jellyfin clients outlive their active binding (a previous profile's
/// client stays in [_jellyfinByCompoundId]); only the currently bound
/// client's exhaustion may verify and flip the machine's status.
void _onJellyfinEndpointsExhausted(String machineId, String compoundId) {
if (!_isActiveJellyfin(machineId, compoundId)) {
appLogger.d('Ignoring endpoint exhaustion from inactive Jellyfin client', error: compoundId);
return;
}
_onServerEndpointsExhausted(ServerId(machineId));
}
@visibleForTesting
Future<void> debugVerifyServerEndpointsExhaustedForTesting(ServerId serverId) =>
_verifyServerEndpointsExhausted(serverId);
/// Entry point matching production exhaustion wiring (debounce + the
/// in-flight-verification guard), for tests driving the full retry loop.
@visibleForTesting
void debugTriggerEndpointsExhaustedForTesting(ServerId serverId) => _onServerEndpointsExhausted(serverId);
/// Disconnect all servers, fire-and-forget.
///
/// Registrations are dropped synchronously ([_detachAllClients] runs before
/// the first await); only the socket drain is left running in the background.
void disconnectAll() {
unawaited(disconnectAllGracefully(drainTimeout: const Duration(seconds: 2)));
}
Future<void> disconnectAllGracefully({Duration drainTimeout = const Duration(seconds: 5)}) async {
appLogger.i('Gracefully disconnecting all servers');
final clients = _detachAllClients();
await Future.wait(
clients.map((client) => _closeClientGracefully(client, drainTimeout: drainTimeout)),
eagerError: false,
);
}
Set<MediaServerClient> _detachAllClients() {
++_profileRefreshEpoch;
_profileRefreshGenerations.clear();
_stopNetworkMonitoring();
for (final timer in _reconnectDebounce.values) {
timer.cancel();
}
_reconnectDebounce.clear();
for (final timer in _relayEscapeTimers.values) {
timer.cancel();
}
_relayEscapeTimers.clear();
_relayEscapeAttempts.clear();
_activeHealthCheck = null;
_activeReconnect = null;
final clients = <MediaServerClient>{..._clients.values, ..._jellyfinByCompoundId.values};
_clients.clear();
_jellyfinByCompoundId.clear();
_activeJellyfinMachine.clear();
_jellyfinHealthByCompoundId.clear();
_plexServers.clear();
_serverStatus.clear();
_authErrorServers.clear();
_clientIdByServer.clear();
_plexScopeByServer.clear();
_activeOptimizations.clear();
if (!_statusController.isClosed) {
_statusController.add({});
}
return clients;
}
/// Terminal app-exit teardown: closes the status streams first, then drains
/// every client connection.
///
/// Unlike [disconnectAllGracefully] — whose empty snapshot profile-switch
/// and disconnect flows must observe — exit teardown must stay invisible:
/// the widget tree is still mounted while the exit request is serviced, and
/// an empty snapshot reads as "all servers gone", flipping the app into
/// offline UI and dismantling screens mid-shutdown.
Future<void> shutdown({Duration drainTimeout = const Duration(seconds: 5)}) async {
if (!_statusController.isClosed) unawaited(_statusController.close());
if (!_connectProgressController.isClosed) unawaited(_connectProgressController.close());
await disconnectAllGracefully(drainTimeout: drainTimeout);
}
/// Dispose resources
void dispose() {
disconnectAll();
if (!_statusController.isClosed) {
_statusController.close();
}
if (!_connectProgressController.isClosed) {
_connectProgressController.close();
}
}
}