diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index d5e6a3d3..f930a5be 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -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>> 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 = { 'userId': connection.userId, 'ParentId': libraryId, diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 075e6262..b3f6c2d9 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -85,6 +85,11 @@ class MultiServerManager { /// Debounce timers for endpoint-exhaustion-triggered reconnection (per server) final Map _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 _endpointHealthChecks = {}; + /// Coalescing guard for checkServerHealth — prevents concurrent health checks Future? _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 _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 debugVerifyServerEndpointsExhaustedForTesting(ServerId serverId) => + _verifyServerEndpointsExhausted(serverId); + /// Disconnect all servers void disconnectAll() { appLogger.i('Disconnecting all servers'); diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index ad40b6c6..bd35d960 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -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 = []; + 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 { diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index a2987f0d..d283246f 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -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 = >[]; + final sub = manager.statusStream.listen(emitted.add); + addTearDown(sub.cancel); + + await manager.debugVerifyServerEndpointsExhaustedForTesting(ServerId('jf-machine')); + await Future.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 = >[]; + final sub = manager.statusStream.listen(emitted.add); + addTearDown(sub.cancel); + + await manager.debugVerifyServerEndpointsExhaustedForTesting(ServerId('jf-machine')); + await Future.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());