fix(jellyfin): load collections from boxsets root

close #1006
This commit is contained in:
edde746
2026-05-11 11:11:22 +02:00
parent 89f256138d
commit b96d734e8c
4 changed files with 99 additions and 4 deletions
+1 -1
View File
@@ -284,7 +284,7 @@ abstract class MediaServerClient {
Future<bool> 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<List<MediaItem>> fetchCollections(String libraryId);
@@ -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<MediaItem> {
const LibraryCollectionsTab({
super.key,
@@ -8,13 +8,18 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
@override
Future<List<MediaItem>> 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<String?> _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]).
@@ -1134,6 +1134,85 @@ void main() {
});
});
group('JellyfinClient.fetchCollections', () {
test('uses boxsets view instead of selected media library parent', () async {
final requests = <Uri>[];
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