fix(jellyfin): prevent false offline loops
This commit is contained in:
@@ -1300,13 +1300,18 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
...jellyfinImageQueryParameters,
|
||||
}, retry: _libraryHubRetry);
|
||||
|
||||
// Music libraries get their own hub set (Latest Albums / Recently Played /
|
||||
// Most Played) — Resume and NextUp are video concepts and Jellyfin's
|
||||
// Resume endpoint is queried with MediaTypes=Video anyway. The branch
|
||||
// ignores [includePlaybackHubs]: the played rows never duplicate the
|
||||
// app-level Continue Watching shelf that flag exists to dedupe.
|
||||
// Music libraries get their own hub set. Home passes
|
||||
// includePlaybackHubs=false because it already renders the app-level
|
||||
// playback shelf; in that mode only fetch Latest Albums. Recently Played
|
||||
// and Most Played remain available on the library's Recommended tab.
|
||||
if (libraryKind == MediaKind.artist) {
|
||||
return _fetchMusicLibraryHubs(libraryId, libraryName: libraryName, limit: limit, latestFuture: latestFuture);
|
||||
return _fetchMusicLibraryHubs(
|
||||
libraryId,
|
||||
libraryName: libraryName,
|
||||
limit: limit,
|
||||
latestFuture: latestFuture,
|
||||
includePlaybackHubs: includePlaybackHubs,
|
||||
);
|
||||
}
|
||||
|
||||
if (!includePlaybackHubs) {
|
||||
@@ -1396,7 +1401,23 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
required String libraryName,
|
||||
required int limit,
|
||||
required Future<List<Map<String, dynamic>>> latestFuture,
|
||||
required bool includePlaybackHubs,
|
||||
}) async {
|
||||
if (!includePlaybackHubs) {
|
||||
final latest = await latestFuture;
|
||||
return [
|
||||
JellyfinMappers.syntheticHub(
|
||||
mapItem: _mapItem,
|
||||
identifier: 'library.$libraryId.recent',
|
||||
title: t.discover.latestAlbumsIn(library: libraryName),
|
||||
type: 'album',
|
||||
items: latest,
|
||||
previewLimit: limit,
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
),
|
||||
].where((hub) => hub.items.isNotEmpty).toList();
|
||||
}
|
||||
final playedParams = <String, String>{
|
||||
'userId': connection.userId,
|
||||
'ParentId': libraryId,
|
||||
|
||||
@@ -85,6 +85,11 @@ class MultiServerManager {
|
||||
/// 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;
|
||||
|
||||
@@ -1120,37 +1125,62 @@ class MultiServerManager {
|
||||
}
|
||||
|
||||
/// Called when all failover endpoints are exhausted for a server.
|
||||
/// Debounced per-server to prevent cascading reconnections from parallel failures.
|
||||
///
|
||||
/// 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) {
|
||||
// Cancel any existing debounce timer for this server
|
||||
_reconnectDebounce[serverId]?.cancel();
|
||||
if (_endpointHealthChecks.contains(serverId)) return;
|
||||
|
||||
_reconnectDebounce[serverId]?.cancel();
|
||||
_reconnectDebounce[serverId] = Timer(const Duration(seconds: 5), () {
|
||||
_reconnectDebounce.remove(serverId);
|
||||
unawaited(_verifyServerEndpointsExhausted(serverId));
|
||||
});
|
||||
}
|
||||
|
||||
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 jellyfinCompoundId = _activeJellyfinMachine[serverId];
|
||||
final jellyfinClient = jellyfinCompoundId != null ? _jellyfinByCompoundId[jellyfinCompoundId] : null;
|
||||
final jellyfinClient = client is JellyfinClient ? client : null;
|
||||
if (plexServer == null && jellyfinClient == null) return;
|
||||
|
||||
appLogger.i('All endpoints exhausted for $serverId, triggering reconnection');
|
||||
updateServerStatus(serverId, false);
|
||||
appLogger.i('Health probe confirmed $serverId offline, triggering reconnection');
|
||||
|
||||
// Guard with _activeOptimizations to prevent duplicate reconnections
|
||||
if (_activeOptimizations.containsKey(serverId)) return;
|
||||
|
||||
final reconnect = plexServer != null
|
||||
? _reconnectServer(serverId, plexServer)
|
||||
: _reconnectJellyfinServer(serverId, jellyfinClient!);
|
||||
_activeOptimizations[serverId] = reconnect.whenComplete(() {
|
||||
_activeOptimizations.remove(serverId);
|
||||
});
|
||||
});
|
||||
} 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 flip the machine's status.
|
||||
/// client's exhaustion may verify and 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);
|
||||
@@ -1159,6 +1189,10 @@ class MultiServerManager {
|
||||
_onServerEndpointsExhausted(ServerId(machineId));
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> debugVerifyServerEndpointsExhaustedForTesting(ServerId serverId) =>
|
||||
_verifyServerEndpointsExhausted(serverId);
|
||||
|
||||
/// Disconnect all servers
|
||||
void disconnectAll() {
|
||||
appLogger.i('Disconnecting all servers');
|
||||
|
||||
@@ -793,6 +793,60 @@ void main() {
|
||||
captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']),
|
||||
['movies', 'mv', 'home-vids', 'music'],
|
||||
);
|
||||
expect(
|
||||
captured.where((uri) => uri.path == '/Items' && uri.queryParameters['Filters'] == 'IsPlayed'),
|
||||
isEmpty,
|
||||
reason: 'the home screen excludes playback-derived music rows',
|
||||
);
|
||||
});
|
||||
|
||||
test('music library recommendations retain recently and most-played rows', () async {
|
||||
final captured = <Uri>[];
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
captured.add(req.url);
|
||||
if (req.url.path == '/Users/user-1/Items/Latest') {
|
||||
return _json([
|
||||
{'Id': 'album-1', 'Type': 'MusicAlbum', 'Name': 'Latest Album', 'ParentLibraryId': 'music'},
|
||||
]);
|
||||
}
|
||||
if (req.url.path == '/Items' && req.url.queryParameters['Filters'] == 'IsPlayed') {
|
||||
final sortBy = req.url.queryParameters['SortBy'];
|
||||
return _json({
|
||||
'Items': [
|
||||
{
|
||||
'Id': sortBy == 'DatePlayed' ? 'recent-track' : 'most-played-track',
|
||||
'Type': 'Audio',
|
||||
'Name': sortBy == 'DatePlayed' ? 'Recent Track' : 'Most Played Track',
|
||||
'ParentLibraryId': 'music',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return http.Response('unexpected request', 500);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final hubs = await client.fetchLibraryHubs(
|
||||
'music',
|
||||
libraryName: 'Music',
|
||||
libraryKind: MediaKind.artist,
|
||||
includePlaybackHubs: true,
|
||||
);
|
||||
|
||||
expect(hubs.map((hub) => hub.identifier), [
|
||||
'library.music.recent',
|
||||
'library.music.recentlyplayed',
|
||||
'library.music.mostplayed',
|
||||
]);
|
||||
expect(
|
||||
captured
|
||||
.where((uri) => uri.path == '/Items' && uri.queryParameters['Filters'] == 'IsPlayed')
|
||||
.map((uri) => uri.queryParameters['SortBy']),
|
||||
['DatePlayed', 'PlayCount'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Plex home layout keeps promoted hubs instead of splitting by preview libraries', () async {
|
||||
|
||||
@@ -148,6 +148,53 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('endpoint exhaustion verification', () {
|
||||
test('content-route exhaustion keeps an authenticated Jellyfin server online', () async {
|
||||
final manager = MultiServerManager();
|
||||
addTearDown(manager.dispose);
|
||||
final client = testJellyfinClient(
|
||||
connection: _jellyfinConnection('user-a'),
|
||||
handler: (_) async =>
|
||||
http.Response('{"Policy":{"IsAdministrator":false}}', 200, headers: {'content-type': 'application/json'}),
|
||||
);
|
||||
manager.debugRegisterJellyfinClientForTesting(client);
|
||||
|
||||
final emitted = <Map<String, bool>>[];
|
||||
final sub = manager.statusStream.listen(emitted.add);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await manager.debugVerifyServerEndpointsExhaustedForTesting(ServerId('jf-machine'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(manager.isServerOnline(ServerId('jf-machine')), isTrue);
|
||||
expect(manager.authErrorServerIds, isEmpty);
|
||||
expect(emitted, isEmpty, reason: 'a successful health probe must not publish a false offline transition');
|
||||
});
|
||||
|
||||
test('auth rejection is published without attempting generic reconnection', () async {
|
||||
final manager = MultiServerManager();
|
||||
addTearDown(manager.dispose);
|
||||
final client = testJellyfinClient(
|
||||
connection: _jellyfinConnection('user-a'),
|
||||
handler: (_) async => http.Response('', 401),
|
||||
);
|
||||
manager.debugRegisterJellyfinClientForTesting(client);
|
||||
|
||||
final emitted = <Map<String, bool>>[];
|
||||
final sub = manager.statusStream.listen(emitted.add);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await manager.debugVerifyServerEndpointsExhaustedForTesting(ServerId('jf-machine'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(manager.isServerOnline(ServerId('jf-machine')), isFalse);
|
||||
expect(manager.authErrorServerIds, {'jf-machine'});
|
||||
expect(emitted, [
|
||||
{'jf-machine': false},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('refreshTokensForProfile', () {
|
||||
test('successful in-place Plex token refresh clears auth-error state', () async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
|
||||
Reference in New Issue
Block a user