From b96d734e8c876fa6ed74cd321823851be53a3fe9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 11 May 2026 11:05:42 +0200 Subject: [PATCH] fix(jellyfin): load collections from boxsets root close #1006 --- lib/media/media_server_client.dart | 2 +- .../tabs/library_collections_tab.dart | 4 +- .../jellyfin_client/parts/collections.dart | 18 ++++- test/services/jellyfin_client_urls_test.dart | 79 +++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 80c0c0b0..753447d8 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -284,7 +284,7 @@ abstract class MediaServerClient { Future removeFromPlaylist({required String playlistId, required MediaItem item}); /// Collections in [libraryId]. Plex hits `/library/sections/{id}/collections`; - /// Jellyfin queries `/Items?ParentId={libraryId}&IncludeItemTypes=BoxSet`. + /// Jellyfin resolves its top-level `boxsets` view and queries that root. /// Each result carries `kind == MediaKind.collection`. Future> fetchCollections(String libraryId); diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index f0f67b4c..d9ed1318 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -8,8 +8,8 @@ import '../adaptive_media_grid.dart'; import 'base_library_tab.dart'; import 'library_grid_tab_state.dart'; -/// Collections tab for library screen -/// Shows collections for the current library +/// Collections tab for library screen. +/// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root. class LibraryCollectionsTab extends BaseLibraryTab { const LibraryCollectionsTab({ super.key, diff --git a/lib/services/jellyfin_client/parts/collections.dart b/lib/services/jellyfin_client/parts/collections.dart index d47490f6..5e65bd9a 100644 --- a/lib/services/jellyfin_client/parts/collections.dart +++ b/lib/services/jellyfin_client/parts/collections.dart @@ -8,13 +8,18 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { @override Future> fetchCollections(String libraryId) async { + // Jellyfin keeps BoxSets under a dedicated top-level view, not under each + // movie/show library. Query that root to avoid recursively scanning media. + final boxSetsViewId = await _fetchBoxSetsViewId(); final response = await _http.get( '/Items', queryParameters: { 'userId': connection.userId, - 'ParentId': libraryId, + 'ParentId': ?boxSetsViewId, 'IncludeItemTypes': 'BoxSet', 'Recursive': 'true', + 'SortBy': 'SortName', + 'SortOrder': 'Ascending', 'Fields': _browseFields, ...jellyfinImageQueryParameters, }, @@ -23,6 +28,17 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { return _mapItems(_itemsArray(response.data)); } + Future _fetchBoxSetsViewId() async { + final response = await _http.get('/Users/${_segment(connection.userId)}/Views'); + throwIfHttpError(response); + for (final view in _itemsArray(response.data)) { + final collectionType = (view['CollectionType'] as String?)?.toLowerCase(); + final id = view['Id'] as String?; + if (collectionType == 'boxsets' && id != null && id.isNotEmpty) return id; + } + return null; + } + /// Jellyfin has no pagination knob for collection children, so the first /// call materialises the full list via [fetchChildren] and subsequent /// paged calls slice from the same in-memory copy ([_collectionItemsCache]). diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 280fa760..846dba0a 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -1134,6 +1134,85 @@ void main() { }); }); + group('JellyfinClient.fetchCollections', () { + test('uses boxsets view instead of selected media library parent', () async { + final requests = []; + final mock = MockClient((req) async { + requests.add(req.url); + if (req.url.path == '/Users/user-1/Views') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (req.url.path == '/Items') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + addTearDown(client.close); + + final collections = await client.fetchCollections('lib-movies'); + + expect(collections.map((c) => c.id).toList(), ['collection-1']); + expect(collections.single.kind, MediaKind.collection); + expect(requests.map((u) => u.path).toList(), ['/Users/user-1/Views', '/Items']); + final itemsRequest = requests.singleWhere((u) => u.path == '/Items'); + expect(itemsRequest.queryParameters['ParentId'], 'lib-boxsets'); + expect(itemsRequest.queryParameters['ParentId'], isNot('lib-movies')); + expect(itemsRequest.queryParameters['IncludeItemTypes'], 'BoxSet'); + expect(itemsRequest.queryParameters['Recursive'], 'true'); + expect(itemsRequest.queryParameters['SortBy'], 'SortName'); + expect(itemsRequest.queryParameters['SortOrder'], 'Ascending'); + }); + + test('falls back to global BoxSet query when boxsets view is missing', () async { + Uri? itemsRequest; + final mock = MockClient((req) async { + if (req.url.path == '/Users/user-1/Views') { + return http.Response( + jsonEncode({ + 'Items': [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (req.url.path == '/Items') { + itemsRequest = req.url; + return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + } + return http.Response('not found', 404); + }); + final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + addTearDown(client.close); + + await client.fetchCollections('lib-movies'); + + expect(itemsRequest, isNotNull); + expect(itemsRequest!.queryParameters.containsKey('ParentId'), isFalse); + expect(itemsRequest!.queryParameters['IncludeItemTypes'], 'BoxSet'); + expect(itemsRequest!.queryParameters['Recursive'], 'true'); + }); + }); + group('JellyfinClient.fetchLibraries view filtering', () { test('drops boxsets and playlists views — they surface as per-library tabs instead', () async { // Jellyfin's `/Users/{userId}/Views` returns the user's collection