diff --git a/lib/media/library_query.dart b/lib/media/library_query.dart index 73be12bb..e3c472e2 100644 --- a/lib/media/library_query.dart +++ b/lib/media/library_query.dart @@ -35,6 +35,10 @@ sealed class LibraryQuery with _$LibraryQuery { /// Restrict to a single kind (e.g. `MediaKind.movie`). Null = library default. MediaKind? kind, + /// Restrict to multiple kinds when no single [kind] represents the browse + /// surface. When non-empty, translators prefer this over [kind]. + @Default([]) List includeKinds, + /// Pagination — zero-based offset. @Default(0) int offset, @Default(50) int limit, diff --git a/lib/media/library_query.freezed.dart b/lib/media/library_query.freezed.dart index 225a0802..01d6ff28 100644 --- a/lib/media/library_query.freezed.dart +++ b/lib/media/library_query.freezed.dart @@ -532,7 +532,9 @@ as List, mixin _$LibraryQuery { /// Restrict to a single kind (e.g. `MediaKind.movie`). Null = library default. - MediaKind? get kind;/// Pagination — zero-based offset. + MediaKind? get kind;/// Restrict to multiple kinds when no single [kind] represents the browse +/// surface. When non-empty, translators prefer this over [kind]. + List get includeKinds;/// Pagination — zero-based offset. int get offset; int get limit; LibrarySort? get sort; List get filters;/// Free-text search restricted to this library. Distinct from the global /// search endpoint. String? get search;/// Whether to include items the active user has already watched. @@ -556,16 +558,16 @@ $LibraryQueryCopyWith get copyWith => _$LibraryQueryCopyWithImpl Object.hash(runtimeType,kind,offset,limit,sort,const DeepCollectionEquality().hash(filters),search,includeWatched,favoritesOnly,nameStartsWith,const DeepCollectionEquality().hash(genres),const DeepCollectionEquality().hash(officialRatings),const DeepCollectionEquality().hash(years),const DeepCollectionEquality().hash(tags)); +int get hashCode => Object.hash(runtimeType,kind,const DeepCollectionEquality().hash(includeKinds),offset,limit,sort,const DeepCollectionEquality().hash(filters),search,includeWatched,favoritesOnly,nameStartsWith,const DeepCollectionEquality().hash(genres),const DeepCollectionEquality().hash(officialRatings),const DeepCollectionEquality().hash(years),const DeepCollectionEquality().hash(tags)); @override String toString() { - return 'LibraryQuery(kind: $kind, offset: $offset, limit: $limit, sort: $sort, filters: $filters, search: $search, includeWatched: $includeWatched, favoritesOnly: $favoritesOnly, nameStartsWith: $nameStartsWith, genres: $genres, officialRatings: $officialRatings, years: $years, tags: $tags)'; + return 'LibraryQuery(kind: $kind, includeKinds: $includeKinds, offset: $offset, limit: $limit, sort: $sort, filters: $filters, search: $search, includeWatched: $includeWatched, favoritesOnly: $favoritesOnly, nameStartsWith: $nameStartsWith, genres: $genres, officialRatings: $officialRatings, years: $years, tags: $tags)'; } @@ -576,7 +578,7 @@ abstract mixin class $LibraryQueryCopyWith<$Res> { factory $LibraryQueryCopyWith(LibraryQuery value, $Res Function(LibraryQuery) _then) = _$LibraryQueryCopyWithImpl; @useResult $Res call({ - MediaKind? kind, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags + MediaKind? kind, List includeKinds, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags }); @@ -593,10 +595,11 @@ class _$LibraryQueryCopyWithImpl<$Res> /// Create a copy of LibraryQuery /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? kind = freezed,Object? offset = null,Object? limit = null,Object? sort = freezed,Object? filters = null,Object? search = freezed,Object? includeWatched = null,Object? favoritesOnly = null,Object? nameStartsWith = freezed,Object? genres = freezed,Object? officialRatings = freezed,Object? years = freezed,Object? tags = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? kind = freezed,Object? includeKinds = null,Object? offset = null,Object? limit = null,Object? sort = freezed,Object? filters = null,Object? search = freezed,Object? includeWatched = null,Object? favoritesOnly = null,Object? nameStartsWith = freezed,Object? genres = freezed,Object? officialRatings = freezed,Object? years = freezed,Object? tags = freezed,}) { return _then(_self.copyWith( kind: freezed == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable -as MediaKind?,offset: null == offset ? _self.offset : offset // ignore: cast_nullable_to_non_nullable +as MediaKind?,includeKinds: null == includeKinds ? _self.includeKinds : includeKinds // ignore: cast_nullable_to_non_nullable +as List,offset: null == offset ? _self.offset : offset // ignore: cast_nullable_to_non_nullable as int,limit: null == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable as int,sort: freezed == sort ? _self.sort : sort // ignore: cast_nullable_to_non_nullable as LibrarySort?,filters: null == filters ? _self.filters : filters // ignore: cast_nullable_to_non_nullable @@ -702,10 +705,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( MediaKind? kind, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( MediaKind? kind, List includeKinds, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _LibraryQuery() when $default != null: -return $default(_that.kind,_that.offset,_that.limit,_that.sort,_that.filters,_that.search,_that.includeWatched,_that.favoritesOnly,_that.nameStartsWith,_that.genres,_that.officialRatings,_that.years,_that.tags);case _: +return $default(_that.kind,_that.includeKinds,_that.offset,_that.limit,_that.sort,_that.filters,_that.search,_that.includeWatched,_that.favoritesOnly,_that.nameStartsWith,_that.genres,_that.officialRatings,_that.years,_that.tags);case _: return orElse(); } @@ -723,10 +726,10 @@ return $default(_that.kind,_that.offset,_that.limit,_that.sort,_that.filters,_th /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( MediaKind? kind, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( MediaKind? kind, List includeKinds, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags) $default,) {final _that = this; switch (_that) { case _LibraryQuery(): -return $default(_that.kind,_that.offset,_that.limit,_that.sort,_that.filters,_that.search,_that.includeWatched,_that.favoritesOnly,_that.nameStartsWith,_that.genres,_that.officialRatings,_that.years,_that.tags);} +return $default(_that.kind,_that.includeKinds,_that.offset,_that.limit,_that.sort,_that.filters,_that.search,_that.includeWatched,_that.favoritesOnly,_that.nameStartsWith,_that.genres,_that.officialRatings,_that.years,_that.tags);} } /// A variant of `when` that fallback to returning `null` /// @@ -740,10 +743,10 @@ return $default(_that.kind,_that.offset,_that.limit,_that.sort,_that.filters,_th /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( MediaKind? kind, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( MediaKind? kind, List includeKinds, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags)? $default,) {final _that = this; switch (_that) { case _LibraryQuery() when $default != null: -return $default(_that.kind,_that.offset,_that.limit,_that.sort,_that.filters,_that.search,_that.includeWatched,_that.favoritesOnly,_that.nameStartsWith,_that.genres,_that.officialRatings,_that.years,_that.tags);case _: +return $default(_that.kind,_that.includeKinds,_that.offset,_that.limit,_that.sort,_that.filters,_that.search,_that.includeWatched,_that.favoritesOnly,_that.nameStartsWith,_that.genres,_that.officialRatings,_that.years,_that.tags);case _: return null; } @@ -755,11 +758,22 @@ return $default(_that.kind,_that.offset,_that.limit,_that.sort,_that.filters,_th class _LibraryQuery implements LibraryQuery { - const _LibraryQuery({this.kind, this.offset = 0, this.limit = 50, this.sort, final List filters = const [], this.search, this.includeWatched = true, this.favoritesOnly = false, this.nameStartsWith, final List? genres, final List? officialRatings, final List? years, final List? tags}): _filters = filters,_genres = genres,_officialRatings = officialRatings,_years = years,_tags = tags; + const _LibraryQuery({this.kind, final List includeKinds = const [], this.offset = 0, this.limit = 50, this.sort, final List filters = const [], this.search, this.includeWatched = true, this.favoritesOnly = false, this.nameStartsWith, final List? genres, final List? officialRatings, final List? years, final List? tags}): _includeKinds = includeKinds,_filters = filters,_genres = genres,_officialRatings = officialRatings,_years = years,_tags = tags; /// Restrict to a single kind (e.g. `MediaKind.movie`). Null = library default. @override final MediaKind? kind; +/// Restrict to multiple kinds when no single [kind] represents the browse +/// surface. When non-empty, translators prefer this over [kind]. + final List _includeKinds; +/// Restrict to multiple kinds when no single [kind] represents the browse +/// surface. When non-empty, translators prefer this over [kind]. +@override@JsonKey() List get includeKinds { + if (_includeKinds is EqualUnmodifiableListView) return _includeKinds; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_includeKinds); +} + /// Pagination — zero-based offset. @override@JsonKey() final int offset; @override@JsonKey() final int limit; @@ -837,16 +851,16 @@ _$LibraryQueryCopyWith<_LibraryQuery> get copyWith => __$LibraryQueryCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _LibraryQuery&&(identical(other.kind, kind) || other.kind == kind)&&(identical(other.offset, offset) || other.offset == offset)&&(identical(other.limit, limit) || other.limit == limit)&&(identical(other.sort, sort) || other.sort == sort)&&const DeepCollectionEquality().equals(other._filters, _filters)&&(identical(other.search, search) || other.search == search)&&(identical(other.includeWatched, includeWatched) || other.includeWatched == includeWatched)&&(identical(other.favoritesOnly, favoritesOnly) || other.favoritesOnly == favoritesOnly)&&(identical(other.nameStartsWith, nameStartsWith) || other.nameStartsWith == nameStartsWith)&&const DeepCollectionEquality().equals(other._genres, _genres)&&const DeepCollectionEquality().equals(other._officialRatings, _officialRatings)&&const DeepCollectionEquality().equals(other._years, _years)&&const DeepCollectionEquality().equals(other._tags, _tags)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LibraryQuery&&(identical(other.kind, kind) || other.kind == kind)&&const DeepCollectionEquality().equals(other._includeKinds, _includeKinds)&&(identical(other.offset, offset) || other.offset == offset)&&(identical(other.limit, limit) || other.limit == limit)&&(identical(other.sort, sort) || other.sort == sort)&&const DeepCollectionEquality().equals(other._filters, _filters)&&(identical(other.search, search) || other.search == search)&&(identical(other.includeWatched, includeWatched) || other.includeWatched == includeWatched)&&(identical(other.favoritesOnly, favoritesOnly) || other.favoritesOnly == favoritesOnly)&&(identical(other.nameStartsWith, nameStartsWith) || other.nameStartsWith == nameStartsWith)&&const DeepCollectionEquality().equals(other._genres, _genres)&&const DeepCollectionEquality().equals(other._officialRatings, _officialRatings)&&const DeepCollectionEquality().equals(other._years, _years)&&const DeepCollectionEquality().equals(other._tags, _tags)); } @override -int get hashCode => Object.hash(runtimeType,kind,offset,limit,sort,const DeepCollectionEquality().hash(_filters),search,includeWatched,favoritesOnly,nameStartsWith,const DeepCollectionEquality().hash(_genres),const DeepCollectionEquality().hash(_officialRatings),const DeepCollectionEquality().hash(_years),const DeepCollectionEquality().hash(_tags)); +int get hashCode => Object.hash(runtimeType,kind,const DeepCollectionEquality().hash(_includeKinds),offset,limit,sort,const DeepCollectionEquality().hash(_filters),search,includeWatched,favoritesOnly,nameStartsWith,const DeepCollectionEquality().hash(_genres),const DeepCollectionEquality().hash(_officialRatings),const DeepCollectionEquality().hash(_years),const DeepCollectionEquality().hash(_tags)); @override String toString() { - return 'LibraryQuery(kind: $kind, offset: $offset, limit: $limit, sort: $sort, filters: $filters, search: $search, includeWatched: $includeWatched, favoritesOnly: $favoritesOnly, nameStartsWith: $nameStartsWith, genres: $genres, officialRatings: $officialRatings, years: $years, tags: $tags)'; + return 'LibraryQuery(kind: $kind, includeKinds: $includeKinds, offset: $offset, limit: $limit, sort: $sort, filters: $filters, search: $search, includeWatched: $includeWatched, favoritesOnly: $favoritesOnly, nameStartsWith: $nameStartsWith, genres: $genres, officialRatings: $officialRatings, years: $years, tags: $tags)'; } @@ -857,7 +871,7 @@ abstract mixin class _$LibraryQueryCopyWith<$Res> implements $LibraryQueryCopyWi factory _$LibraryQueryCopyWith(_LibraryQuery value, $Res Function(_LibraryQuery) _then) = __$LibraryQueryCopyWithImpl; @override @useResult $Res call({ - MediaKind? kind, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags + MediaKind? kind, List includeKinds, int offset, int limit, LibrarySort? sort, List filters, String? search, bool includeWatched, bool favoritesOnly, String? nameStartsWith, List? genres, List? officialRatings, List? years, List? tags }); @@ -874,10 +888,11 @@ class __$LibraryQueryCopyWithImpl<$Res> /// Create a copy of LibraryQuery /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? kind = freezed,Object? offset = null,Object? limit = null,Object? sort = freezed,Object? filters = null,Object? search = freezed,Object? includeWatched = null,Object? favoritesOnly = null,Object? nameStartsWith = freezed,Object? genres = freezed,Object? officialRatings = freezed,Object? years = freezed,Object? tags = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? kind = freezed,Object? includeKinds = null,Object? offset = null,Object? limit = null,Object? sort = freezed,Object? filters = null,Object? search = freezed,Object? includeWatched = null,Object? favoritesOnly = null,Object? nameStartsWith = freezed,Object? genres = freezed,Object? officialRatings = freezed,Object? years = freezed,Object? tags = freezed,}) { return _then(_LibraryQuery( kind: freezed == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable -as MediaKind?,offset: null == offset ? _self.offset : offset // ignore: cast_nullable_to_non_nullable +as MediaKind?,includeKinds: null == includeKinds ? _self._includeKinds : includeKinds // ignore: cast_nullable_to_non_nullable +as List,offset: null == offset ? _self.offset : offset // ignore: cast_nullable_to_non_nullable as int,limit: null == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable as int,sort: freezed == sort ? _self.sort : sort // ignore: cast_nullable_to_non_nullable as LibrarySort?,filters: null == filters ? _self._filters : filters // ignore: cast_nullable_to_non_nullable diff --git a/lib/media/media_library.dart b/lib/media/media_library.dart index 9bfe3c84..87582c48 100644 --- a/lib/media/media_library.dart +++ b/lib/media/media_library.dart @@ -15,6 +15,10 @@ class MediaLibrary { /// (poster shape, sort options). For mixed libraries this is [MediaKind.unknown]. final MediaKind kind; + /// Explicit item kinds for this library's flat root browse. Mixed libraries + /// use this when no single [kind] can describe the server's root view. + final List defaultBrowseKinds; + /// Optional ISO language code of the library's metadata locale. final String? language; @@ -37,6 +41,7 @@ class MediaLibrary { required this.backend, required this.title, this.kind = MediaKind.unknown, + this.defaultBrowseKinds = const [], this.language, this.updatedAt, this.createdAt, diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index b8598d6d..4a906239 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -760,12 +760,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> fetchPage(int start, int size, AbortController? abort) async { final client = context.getMediaClientForLibrary(widget.library); final filterParams = _buildFilterParams(); - final query = libraryQueryFromPlexMap( + final baseQuery = libraryQueryFromPlexMap( map: filterParams, libraryKind: filterParams.containsKey('type') ? null : widget.library.kind, offset: start, limit: size, ); + final query = + baseQuery.kind == null && + baseQuery.includeKinds.isEmpty && + _selectedGrouping == browseGroupingAll && + widget.library.defaultBrowseKinds.isNotEmpty + ? baseQuery.copyWith(includeKinds: widget.library.defaultBrowseKinds) + : baseQuery; return client.fetchLibraryPagedContent( widget.library.id, query: query, diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index ecbda98a..3c9084a9 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -428,7 +428,8 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { }) async { // [libraryKind] is only a fallback for library-default browsing. Explicit // grouping types on [query] (seasons/episodes) must keep priority. - final effective = (query.kind == null && libraryKind != null && libraryKind != MediaKind.unknown) + final effective = + (query.kind == null && query.includeKinds.isEmpty && libraryKind != null && libraryKind != MediaKind.unknown) ? query.copyWith(kind: libraryKind) : query; return fetchLibraryContent(libraryId, effective, abort: abort); diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index d8e8e207..e8befd58 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -273,6 +273,7 @@ class JellyfinMappers { backend: MediaBackend.jellyfin, title: view['Name'] as String? ?? t.libraries.fallbackTitle, kind: _libraryKindFromCollectionType(collectionType, view['Type'] as String?), + defaultBrowseKinds: _defaultBrowseKindsFromCollectionType(collectionType), updatedAt: jellyfinIsoToEpochSeconds(view['DateLastSaved'] as String? ?? view['DateModified'] as String?), createdAt: jellyfinIsoToEpochSeconds(view['DateCreated'] as String?), hidden: false, @@ -334,6 +335,10 @@ class JellyfinMappers { return MediaKind.fromString(type); } + static List _defaultBrowseKindsFromCollectionType(String? collectionType) { + return collectionType?.toLowerCase() == 'mixed' ? const [MediaKind.movie, MediaKind.show] : const []; + } + static Map? _userData(Map item) { final ud = item['UserData']; return ud is Map ? ud : null; diff --git a/lib/services/library_query_translator.dart b/lib/services/library_query_translator.dart index 2cf682cb..b37a3615 100644 --- a/lib/services/library_query_translator.dart +++ b/lib/services/library_query_translator.dart @@ -52,9 +52,16 @@ class PlexLibraryQueryTranslator implements LibraryQueryTranslator { @override Map toQueryParameters(LibraryQuery query) { final filters = {}; - final kindNumber = _plexTypeNumberFor(query.kind); - if (kindNumber != null) { - filters['type'] = kindNumber.toString(); + if (query.includeKinds.isNotEmpty) { + final kindNumbers = query.includeKinds.map(_plexTypeNumberFor).whereType().join(','); + if (kindNumbers.isNotEmpty) { + filters['type'] = kindNumbers; + } + } else { + final kindNumber = _plexTypeNumberFor(query.kind); + if (kindNumber != null) { + filters['type'] = kindNumber.toString(); + } } final sort = query.sort; if (sort != null) { @@ -221,7 +228,7 @@ class JellyfinLibraryQueryTranslator implements LibraryQueryTranslator { 'StartIndex': query.offset.toString(), 'Limit': query.limit.toString(), 'EnableTotalRecordCount': 'true', - 'IncludeItemTypes': _includeTypesFor(query.kind), + 'IncludeItemTypes': _includeTypesFor(query), 'Fields': fields, ...jellyfinImageQueryParameters, }; @@ -263,7 +270,14 @@ class JellyfinLibraryQueryTranslator implements LibraryQueryTranslator { return params; } - static String _includeTypesFor(MediaKind? kind) { + static String _includeTypesFor(LibraryQuery query) { + if (query.includeKinds.isNotEmpty) { + return query.includeKinds.map(_includeTypesForKind).join(','); + } + return _includeTypesForKind(query.kind); + } + + static String _includeTypesForKind(MediaKind? kind) { return switch (kind) { MediaKind.movie => 'Movie', MediaKind.show => 'Series', diff --git a/test/screens/libraries/library_browse_tab_test.dart b/test/screens/libraries/library_browse_tab_test.dart index 6b3c2586..617b6b54 100644 --- a/test/screens/libraries/library_browse_tab_test.dart +++ b/test/screens/libraries/library_browse_tab_test.dart @@ -132,6 +132,26 @@ void main() { expect(harness.loadedLibraries, [harness.libraryA.globalKey]); expect(groupingChip.focusNode!.hasFocus, isTrue); }); + + testWidgets('mixed library all grouping applies its explicit root kinds', (tester) async { + final client = _BrowseClient('server-a', 'Mixed'); + final harness = _BrowseHarness(clientA: client); + addTearDown(harness.dispose); + harness.selectedLibrary.value = MediaLibrary( + id: 'mixed-library', + backend: MediaBackend.jellyfin, + title: 'Mixed', + defaultBrowseKinds: const [MediaKind.movie, MediaKind.show], + serverId: client.serverId, + ); + + await _pumpHarness(tester, harness); + + expect(client.pageQueries, hasLength(1)); + expect(client.pageQueries.single.kind, isNull); + expect(client.pageQueries.single.includeKinds, const [MediaKind.movie, MediaKind.show]); + expect(client.pageLibraryKinds.single, MediaKind.unknown); + }); } Future _pumpHarness(WidgetTester tester, _BrowseHarness harness, {bool settle = true}) async { @@ -257,6 +277,8 @@ class _BrowseClient implements MediaServerClient { final Queue> Function()> pageResponses = Queue(); var sortRequestCount = 0; var pageRequestCount = 0; + final List pageQueries = []; + final List pageLibraryKinds = []; _BrowseClient(String serverId, this.itemTitle, {this.sortResponse}) : serverId = ServerId(serverId); @@ -288,6 +310,8 @@ class _BrowseClient implements MediaServerClient { AbortController? abort, }) { pageRequestCount++; + pageQueries.add(query); + pageLibraryKinds.add(libraryKind); if (pageResponses.isNotEmpty) return pageResponses.removeFirst()(); return Future.value(_pageFor(this, itemTitle)); } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 3713b6e0..a3966840 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -2342,6 +2342,8 @@ void main() { expect(albumBrowse['Fields'], 'PremiereDate,OriginalTitle,SortName'); expect(albumBrowse['EnableUserData'], 'false'); expect(trackBrowse['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName'); + expect(albumBrowse['IncludeItemTypes'], 'MusicAlbum'); + expect(trackBrowse['IncludeItemTypes'], 'Audio'); expect(trackBrowse.containsKey('EnableUserData'), isFalse); expect(artistAlbums['Fields'], 'PremiereDate,OriginalTitle,SortName'); expect(artistAlbums['EnableUserData'], 'false'); diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index bd77d6af..e32ef550 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -474,13 +474,22 @@ void main() { } }); - test('falls back to MediaKind.unknown for unrecognised collections', () { - final lib = JellyfinMappers.library({ - 'Id': 'view-x', + test('maps mixed collections to a movie and show root browse', () { + final mixed = JellyfinMappers.library({ + 'Id': 'view-mixed', 'Name': 'Mixed', 'CollectionType': 'mixed', }, serverId: ServerId(_serverId))!; - expect(lib.kind, MediaKind.unknown); + expect(mixed.kind, MediaKind.unknown); + expect(mixed.defaultBrowseKinds, const [MediaKind.movie, MediaKind.show]); + + final unrecognised = JellyfinMappers.library({ + 'Id': 'view-books', + 'Name': 'Books', + 'CollectionType': 'books', + }, serverId: ServerId(_serverId))!; + expect(unrecognised.kind, MediaKind.unknown); + expect(unrecognised.defaultBrowseKinds, isEmpty); }); }); diff --git a/test/services/library_query_translator_test.dart b/test/services/library_query_translator_test.dart index 3ebc6247..d54bf0e6 100644 --- a/test/services/library_query_translator_test.dart +++ b/test/services/library_query_translator_test.dart @@ -21,6 +21,11 @@ void main() { expect(params['type'], '2'); }); + test('multiple kinds map to a comma-delimited type filter', () { + final params = translator.toQueryParameters(const LibraryQuery(includeKinds: [MediaKind.movie, MediaKind.show])); + expect(params['type'], '1,2'); + }); + test('collection kind has no Plex type number (filtered separately)', () { final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.collection)); expect(params, isNot(contains('type'))); @@ -98,6 +103,13 @@ void main() { expect(params['IncludeItemTypes'], 'Series'); }); + test('multiple kinds map to combined IncludeItemTypes', () { + final params = translator.toQueryParameters( + const LibraryQuery(kind: MediaKind.episode, includeKinds: [MediaKind.movie, MediaKind.show]), + ); + expect(params['IncludeItemTypes'], 'Movie,Series'); + }); + test('collection kind maps to IncludeItemTypes=BoxSet', () { final params = translator.toQueryParameters(const LibraryQuery(kind: MediaKind.collection)); expect(params['IncludeItemTypes'], 'BoxSet');