@@ -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
|
||||
|
||||
Reference in New Issue
Block a user