diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index b9f6fe35..4c0e98a3 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -106,6 +106,7 @@ class JellyfinClient static Future create( JellyfinConnection connection, { FavoriteChannelsRepository? favoritesRepository, + void Function()? onAllEndpointsExhausted, }) async { // Register before any HTTP traffic so the very first probe URL doesn't // leak the token verbatim. `LogRedactionManager.redact()` also has @@ -142,6 +143,7 @@ class JellyfinClient logLabel: 'Jellyfin', prioritizedEndpoints: connection.baseUrls, onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist), + onAllEndpointsExhausted: onAllEndpointsExhausted, ); client = JellyfinClient._(connection: connection, http: http, favoritesRepository: favoritesRepository); return client; @@ -154,6 +156,7 @@ class JellyfinClient required JellyfinConnection connection, required http.Client httpClient, FavoriteChannelsRepository? favoritesRepository, + void Function()? onAllEndpointsExhausted, }) { late JellyfinClient client; final mediaHttp = FailoverHttpClient( @@ -162,6 +165,7 @@ class JellyfinClient logLabel: 'Jellyfin', prioritizedEndpoints: connection.baseUrls, onEndpointSwitch: (newBaseUrl, {required persist}) => client._handleEndpointSwitch(newBaseUrl, persist: persist), + onAllEndpointsExhausted: onAllEndpointsExhausted, client: httpClient, ); client = JellyfinClient._(connection: connection, http: mediaHttp, favoritesRepository: favoritesRepository); diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index e63d5b10..ccae3227 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -575,7 +575,12 @@ class MultiServerManager { } } - final client = await JellyfinClient.create(resolvedConnection); + 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); @@ -1022,8 +1027,10 @@ class MultiServerManager { _reconnectDebounce[serverId] = Timer(const Duration(seconds: 5), () { _reconnectDebounce.remove(serverId); - final server = _plexServers[serverId]; - if (server == null) return; + final plexServer = _plexServers[serverId]; + final jellyfinCompoundId = _activeJellyfinMachine[serverId]; + final jellyfinClient = jellyfinCompoundId != null ? _jellyfinByCompoundId[jellyfinCompoundId] : null; + if (plexServer == null && jellyfinClient == null) return; appLogger.i('All endpoints exhausted for $serverId, triggering reconnection'); updateServerStatus(serverId, false); @@ -1031,12 +1038,26 @@ class MultiServerManager { // Guard with _activeOptimizations to prevent duplicate reconnections if (_activeOptimizations.containsKey(serverId)) return; - _activeOptimizations[serverId] = _reconnectServer(serverId, server).whenComplete(() { + final reconnect = plexServer != null + ? _reconnectServer(serverId, plexServer) + : _reconnectJellyfinServer(serverId, jellyfinClient!); + _activeOptimizations[serverId] = reconnect.whenComplete(() { _activeOptimizations.remove(serverId); }); }); } + /// Jellyfin clients outlive their active binding (a previous profile's + /// client stays in [_jellyfinByCompoundId]); only the currently bound + /// client's exhaustion may flip the machine's status. + void _onJellyfinEndpointsExhausted(String machineId, String compoundId) { + if (_activeJellyfinMachine[machineId] != compoundId) { + appLogger.d('Ignoring endpoint exhaustion from inactive Jellyfin client', error: compoundId); + return; + } + _onServerEndpointsExhausted(ServerId(machineId)); + } + /// Disconnect all servers void disconnectAll() { appLogger.i('Disconnecting all servers'); diff --git a/test/services/jellyfin_client_failures_test.dart b/test/services/jellyfin_client_failures_test.dart index 4eb6414c..d81a6bb6 100644 --- a/test/services/jellyfin_client_failures_test.dart +++ b/test/services/jellyfin_client_failures_test.dart @@ -165,6 +165,23 @@ void main() { expect(client.connection.baseUrls, ['https://fallback.example.com', 'https://primary.example.com']); }); + test('exhausting every endpoint fires onAllEndpointsExhausted', () async { + var exhausted = 0; + final client = JellyfinClient.forTesting( + connection: _conn( + baseUrl: 'https://primary.example.com', + baseUrls: const ['https://primary.example.com', 'https://fallback.example.com'], + ), + httpClient: MockClient((req) async => throw TimeoutException('endpoint down')), + onAllEndpointsExhausted: () => exhausted++, + ); + addTearDown(client.close); + + await client.getMachineIdentifier(); + + expect(exhausted, 1); + }); + test('resets live base URL after fallback endpoint is exhausted', () async { final requests = []; final client = JellyfinClient.forTesting(