diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index dbc88b65b..dda837aab 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Hamısını təkrarla", "repeatOne": "Birini təkrarla", "instantMixNoServer": "Ani miks üçün heç bir server mövcud deyil", - "noAudioUrl": "${track} üçün səs URL-i mövcud deyil" + "noAudioUrl": "${track} üçün səs URL-i mövcud deyil", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Birlikdə İzlə", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 8bd9411c7..1dfa71457 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Повтаряне на всички", "repeatOne": "Повтаряне на една", "instantMixNoServer": "Няма наличен сървър за незабавен микс", - "noAudioUrl": "Няма наличен URL за аудиото на ${track}" + "noAudioUrl": "Няма наличен URL за аудиото на ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Гледане заедно", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index b9d31966a..f50464e88 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Gentag alle", "repeatOne": "Gentag ét nummer", "instantMixNoServer": "Ingen server er tilgængelig til et øjeblikkeligt mix", - "noAudioUrl": "Ingen lyd-URL er tilgængelig for ${track}" + "noAudioUrl": "Ingen lyd-URL er tilgængelig for ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Se sammen", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index e6390e26a..15ad17f23 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Alle wiederholen", "repeatOne": "Titel wiederholen", "instantMixNoServer": "Für einen Instant Mix ist kein Server verfügbar", - "noAudioUrl": "Für ${track} ist keine Audio-URL verfügbar" + "noAudioUrl": "Für ${track} ist keine Audio-URL verfügbar", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Gemeinsam Schauen", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index ff4fb14fb..27b67a759 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Repeat all", "repeatOne": "Repeat one", "instantMixNoServer": "No server is available for an instant mix", - "noAudioUrl": "No audio URL is available for ${track}" + "noAudioUrl": "No audio URL is available for ${track}", + "discography": { + "singlesAndEps": "Singles & EPs", + "live": "Live", + "compilations": "Compilations" + } }, "watchTogether": { "title": "Watch Together", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 18f2e1dee..4c9f2f2f1 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Repetir todo", "repeatOne": "Repetir una", "instantMixNoServer": "No hay ningún servidor disponible para una mezcla instantánea", - "noAudioUrl": "No hay ninguna URL de audio disponible para ${track}" + "noAudioUrl": "No hay ninguna URL de audio disponible para ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Ver juntos", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index fab51e9ba..e998c838d 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Tout répéter", "repeatOne": "Répéter le titre", "instantMixNoServer": "Aucun serveur n’est disponible pour créer un mix instantané", - "noAudioUrl": "Aucune URL audio n’est disponible pour ${track}" + "noAudioUrl": "Aucune URL audio n’est disponible pour ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Regarder ensemble", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 6af52d459..1ec2f805b 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Összes ismétlése", "repeatOne": "Egy szám ismétlése", "instantMixNoServer": "Nem érhető el szerver az azonnali mixhez", - "noAudioUrl": "Nem érhető el hang-URL ehhez: ${track}" + "noAudioUrl": "Nem érhető el hang-URL ehhez: ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Watch Together (Közös nézés)", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 3e304a455..0a61c0184 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Ripeti tutto", "repeatOne": "Ripeti il brano", "instantMixNoServer": "Nessun server disponibile per un mix istantaneo", - "noAudioUrl": "Nessun URL audio disponibile per ${track}" + "noAudioUrl": "Nessun URL audio disponibile per ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Guarda insieme", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 9bc6b4957..457962160 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1406,7 +1406,12 @@ "repeatAll": "全曲リピート", "repeatOne": "1曲リピート", "instantMixNoServer": "インスタントミックスに利用できるサーバーがありません", - "noAudioUrl": "${track}で利用可能な音声URLがありません" + "noAudioUrl": "${track}で利用可能な音声URLがありません", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "一緒に見る", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 466e61703..b7ec70365 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Барлығын қайталау", "repeatOne": "Біреуін қайталау", "instantMixNoServer": "Жедел микс үшін қолжетімді сервер жоқ", - "noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес" + "noAudioUrl": "${track} үшін аудио URL-і қолжетімді емес", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Бірге көру", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index f265057ab..995d9679f 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1406,7 +1406,12 @@ "repeatAll": "전체 반복", "repeatOne": "한 곡 반복", "instantMixNoServer": "즉석 믹스에 사용할 수 있는 서버가 없습니다", - "noAudioUrl": "${track}에 사용할 수 있는 오디오 URL이 없습니다" + "noAudioUrl": "${track}에 사용할 수 있는 오디오 URL이 없습니다", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "함께 보기", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 7b9e06beb..2f81a48f1 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Gjenta alle", "repeatOne": "Gjenta ett spor", "instantMixNoServer": "Ingen server er tilgjengelig for en hurtigmiks", - "noAudioUrl": "Ingen lyd-URL er tilgjengelig for ${track}" + "noAudioUrl": "Ingen lyd-URL er tilgjengelig for ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Se sammen", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index beccf36ba..6966c7c96 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Alles herhalen", "repeatOne": "Eén herhalen", "instantMixNoServer": "Er is geen server beschikbaar voor een instantmix", - "noAudioUrl": "Er is geen audio-URL beschikbaar voor ${track}" + "noAudioUrl": "Er is geen audio-URL beschikbaar voor ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Samen kijken", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 97150b66c..79fc66db7 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1436,7 +1436,12 @@ "repeatAll": "Powtarzaj wszystko", "repeatOne": "Powtarzaj jeden", "instantMixNoServer": "Brak dostępnego serwera dla szybkiego miksu", - "noAudioUrl": "Brak dostępnego adresu URL audio dla utworu ${track}" + "noAudioUrl": "Brak dostępnego adresu URL audio dla utworu ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Oglądaj razem", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 18c07b1b8..698fae954 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Repetir tudo", "repeatOne": "Repetir uma faixa", "instantMixNoServer": "Nenhum servidor está disponível para uma mistura instantânea", - "noAudioUrl": "Nenhuma URL de áudio está disponível para ${track}" + "noAudioUrl": "Nenhuma URL de áudio está disponível para ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Assistir Juntos", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 8307b4e38..fb0fe2ea1 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1436,7 +1436,12 @@ "repeatAll": "Повторять все", "repeatOne": "Повторять один", "instantMixNoServer": "Нет доступного сервера для мгновенного микса", - "noAudioUrl": "Для трека ${track} нет доступного URL аудио" + "noAudioUrl": "Для трека ${track} нет доступного URL аудио", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Смотреть вместе", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index f24f65806..ddde999c3 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 40727 (1851 per locale) +/// Strings: 40730 (1851 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index d22d1b5be..1e521a303 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -3696,6 +3696,8 @@ class Translations$music$en { /// en: 'No audio URL is available for ${track}' String noAudioUrl({required Object track}) => 'No audio URL is available for ${track}'; + + late final Translations$music$discography$en discography = Translations$music$discography$en.internal(_root); } // Path: watchTogether @@ -5955,6 +5957,24 @@ class Translations$explore$detail$en { String get background => 'Background'; } +// Path: music.discography +class Translations$music$discography$en { + Translations$music$discography$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Singles & EPs' + String get singlesAndEps => 'Singles & EPs'; + + /// en: 'Live' + String get live => 'Live'; + + /// en: 'Compilations' + String get compilations => 'Compilations'; +} + // Path: downloads.backgroundWarning class Translations$downloads$backgroundWarning$en { Translations$downloads$backgroundWarning$en.internal(this._root); @@ -7684,6 +7704,9 @@ extension on Translations { 'music.repeatOne' => 'Repeat one', 'music.instantMixNoServer' => 'No server is available for an instant mix', 'music.noAudioUrl' => ({required Object track}) => 'No audio URL is available for ${track}', + 'music.discography.singlesAndEps' => 'Singles & EPs', + 'music.discography.live' => 'Live', + 'music.discography.compilations' => 'Compilations', 'watchTogether.title' => 'Watch Together', 'watchTogether.description' => 'Watch content in sync with friends and family', 'watchTogether.createSession' => 'Create Session', @@ -7935,11 +7958,11 @@ extension on Translations { 'companionRemote.errors.authenticationFailed' => 'Authentication failed', 'companionRemote.errors.serverStartFailed' => ({required Object error}) => 'Failed to start remote server: ${error}', 'companionRemote.errors.commandFailed' => ({required Object error}) => 'Failed to send remote command: ${error}', + _ => null, + } ?? switch (path) { 'companionRemote.errors.joinTimedOut' => 'Timed out joining session', 'companionRemote.errors.failedToConnectAnyAddress' => 'Failed to connect to any address', 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Connection lost after ${attempts} attempts', - _ => null, - } ?? switch (path) { 'companionRemote.errors.connectionLost' => 'Connection lost', 'companionRemote.closedBeforeAuth' => 'The connection closed before authentication', 'videoSettings.playbackSpeed' => 'Playback Speed', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 8199fa742..bb07730a6 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Upprepa alla", "repeatOne": "Upprepa en låt", "instantMixNoServer": "Ingen server är tillgänglig för en snabbmix", - "noAudioUrl": "Ingen ljud-URL är tillgänglig för ${track}" + "noAudioUrl": "Ingen ljud-URL är tillgänglig för ${track}", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Titta tillsammans", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index ed99baf9e..c61e82e35 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Tümünü tekrarla", "repeatOne": "Tekini tekrarla", "instantMixNoServer": "Anında miks için kullanılabilir sunucu yok", - "noAudioUrl": "${track} için kullanılabilir ses URL'si yok" + "noAudioUrl": "${track} için kullanılabilir ses URL'si yok", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Birlikte İzle", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 3ef29e14d..1465e9643 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -1416,7 +1416,12 @@ "repeatAll": "Barchasini takrorlash", "repeatOne": "Birtasini takrorlash", "instantMixNoServer": "Tezkor miks uchun hech qanday server mavjud emas", - "noAudioUrl": "${track} uchun audio URL mavjud emas" + "noAudioUrl": "${track} uchun audio URL mavjud emas", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "Birgalikda tomosha qilish", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 24804649c..7192abdc7 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -1406,7 +1406,12 @@ "repeatAll": "全部重複播放", "repeatOne": "單曲重複播放", "instantMixNoServer": "沒有可用於即時混音的伺服器", - "noAudioUrl": "${track} 沒有可用的音訊 URL" + "noAudioUrl": "${track} 沒有可用的音訊 URL", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "一起看", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 61dd9f824..54a490aee 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1406,7 +1406,12 @@ "repeatAll": "列表循环", "repeatOne": "单曲循环", "instantMixNoServer": "没有可用于即时混合的服务器", - "noAudioUrl": "没有可供 ${track} 使用的音频 URL" + "noAudioUrl": "没有可供 ${track} 使用的音频 URL", + "discography": { + "singlesAndEps": "", + "live": "", + "compilations": "" + } }, "watchTogether": { "title": "一起看", diff --git a/lib/media/artist_discography.dart b/lib/media/artist_discography.dart new file mode 100644 index 000000000..ef94f5632 --- /dev/null +++ b/lib/media/artist_discography.dart @@ -0,0 +1,33 @@ +import 'media_item.dart'; + +/// Release-format sections of an artist's discography, mirroring how Plex's +/// own clients split an artist's music library into Albums, Singles & EPs, +/// Live, and Compilations. Declaration order is display order. +enum DiscographyGroupKind { albums, singlesAndEps, live, compilations } + +/// One discography section: a [kind] plus the albums it contains, in server +/// order (newest first). +class ArtistDiscographyGroup { + final DiscographyGroupKind kind; + final List items; + + const ArtistDiscographyGroup({required this.kind, required this.items}); +} + +/// Partitions [albums] into ordered discography sections using the per-album +/// [kindOf] classification. Album order is preserved within each section, +/// sections appear in [DiscographyGroupKind] declaration order, and empty +/// sections are dropped. +List buildArtistDiscographyGroups( + List albums, + DiscographyGroupKind Function(MediaItem album) kindOf, +) { + final buckets = {for (final kind in DiscographyGroupKind.values) kind: []}; + for (final album in albums) { + buckets[kindOf(album)]!.add(album); + } + return [ + for (final kind in DiscographyGroupKind.values) + if (buckets[kind]!.isNotEmpty) ArtistDiscographyGroup(kind: kind, items: buckets[kind]!), + ]; +} diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 7b0ec4707..4d2f54111 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -7,6 +7,7 @@ import '../utils/app_logger.dart'; import '../utils/media_server_http_client.dart' show AbortController, MediaServerResponse, throwIfHttpError; import '../utils/external_ids.dart'; import '../utils/watch_state_notifier.dart'; +import 'artist_discography.dart'; import 'download_resolution.dart'; import 'ids.dart'; import 'library_filter_result.dart'; @@ -289,6 +290,20 @@ abstract class MediaServerClient { /// `/Items?AlbumArtistIds={id}&IncludeItemTypes=MusicAlbum`. Future> fetchArtistAlbums(MediaItem artist); + /// The artist's discography split into release-format sections (albums, + /// singles & EPs, live, compilations), in display order. Plex follows the + /// album listing with one batched by-id metadata request for the + /// `Format`/`Subformat` tags (listing rows never carry them) and classifies + /// each album; if the tag fetch fails it degrades to a single flat + /// `albums` group. + /// + /// Jellyfin/Emby `BaseItemDto`s carry no single/EP/live/compilation + /// taxonomy, so the MediaBrowser family always returns exactly one + /// `albums` group. No [ServerCapabilities] flag gates this: support is + /// encoded in the result shape (one group = no wire taxonomy) and no UI + /// affordance would consult a flag, so a flag would be dead weight. + Future> fetchArtistDiscography(MediaItem artist); + /// Tracks of album [albumId] in disc/track order. Plex: /// `/library/metadata/{id}/children`; Jellyfin: /// `/Items?AlbumIds={id}&IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber` diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 513f28f02..6c30d9072 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -170,6 +170,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu String? collectionId, VoidCallback? onListRefresh, CardShape? shape, + int indexOffset = 0, }) { return buildSparseFocusableGrid( totalItems: items.length, @@ -178,6 +179,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu collectionId: collectionId, onListRefresh: onListRefresh, shape: shape, + indexOffset: indexOffset, ); } @@ -185,6 +187,14 @@ mixin FocusableDetailScreenMixin on State, GridFocu /// slots; for each, [itemAt] returns the loaded item or null if not yet /// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so /// the caller can kick off a page fetch containing that index. + /// + /// [indexOffset] shifts this grid's slots into the screen-global focus + /// index space (focus nodes and [lastFocusedGridIndex] are keyed 0..n-1 + /// across every grid on the screen). Screens that stack several titled + /// grids pass the running total; single-grid screens keep the default 0. + /// With an offset, up-navigation from this grid's first row falls through + /// to framework traversal (crossing into the previous section's grid) + /// instead of jumping to the app bar. Widget buildSparseFocusableGrid({ required int totalItems, required MediaItem? Function(int index) itemAt, @@ -193,6 +203,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu String? collectionId, VoidCallback? onListRefresh, CardShape? shape, + int indexOffset = 0, }) { return SettingsBuilder( prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], @@ -205,12 +216,13 @@ mixin FocusableDetailScreenMixin on State, GridFocu Widget buildTile(MediaCardSliverPosition position) { final index = position.index; + final globalIndex = index + indexOffset; final item = itemAt(index); if (item == null) { onSkeletonVisible?.call(index); return const SkeletonMediaCard(); } - final focusNode = _focusNodeForIndex(index); + final focusNode = _focusNodeForIndex(globalIndex); return FocusableMediaCard( key: Key(item.id), item: item, @@ -222,9 +234,12 @@ mixin FocusableDetailScreenMixin on State, GridFocu onListRefresh: onListRefresh, fullBleedImage: useFullCardLayout && position.isGrid, cardShapeOverride: shape, - onNavigateUp: position.isFirstRow ? navigateToAppBar : null, + // The first section's first row reaches the app bar; later + // sections fall through to traversal, which enters the previous + // section's grid. + onNavigateUp: position.isFirstRow && indexOffset == 0 ? navigateToAppBar : null, onBack: handleBackFromContent, - onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), + onFocusChange: (hasFocus) => trackGridItemFocus(globalIndex, hasFocus), ); } diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart index 5f651aa46..c8d072b64 100644 --- a/lib/screens/music/artist_detail_screen.dart +++ b/lib/screens/music/artist_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../focus/focusable_action_bar.dart'; +import '../../media/artist_discography.dart'; import '../../i18n/strings.g.dart'; import '../../media/ids.dart'; import '../../media/media_item.dart'; @@ -15,6 +16,7 @@ import '../../utils/formatters.dart'; import '../../utils/error_message_utils.dart'; import '../../utils/media_image_helper.dart'; import '../../utils/music_navigation.dart'; +import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/collapsible_text.dart'; @@ -45,6 +47,12 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen { final FocusNode _bioFocusNode = FocusNode(debugLabel: 'artist_bio'); + /// Discography sections in display order. A single flat `albums` group + /// (Plex with no singles/live/compilations, or any Jellyfin/Emby artist) + /// renders the flat grid. The sections mirror the concatenation order of + /// [items], so [updateItem]'s in-place swap stays visible. + List _discographyGroups = const []; + @override Object get mediaItem => widget.artist; @@ -58,7 +66,13 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen items.isNotEmpty; @override - Future> fetchItems() => mediaClient.fetchArtistAlbums(widget.artist); + Future> fetchItems() async { + final groups = await mediaClient.fetchArtistDiscography(widget.artist); + _discographyGroups = groups; + // Flat concatenation keeps BaseMediaListDetailScreen/StandardItemLoader/ + // updateItem working against one list; the grouped slivers slice it. + return [for (final group in groups) ...group.items]; + } @override Future loadItems() async { @@ -168,6 +182,52 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen _buildDiscographySlivers(BuildContext context) { + if (_discographyGroups.length <= 1) { + return [buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square)]; + } + + final sectionTitleStyle = Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: .bold, fontSize: PlatformDetector.isTV() ? 28 : null); + + final slivers = []; + var offset = 0; + for (final group in _discographyGroups) { + final groupItems = items.sublist(offset, offset + group.items.length); + final sectionOffset = offset; + offset += group.items.length; + slivers.add( + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text(_discographyGroupTitle(group.kind), style: sectionTitleStyle), + ), + ), + ); + slivers.add( + buildFocusableGrid( + items: groupItems, + onRefresh: updateItem, + shape: CardShape.square, + indexOffset: sectionOffset, + ), + ); + } + return slivers; + } + + String _discographyGroupTitle(DiscographyGroupKind kind) => switch (kind) { + DiscographyGroupKind.albums => t.libraries.groupings.albums, + DiscographyGroupKind.singlesAndEps => t.music.discography.singlesAndEps, + DiscographyGroupKind.live => t.music.discography.live, + DiscographyGroupKind.compilations => t.music.discography.compilations, + }; + @override Widget build(BuildContext context) { return buildDetailScaffold( @@ -176,7 +236,7 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen()?.overlayHeight ?? 0)), ], diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 3fc481a68..2a17d5bee 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import '../connection/connection.dart'; +import '../media/artist_discography.dart'; import '../media/episode_collection.dart'; import '../media/library_filter_result.dart'; import '../media/library_first_character.dart'; diff --git a/lib/services/jellyfin_client/parts/music.dart b/lib/services/jellyfin_client/parts/music.dart index 1d3aafab4..cf9cb9da5 100644 --- a/lib/services/jellyfin_client/parts/music.dart +++ b/lib/services/jellyfin_client/parts/music.dart @@ -28,6 +28,16 @@ mixin _JellyfinMusicMethods on _JellyfinClientInternals { return _mapItems(_itemsArray(response.data)); } + /// MediaBrowser `BaseItemDto`s carry no single/EP/live/compilation + /// taxonomy, so the discography is a single albums group — the neutral + /// contract's MediaBrowser-family shape. + @override + Future> fetchArtistDiscography(MediaItem artist) async { + final albums = await fetchArtistAlbums(artist); + if (albums.isEmpty) return const []; + return [ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: albums)]; + } + /// Tracks of [albumId] in disc/track order. `AlbumIds` (not `ParentId`) so /// tag-based albums whose files share one physical folder still resolve; /// `ParentIndexNumber,IndexNumber` yields correct multi-disc ordering. diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 562bad8ec..bf3fb3bc8 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:uuid/uuid.dart'; +import '../media/artist_discography.dart'; import '../media/download_resolution.dart'; import '../media/episode_collection.dart'; import '../media/library_filter_result.dart'; @@ -3389,6 +3390,65 @@ class PlexClient return (metadata ?? const []).map((item) => PlexMappers.mediaItem(item)).toList(); } + /// Grouped discography for [artist]: albums, singles & EPs, live, and + /// compilations. Plex listing rows never carry `Format`/`Subformat` tags + /// (even with `resolveTags=1`), so the album list is followed by one + /// batched `/library/metadata/{ids}` detail request, whose rows do include + /// them; each album is then classified individually. A failed tag fetch + /// degrades to the flat albums list rather than sinking the screen. + @override + Future> fetchArtistDiscography(MediaItem artist) async { + final albums = await fetchArtistAlbums(artist); + // With one album at most, grouping is invisible (a single section renders + // as the flat grid), so the tag lookup is pure overhead. + if (albums.length <= 1) { + return [if (albums.isNotEmpty) ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: albums)]; + } + + final Map kinds; + try { + kinds = await _fetchDiscographyKinds(artist.id, [for (final album in albums) album.id]); + } catch (e) { + appLogger.w('Discography tag fetch failed for artist ${artist.id}, degrading to a flat albums list', error: e); + return [ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: albums)]; + } + return buildArtistDiscographyGroups(albums, (album) => kinds[album.id] ?? DiscographyGroupKind.albums); + } + + /// Ids per batched `/library/metadata/{ids}` request. Plex ids are short, + /// so 100 keeps the URL well under proxy limits. + static const _discographyTagChunkSize = 100; + + /// Fetches `Format`/`Subformat` tags for [albumIds] via batched by-id + /// metadata requests and classifies each album. Explicit container bounds + /// defeat any server-side default page cap; a missing row simply leaves + /// that album in the default section. + Future> _fetchDiscographyKinds(String artistId, List albumIds) async { + final chunks = [ + for (var i = 0; i < albumIds.length; i += _discographyTagChunkSize) + albumIds.sublist( + i, + i + _discographyTagChunkSize > albumIds.length ? albumIds.length : i + _discographyTagChunkSize, + ), + ]; + final results = await Future.wait([ + for (final (index, chunk) in chunks.indexed) + fetchWithCacheFallback>( + cacheKey: '/library/metadata/$artistId/discography-tags/$index', + networkCall: () => _http.get( + '/library/metadata/${chunk.join(',')}', + queryParameters: {'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': chunk.length}, + ), + parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), + parseResponse: (response) => _extractMetadataList(response), + ), + ]); + return { + for (final metadata in results) + for (final dto in metadata ?? const []) dto.ratingKey: PlexMappers.discographyKind(dto), + }; + } + @override Future> fetchAlbumTracks(String albumId) => fetchChildren(albumId); diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 392dcd4af..f55b3ed26 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -12,6 +12,7 @@ // resolution and server-tagging. import 'package:json_annotation/json_annotation.dart'; +import '../media/artist_discography.dart'; import '../media/ids.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../i18n/strings.g.dart'; @@ -637,6 +638,10 @@ class PlexMetadataDto { final List? style; @JsonKey(name: 'Mood', fromJson: _tagListFromJson, includeToJson: false) final List? mood; + @JsonKey(name: 'Format', fromJson: _tagListFromJson, includeToJson: false) + final List? format; + @JsonKey(name: 'Subformat', fromJson: _tagListFromJson, includeToJson: false) + final List? subformat; final String? audioLanguage; final String? subtitleLanguage; @JsonKey(fromJson: flexibleInt) @@ -718,6 +723,8 @@ class PlexMetadataDto { this.label, this.style, this.mood, + this.format, + this.subformat, this.audioLanguage, this.subtitleLanguage, this.subtitleMode, @@ -843,6 +850,8 @@ class PlexMetadataDto { List? label, List? style, List? mood, + List? format, + List? subformat, String? audioLanguage, String? subtitleLanguage, int? subtitleMode, @@ -915,6 +924,8 @@ class PlexMetadataDto { label: label ?? this.label, style: style ?? this.style, mood: mood ?? this.mood, + format: format ?? this.format, + subformat: subformat ?? this.subformat, audioLanguage: audioLanguage ?? this.audioLanguage, subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage, subtitleMode: subtitleMode ?? this.subtitleMode, @@ -1041,6 +1052,19 @@ class PlexMappers { return mediaItem(dto); } + /// Plex's release-format taxonomy for an album row. `Format` tags EP/Single + /// mark Singles & EPs; otherwise `Subformat` live/compilation mark those + /// sections; everything else is a standard album. Matching is + /// case-insensitive, mirroring Plex's own filter semantics. + static DiscographyGroupKind discographyKind(PlexMetadataDto dto) { + bool hasTag(List? tags, Set wanted) => + tags?.any((tag) => wanted.contains(tag.toLowerCase())) ?? false; + if (hasTag(dto.format, const {'ep', 'single'})) return DiscographyGroupKind.singlesAndEps; + if (hasTag(dto.subformat, const {'live'})) return DiscographyGroupKind.live; + if (hasTag(dto.subformat, const {'compilation'})) return DiscographyGroupKind.compilations; + return DiscographyGroupKind.albums; + } + /// Map a parsed [PlexMetadataDto] into a [PlexMediaItem]. static PlexMediaItem mediaItem(PlexMetadataDto dto) { final kind = MediaKind.fromString(dto.type); diff --git a/lib/services/plex_mappers.g.dart b/lib/services/plex_mappers.g.dart index 9ee1709dc..b8f55bf63 100644 --- a/lib/services/plex_mappers.g.dart +++ b/lib/services/plex_mappers.g.dart @@ -143,6 +143,8 @@ PlexMetadataDto _$PlexMetadataDtoFromJson(Map json) => label: _tagListFromJson(json['Label']), style: _tagListFromJson(json['Style']), mood: _tagListFromJson(json['Mood']), + format: _tagListFromJson(json['Format']), + subformat: _tagListFromJson(json['Subformat']), audioLanguage: json['audioLanguage'] as String?, subtitleLanguage: json['subtitleLanguage'] as String?, subtitleMode: flexibleInt(json['subtitleMode']), diff --git a/test/media/artist_discography_test.dart b/test/media/artist_discography_test.dart new file mode 100644 index 000000000..12cd3576b --- /dev/null +++ b/test/media/artist_discography_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/artist_discography.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; + +void main() { + MediaItem album(String id) => MediaItem.plex(id: id, kind: MediaKind.album, title: id); + + final kinds = { + 'ep-1': DiscographyGroupKind.singlesAndEps, + 'live-1': DiscographyGroupKind.live, + 'comp-1': DiscographyGroupKind.compilations, + }; + DiscographyGroupKind kindOf(MediaItem item) => kinds[item.id] ?? DiscographyGroupKind.albums; + + test('partitions albums into sections in display order', () { + final groups = buildArtistDiscographyGroups([ + album('lp-1'), + album('ep-1'), + album('live-1'), + album('comp-1'), + album('lp-2'), + ], kindOf); + + expect(groups.map((group) => group.kind), [ + DiscographyGroupKind.albums, + DiscographyGroupKind.singlesAndEps, + DiscographyGroupKind.live, + DiscographyGroupKind.compilations, + ]); + expect(groups[0].items.map((item) => item.id), ['lp-1', 'lp-2']); + expect(groups[1].items.map((item) => item.id), ['ep-1']); + expect(groups[2].items.map((item) => item.id), ['live-1']); + expect(groups[3].items.map((item) => item.id), ['comp-1']); + }); + + test('preserves album order within each section', () { + final groups = buildArtistDiscographyGroups([album('lp-3'), album('ep-1'), album('lp-1'), album('lp-2')], kindOf); + + expect(groups[0].items.map((item) => item.id), ['lp-3', 'lp-1', 'lp-2']); + expect(groups[1].items.map((item) => item.id), ['ep-1']); + }); + + test('drops empty sections and returns an empty list for no albums', () { + final groups = buildArtistDiscographyGroups([album('lp-1'), album('live-1')], kindOf); + expect(groups.map((group) => group.kind), [DiscographyGroupKind.albums, DiscographyGroupKind.live]); + + expect(buildArtistDiscographyGroups(const [], kindOf), isEmpty); + }); +} diff --git a/test/screens/music/artist_detail_screen_test.dart b/test/screens/music/artist_detail_screen_test.dart new file mode 100644 index 000000000..dcf53808f --- /dev/null +++ b/test/screens/music/artist_detail_screen_test.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/artist_discography.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/music/artist_detail_screen.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/music/music_playback_service.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/widgets/focusable_media_card.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/prefs.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; +import '../../test_helpers/stub_music_playback_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + const artist = MediaItem.plex( + id: 'artist_1', + kind: MediaKind.artist, + title: 'Test Artist', + serverId: 'server_1', + serverName: 'Server', + ); + + MediaItem album(String id) => MediaItem.plex( + id: id, + kind: MediaKind.album, + title: id, + parentId: 'artist_1', + parentTitle: 'Test Artist', + serverId: 'server_1', + serverName: 'Server', + ); + + testWidgets('multiple groups render titled sections in order', (tester) async { + // Tall viewport so every lazily-built section sliver is inside the + // cache extent; the default 600px fits only the first two sections. + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final harness = await _createHarness([ + ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: [album('album-a1')]), + ArtistDiscographyGroup(kind: DiscographyGroupKind.singlesAndEps, items: [album('album-s1')]), + ArtistDiscographyGroup(kind: DiscographyGroupKind.live, items: [album('album-l1')]), + ArtistDiscographyGroup(kind: DiscographyGroupKind.compilations, items: [album('album-c1')]), + ]); + + await tester.pumpWidget(harness.wrap(const ArtistDetailScreen(artist: artist))); + await tester.pumpAndSettle(); + + expect(find.byType(FocusableMediaCard), findsNWidgets(4)); + final titles = ['Albums', 'Singles & EPs', 'Live', 'Compilations']; + for (final title in titles) { + expect(find.text(title), findsOneWidget); + } + final dy = [for (final title in titles) tester.getTopLeft(find.text(title)).dy]; + expect(dy, List.of(dy)..sort(), reason: 'sections must render in display order'); + }); + + testWidgets('a single group renders the flat grid without section headers', (tester) async { + final harness = await _createHarness([ + ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: [album('album-a1'), album('album-a2')]), + ]); + + await tester.pumpWidget(harness.wrap(const ArtistDetailScreen(artist: artist))); + await tester.pumpAndSettle(); + + expect(find.byType(FocusableMediaCard), findsNWidgets(2)); + expect(find.text('Albums'), findsNothing); + expect(find.text('Singles & EPs'), findsNothing); + }); + + testWidgets('d-pad up from a later section enters the previous section grid', (tester) async { + final harness = await _createHarness([ + ArtistDiscographyGroup(kind: DiscographyGroupKind.albums, items: [album('album-a1')]), + ArtistDiscographyGroup(kind: DiscographyGroupKind.singlesAndEps, items: [album('album-s1')]), + ]); + + await tester.pumpWidget(harness.wrap(const ArtistDetailScreen(artist: artist))); + await tester.pumpAndSettle(); + + final cards = find.byType(FocusableMediaCard); + // Section 2's card: global index 1, its own focus node. + tester.widget(cards.at(1)).focusNode!.requestFocus(); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_grid_item_1'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_first_item'); + + // The first section's first row still exits the grid upward, landing on + // the header action bar's play button — the flat screen's pre-existing + // navigateToAppBar contract. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'music_play'); + }); +} + +Future<_ArtistHarness> _createHarness(List groups) async { + await SettingsService.getInstance(); + + final client = _FakeDiscographyClient(groups); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = testMultiServerProvider(manager); + + addTearDown(multiServerProvider.dispose); + + return _ArtistHarness(client: client, multiServerProvider: multiServerProvider); +} + +class _ArtistHarness { + final _FakeDiscographyClient client; + final MultiServerProvider multiServerProvider; + + const _ArtistHarness({required this.client, required this.multiServerProvider}); + + Widget wrap(Widget child) { + return TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider(create: (_) => StubMusicPlaybackService()), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: SizedBox(width: 1280, height: 720, child: child), + ), + ), + ); + } +} + +class _FakeDiscographyClient implements MediaServerClient { + final List groups; + final List fetchedArtistIds = []; + + _FakeDiscographyClient(this.groups); + + @override + ServerId get serverId => ServerId('server_1'); + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + Future> fetchArtistDiscography(MediaItem artist) async { + fetchedArtistIds.add(artist.id); + return groups; + } + + @override + String thumbnailUrl(String? path, {int? width, int? height, bool cover = true}) => ''; + + @override + void close() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index b0ab73cd2..5f0273005 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/connection/connection.dart'; +import 'package:plezy/media/artist_discography.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -3015,6 +3016,30 @@ void main() { expect(albumTracks['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName'); }); + test('fetchArtistDiscography wraps the album listing in one albums group', () async { + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + return jsonResponse({ + 'Items': [ + {'Id': 'album-1', 'Type': 'MusicAlbum', 'Name': 'Album 1'}, + {'Id': 'album-2', 'Type': 'MusicAlbum', 'Name': 'Album 2'}, + ], + 'TotalRecordCount': 2, + }); + }), + ); + addTearDown(scoped.close); + + final groups = await scoped.fetchArtistDiscography( + testMediaItem(id: 'artist-1', backend: MediaBackend.jellyfin, kind: MediaKind.artist), + ); + + expect(groups, hasLength(1)); + expect(groups.single.kind, DiscographyGroupKind.albums); + expect(groups.single.items.map((item) => item.id), ['album-1', 'album-2']); + }); + test('fetchLibraryFiltersWithValues adds unwatched boolean filter', () async { Uri? captured; final scoped = JellyfinClient.forTesting( diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index e93399c74..4b6fe86e4 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/testing.dart'; import 'package:http/http.dart' as http; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/artist_discography.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; @@ -1066,6 +1067,191 @@ void main() { expect(albums.map((album) => album.id), ['album-1']); }); + test('fetchArtistDiscography fetches tags in one batch and classifies per album', () async { + const baseKey = '/library/metadata/artist-1/children'; + final requests = []; + final client = makeClient((request) async { + requests.add(request.url); + if (request.url.path == '/library/sections/7/all') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'librarySectionID': 7, + 'size': 5, + 'totalSize': 5, + 'Metadata': [ + for (final id in ['album-1', 'album-2', 'album-ep', 'album-live', 'album-comp']) + {'ratingKey': id, 'type': 'album', 'title': id}, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + // Batched by-id metadata request: the only response shape that carries + // Format/Subformat. `album-ep` is deliberately dual-tagged to pin the + // singles-over-live precedence, and tag casing is mixed to pin + // case-insensitive matching. + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'size': 5, + 'Metadata': [ + {'ratingKey': 'album-1', 'type': 'album', 'title': 'album-1'}, + {'ratingKey': 'album-2', 'type': 'album', 'title': 'album-2'}, + { + 'ratingKey': 'album-ep', + 'type': 'album', + 'title': 'album-ep', + 'Format': [ + {'tag': 'EP'}, + ], + 'Subformat': [ + {'tag': 'Live'}, + ], + }, + { + 'ratingKey': 'album-live', + 'type': 'album', + 'title': 'album-live', + 'Subformat': [ + {'tag': 'Live'}, + ], + }, + { + 'ratingKey': 'album-comp', + 'type': 'album', + 'title': 'album-comp', + 'Subformat': [ + {'tag': 'compilation'}, + ], + }, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final groups = await client.fetchArtistDiscography( + testMediaItem(id: 'artist-1', kind: MediaKind.artist, libraryId: '7'), + ); + + // Exactly two requests: the unchanged album listing plus one batched + // by-id tag lookup with explicit container bounds. + expect(requests, hasLength(2)); + expect(requests[0].path, '/library/sections/7/all'); + expect(requests[0].queryParameters['type'], '9'); + expect(requests[0].queryParameters['artist.id'], 'artist-1'); + expect(requests[0].queryParameters['sort'], 'album.year:desc'); + expect(Uri.decodeComponent(requests[1].path), '/library/metadata/album-1,album-2,album-ep,album-live,album-comp'); + expect(requests[1].queryParameters['X-Plex-Container-Start'], '0'); + expect(requests[1].queryParameters['X-Plex-Container-Size'], '5'); + + expect(groups.map((group) => group.kind), [ + DiscographyGroupKind.albums, + DiscographyGroupKind.singlesAndEps, + DiscographyGroupKind.live, + DiscographyGroupKind.compilations, + ]); + expect(groups.map((group) => group.items.map((item) => item.id).toList()), [ + ['album-1', 'album-2'], + ['album-ep'], + ['album-live'], + ['album-comp'], + ]); + + // The listing keeps the fetchArtistAlbums cache identity; the tag batch + // gets its own row. + final cache = PlexApiCache.instance; + Future> cachedRatingKeys(String key) async { + final cached = await cache.get(defaultProfileScopeId.cacheServerId, key); + final container = cached!['MediaContainer'] as Map; + return [ + for (final item in container['Metadata'] as List) + (item as Map)['ratingKey'] as String, + ]; + } + + expect(await cachedRatingKeys(baseKey), ['album-1', 'album-2', 'album-ep', 'album-live', 'album-comp']); + expect(await cachedRatingKeys('/library/metadata/artist-1/discography-tags/0'), [ + 'album-1', + 'album-2', + 'album-ep', + 'album-live', + 'album-comp', + ]); + }); + + test('fetchArtistDiscography degrades to a flat albums group when the tag batch fails', () async { + final requests = []; + final client = makeClient((request) async { + requests.add(request.url); + if (request.url.path != '/library/sections/7/all') { + return http.Response('boom', 500, headers: const {'content-type': 'application/json'}); + } + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'librarySectionID': 7, + 'size': 2, + 'totalSize': 2, + 'Metadata': [ + {'ratingKey': 'album-1', 'type': 'album', 'title': 'Album 1'}, + {'ratingKey': 'album-2', 'type': 'album', 'title': 'Album 2'}, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final groups = await client.fetchArtistDiscography( + testMediaItem(id: 'artist-1', kind: MediaKind.artist, libraryId: '7'), + ); + + expect(groups, hasLength(1)); + expect(groups.single.kind, DiscographyGroupKind.albums); + expect(groups.single.items.map((item) => item.id), ['album-1', 'album-2']); + }); + + test('fetchArtistDiscography skips the tag batch for a single-album artist', () async { + final requests = []; + final client = makeClient((request) async { + requests.add(request.url); + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'librarySectionID': 7, + 'size': 1, + 'totalSize': 1, + 'Metadata': [ + {'ratingKey': 'album-1', 'type': 'album', 'title': 'Album 1'}, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final groups = await client.fetchArtistDiscography( + testMediaItem(id: 'artist-1', kind: MediaKind.artist, libraryId: '7'), + ); + + // Grouping is invisible with one album, so no tag lookup is issued. + expect(requests, hasLength(1)); + expect(requests.single.path, '/library/sections/7/all'); + expect(groups.single.kind, DiscographyGroupKind.albums); + expect(groups.single.items.map((item) => item.id), ['album-1']); + }); + test('profile transition isolates metadata and every direct cache-only bypass', () async { final scopeA = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-a'); final scopeB = buildPlexProfileScopeId(serverId: publicServerId, profileId: 'profile-b');