fix(jellyfin): slim music hub row fields

close #1552

Latest Albums returns MusicAlbum folder dtos, where the browse count/user-data
fields each cost a recursive per-album COUNT query; request it with the slim
album fields + EnableUserData=false under a dedicated latestalbums identifier,
and drop the folder fields and Overview from the played-track rows.
This commit is contained in:
edde746
2026-07-13 12:05:23 +02:00
parent f104b5ffae
commit 65e63c206e
3 changed files with 123 additions and 55 deletions
+68 -47
View File
@@ -68,6 +68,24 @@ const _folderBrowseFields = 'UserData,PremiereDate,OriginalTitle,SortName';
/// dominant cost of folder browsing (see [_fetchFolderChildren]).
const _folderRowFields = 'SortName';
/// Latest Albums hub row. `/Users/{id}/Items/Latest` on a music library
/// returns MusicAlbum FOLDER dtos, so [_browseFields] would trigger the same
/// per-folder recursive COUNT queries described on [_folderBrowseFields] —
/// with music libraries in the home fan-out that load helped peg small remote
/// servers (#1552). The album card renders artwork + title + album artist
/// (`AlbumArtist`/`AlbumArtists` are unconditional dto properties), so no
/// count fields are needed; queried with `EnableUserData=false` like the
/// filesystem folder rows. Trade-off: fully played albums lose the watched
/// checkmark on this row (Jellyfin web's latest-albums row shows no play
/// state either).
const _musicAlbumRowFields = 'PremiereDate,OriginalTitle,SortName';
/// Played-track hub rows (Recently Played / Most Played): Audio LEAF dtos.
/// Keeps `UserData` — a cheap direct lookup on leaves that drives the
/// play-state overlay — and drops the folder count fields (meaningless on
/// Audio) and `Overview` (never rendered on track cards).
const _musicTrackRowFields = 'UserData,PremiereDate,OriginalTitle,SortName';
/// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows
/// only need title, thumbnail (`ImageTags['Primary']`), season/episode
/// index, watched state, and the air date that drives the watch order.
@@ -1287,6 +1305,21 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
bool includePlaybackHubs = true,
MediaKind? libraryKind,
}) async {
// 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.
// Branched before the Latest request below fires (futures are eager):
// music needs the slim [_musicAlbumRowFields], not [_browseFields].
if (libraryKind == MediaKind.artist) {
return _fetchMusicLibraryHubs(
libraryId,
libraryName: libraryName,
limit: limit,
includePlaybackHubs: includePlaybackHubs,
);
}
// Mirror the Jellyfin web client's per-library "Suggestions" tab:
// Continue Watching + Next Up (TV libraries) + Recently Added.
//
@@ -1300,20 +1333,6 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
...jellyfinImageQueryParameters,
}, retry: _libraryHubRetry);
// 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,
includePlaybackHubs: includePlaybackHubs,
);
}
if (!includePlaybackHubs) {
final latest = await latestFuture;
return [
@@ -1391,32 +1410,38 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
}
/// Music-library hub set, mirroring the Jellyfin web client's music
/// "Suggestions" tab. `Latest Albums` reuses the `.recent` identifier —
/// `/Users/{userId}/Items/Latest` natively groups a music library's new
/// items into albums, so the existing `recent` paging path in
/// [fetchMoreHubItemsPage] is already the correct expansion. The played
/// rows filter `IsPlayed` so unplayed tracks (PlayCount 0) never pad them.
/// "Suggestions" tab. `/Users/{userId}/Items/Latest` natively groups a
/// music library's new items into albums; the row carries the
/// `latestalbums` identifier so [fetchMoreHubItemsPage] expands it with
/// the same slim album fields. The played rows filter `IsPlayed` so
/// unplayed tracks (PlayCount 0) never pad them.
Future<List<MediaHub>> _fetchMusicLibraryHubs(
String libraryId, {
required String libraryName,
required int limit,
required Future<List<Map<String, dynamic>>> latestFuture,
required bool includePlaybackHubs,
}) async {
final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'ParentId': libraryId,
'Fields': _musicAlbumRowFields,
'EnableUserData': 'false',
...jellyfinImageQueryParameters,
}, retry: _libraryHubRetry);
MediaHub latestAlbumsHub(List<Map<String, dynamic>> items) => JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.latestalbums',
title: t.discover.latestAlbumsIn(library: libraryName),
type: 'album',
items: items,
previewLimit: limit,
serverId: serverId,
serverName: serverName,
);
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();
return [latestAlbumsHub(await latestFuture)].where((hub) => hub.items.isNotEmpty).toList();
}
final playedParams = <String, String>{
'userId': connection.userId,
@@ -1426,7 +1451,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
'Filters': 'IsPlayed',
'SortOrder': 'Descending',
'Limit': limit.toString(),
'Fields': _browseFields,
'Fields': _musicTrackRowFields,
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
};
@@ -1437,16 +1462,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
]);
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.recent',
title: t.discover.latestAlbumsIn(library: libraryName),
type: 'album',
items: results.first,
previewLimit: limit,
serverId: serverId,
serverName: serverName,
),
latestAlbumsHub(results.first),
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.recentlyplayed',
@@ -1473,7 +1489,8 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
/// Re-run the synthetic hub query without the preview limit so the
/// hub-detail screen can render the full list. Branches on the
/// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]:
/// `home.recent` / `library.{id}.recent` → Latest, `*.continue` → Resume,
/// `home.recent` / `library.{id}.recent` → Latest, `*.latestalbums` →
/// Latest with the slim music album fields, `*.continue` → Resume,
/// `*.nextup` → NextUp, `*.recentlyplayed` / `*.mostplayed` → the music
/// played-track queries. Unknown ids return an empty list.
@override
@@ -1509,14 +1526,18 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
final tail = hubId.split('.').last;
switch (tail) {
case 'recent':
case 'latestalbums':
// Jellyfin's Latest endpoint has a Limit but no StartIndex. Expose it
// as one bounded page so callers don't infer endless fake pages.
// Music album rows keep the slim fields their preview row used
// (see [_musicAlbumRowFields]).
if (offset > 0) return LibraryPage<MediaItem>(items: const [], totalCount: offset, offset: offset);
return _safeFetchMediaPage(
'/Users/${_segment(connection.userId)}/Items/Latest',
{
'Limit': effectiveLimit,
'Fields': _browseFields,
'Fields': tail == 'latestalbums' ? _musicAlbumRowFields : _browseFields,
if (tail == 'latestalbums') 'EnableUserData': 'false',
if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
},
@@ -1573,7 +1594,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
'SortOrder': 'Descending',
'StartIndex': offset.toString(),
'Limit': effectiveLimit,
'Fields': _browseFields,
'Fields': _musicTrackRowFields,
'EnableTotalRecordCount': 'true',
...jellyfinImageQueryParameters,
},
@@ -785,7 +785,7 @@ void main() {
'library.movies.recent',
'library.mv.recent',
'library.home-vids.recent',
'library.music.recent',
'library.music.latestalbums',
]);
expect(hubs[1].items.single.kind, MediaKind.clip);
expect(hubs[3].items.single.kind, MediaKind.album);
@@ -798,6 +798,19 @@ void main() {
isEmpty,
reason: 'the home screen excludes playback-derived music rows',
);
// Music Latest returns album FOLDER dtos — count/user-data fields would
// each cost a recursive per-album COUNT query (#1552); video libraries
// keep the full browse fields (series leaf counts).
final musicLatest = captured.singleWhere(
(uri) => uri.path == '/Users/user-1/Items/Latest' && uri.queryParameters['ParentId'] == 'music',
);
expect(musicLatest.queryParameters['Fields'], 'PremiereDate,OriginalTitle,SortName');
expect(musicLatest.queryParameters['EnableUserData'], 'false');
final movieLatest = captured.singleWhere(
(uri) => uri.path == '/Users/user-1/Items/Latest' && uri.queryParameters['ParentId'] == 'movies',
);
expect(movieLatest.queryParameters['Fields'], contains('RecursiveItemCount'));
expect(movieLatest.queryParameters.containsKey('EnableUserData'), isFalse);
});
test('music library recommendations retain recently and most-played rows', () async {
@@ -837,16 +850,19 @@ void main() {
);
expect(hubs.map((hub) => hub.identifier), [
'library.music.recent',
'library.music.latestalbums',
'library.music.recentlyplayed',
'library.music.mostplayed',
]);
expect(
captured
.where((uri) => uri.path == '/Items' && uri.queryParameters['Filters'] == 'IsPlayed')
.map((uri) => uri.queryParameters['SortBy']),
['DatePlayed', 'PlayCount'],
);
final playedQueries = captured
.where((uri) => uri.path == '/Items' && uri.queryParameters['Filters'] == 'IsPlayed')
.toList();
expect(playedQueries.map((uri) => uri.queryParameters['SortBy']), ['DatePlayed', 'PlayCount']);
// Audio LEAF dtos: UserData stays (cheap, drives play state); the
// folder count fields and Overview are dropped.
expect(playedQueries.map((uri) => uri.queryParameters['Fields']).toSet(), {
'UserData,PremiereDate,OriginalTitle,SortName',
});
});
test('Plex home layout keeps promoted hubs instead of splitting by preview libraries', () async {
@@ -2686,6 +2686,37 @@ void main() {
client.close();
});
test('library-scoped "library.{id}.latestalbums" hits Latest with slim music album fields', () async {
final client = buildClient();
await client.fetchMoreHubItems('library.lib-99.latestalbums', limit: 30);
expect(captured, isNotNull);
expect(captured!.path, '/Users/user-1/Items/Latest');
expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['Limit'], '30');
// Album FOLDER dtos: count/user-data fields would each cost the server
// a recursive per-album COUNT query (#1552).
expect(captured!.queryParameters['Fields'], 'PremiereDate,OriginalTitle,SortName');
expect(captured!.queryParameters['EnableUserData'], 'false');
client.close();
});
test('library-scoped "library.{id}.recentlyplayed" queries played audio with slim track fields', () async {
final client = buildClient();
await client.fetchMoreHubItems('library.lib-99.recentlyplayed');
expect(captured, isNotNull);
expect(captured!.path, '/Items');
expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['IncludeItemTypes'], 'Audio');
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['Filters'], 'IsPlayed');
expect(captured!.queryParameters['SortBy'], 'DatePlayed');
expect(captured!.queryParameters['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
client.close();
});
test('unknown identifier returns empty without hitting the network', () async {
final client = buildClient();
final items = await client.fetchMoreHubItems('totally.unknown');