diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 12069d19..e545afcf 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -238,12 +238,12 @@ abstract class MediaServerClient { /// series" via the null vs `[]` distinction. Future?> fetchClientSideEpisodeQueue(String seriesId); - /// Albums credited to the artist [artistId], newest first. Not the same as - /// [fetchChildren]: Plex artists *are* folder-parents of their albums - /// (`/library/metadata/{id}/children`), but Jellyfin albums link to artists - /// only via tags, so it queries + /// Albums credited to [artist], newest first. Plex filters album rows in + /// the artist's music section so release formats omitted from + /// `/library/metadata/{id}/children` remain visible. Jellyfin links albums + /// to artists via tags and queries /// `/Items?AlbumArtistIds={id}&IncludeItemTypes=MusicAlbum`. - Future> fetchArtistAlbums(String artistId); + Future> fetchArtistAlbums(MediaItem artist); /// Tracks of album [albumId] in disc/track order. Plex: /// `/library/metadata/{id}/children`; Jellyfin: diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart index b2c96af1..c07d3844 100644 --- a/lib/screens/music/artist_detail_screen.dart +++ b/lib/screens/music/artist_detail_screen.dart @@ -62,7 +62,7 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen items.isNotEmpty; @override - Future> fetchItems() => mediaClient.fetchArtistAlbums(widget.artist.id); + Future> fetchItems() => mediaClient.fetchArtistAlbums(widget.artist); @override Future loadItems() async { diff --git a/lib/services/jellyfin_client/parts/music.dart b/lib/services/jellyfin_client/parts/music.dart index 5ea64b4e..ec556d94 100644 --- a/lib/services/jellyfin_client/parts/music.dart +++ b/lib/services/jellyfin_client/parts/music.dart @@ -9,16 +9,16 @@ mixin _JellyfinMusicMethods on MediaServerCacheMixin { FailoverHttpClient get _http; List _mapItems(Iterable> items); - /// Albums credited to [artistId], newest first. Queries `AlbumArtistIds` + /// Albums credited to [artist], newest first. Queries `AlbumArtistIds` /// rather than `ParentId` because Jellyfin links albums to artists via /// tags — an artist's albums are usually not its folder children. @override - Future> fetchArtistAlbums(String artistId) async { + Future> fetchArtistAlbums(MediaItem artist) async { final response = await _http.get( '/Items', queryParameters: { 'userId': connection.userId, - 'AlbumArtistIds': artistId, + 'AlbumArtistIds': artist.id, 'IncludeItemTypes': 'MusicAlbum', 'Recursive': 'true', 'SortBy': 'PremiereDate,ProductionYear,SortName', diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 39986700..b039dc45 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -2856,10 +2856,33 @@ class PlexClient @override Future?> fetchClientSideEpisodeQueue(String seriesId) async => null; - /// Plex artists are folder-parents of their albums, so both music child - /// listings are plain `/library/metadata/{id}/children` fetches. + /// Plex's artist `/children` response only contains the primary album + /// bucket. Filter album rows in the artist's music section to include every + /// release format Plex associates with the artist. @override - Future> fetchArtistAlbums(String artistId) => fetchChildren(artistId); + Future> fetchArtistAlbums(MediaItem artist) async { + final embeddedSectionId = artist.libraryId; + final sectionId = embeddedSectionId != null && embeddedSectionId.isNotEmpty + ? embeddedSectionId + : (await _getMetadataWithImages(artist.id))?.librarySectionID?.toString(); + if (sectionId == null || sectionId.isEmpty) { + throw StateError('Plex artist ${artist.id} is missing a library section ID'); + } + + // Preserve the existing artist-list cache identity so offline fallback + // and item invalidation continue to cover the complete discography. + final cacheKey = '/library/metadata/${artist.id}/children'; + final metadata = await fetchWithCacheFallback>( + cacheKey: cacheKey, + networkCall: () => _getAllPagesResponse( + '/library/sections/$sectionId/all', + queryParameters: {'type': PlexMetadataType.album, 'artist.id': artist.id, 'sort': 'album.year:desc'}, + ), + parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), + parseResponse: (response) => _extractMetadataList(response), + ); + return (metadata ?? const []).map((item) => PlexMappers.mediaItem(item)).toList(); + } @override Future> fetchAlbumTracks(String albumId) => fetchChildren(albumId); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 40d1f28d..2bf6ec3f 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -1781,7 +1781,9 @@ void main() { await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.album, offset: 0, limit: 20)); await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.track, offset: 0, limit: 20)); - await scoped.fetchArtistAlbums('artist-1'); + await scoped.fetchArtistAlbums( + testMediaItem(id: 'artist-1', backend: MediaBackend.jellyfin, kind: MediaKind.artist), + ); await scoped.fetchAlbumTracks('album-1'); final albumBrowse = captured[0].queryParameters; diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index c75eac6b..555c3587 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -6,10 +6,12 @@ import 'package:http/http.dart' as http; import 'package:plezy/database/app_database.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_kind.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/media_items.dart'; void main() { late AppDatabase db; @@ -289,4 +291,94 @@ void main() { 'season-3', ]); }); + + test('artist albums include every Plex release bucket and cache all pages', () async { + const cacheKey = '/library/metadata/artist-1/children'; + final requests = []; + final client = makeClient((request) async { + requests.add(request.url); + final start = int.parse(request.url.queryParameters['X-Plex-Container-Start']!); + final metadata = start == 0 + ? [ + {'ratingKey': 'album-lp', 'type': 'album', 'title': 'LP'}, + {'ratingKey': 'album-ep', 'type': 'album', 'title': 'EP'}, + ] + : [ + {'ratingKey': 'album-single', 'type': 'album', 'title': 'Single'}, + {'ratingKey': 'album-compilation', 'type': 'album', 'title': 'Compilation'}, + ]; + return http.Response( + jsonEncode({ + 'MediaContainer': {'librarySectionID': 7, 'size': metadata.length, 'totalSize': 4, 'Metadata': metadata}, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final albums = await client.fetchArtistAlbums( + testMediaItem(id: 'artist-1', kind: MediaKind.artist, libraryId: '7'), + ); + final cached = await PlexApiCache.instance.get(ServerId('server-id'), cacheKey); + final cachedContainer = cached!['MediaContainer'] as Map; + final cachedMetadata = cachedContainer['Metadata'] as List; + + expect(albums.map((album) => album.id), ['album-lp', 'album-ep', 'album-single', 'album-compilation']); + expect(requests, hasLength(2)); + expect(requests.every((uri) => uri.path == '/library/sections/7/all'), isTrue); + expect(requests.every((uri) => uri.queryParameters['type'] == '9'), isTrue); + expect(requests.every((uri) => uri.queryParameters['artist.id'] == 'artist-1'), isTrue); + expect(requests.every((uri) => uri.queryParameters['sort'] == 'album.year:desc'), isTrue); + expect(requests.map((uri) => uri.queryParameters['X-Plex-Container-Start']), ['0', '2']); + expect(cachedMetadata.map((item) => (item as Map)['ratingKey']), [ + 'album-lp', + 'album-ep', + 'album-single', + 'album-compilation', + ]); + }); + + test('artist albums resolve a missing music section from artist metadata', () async { + final requestedPaths = []; + final client = makeClient((request) async { + requestedPaths.add(request.url.path); + if (request.url.path == '/library/metadata/artist-1') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'librarySectionID': 7, + 'Metadata': [ + {'ratingKey': 'artist-1', 'type': 'artist', 'title': 'Artist'}, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/library/sections/7/all') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'librarySectionID': 7, + 'size': 1, + 'Metadata': [ + {'ratingKey': 'album-1', 'type': 'album', 'title': 'Album'}, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + return http.Response('not found', 404); + }); + addTearDown(client.close); + + final albums = await client.fetchArtistAlbums(testMediaItem(id: 'artist-1', kind: MediaKind.artist)); + + expect(requestedPaths, ['/library/metadata/artist-1', '/library/sections/7/all']); + expect(albums.map((album) => album.id), ['album-1']); + }); } diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 6236af56..7fed472d 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -596,7 +596,7 @@ class _RelatedMusicClient implements MediaServerClient { Future> fetchAlbumTracks(String albumId) async => const []; @override - Future> fetchArtistAlbums(String artistId) async => const []; + Future> fetchArtistAlbums(MediaItem artist) async => const []; @override void close() {}