From f869dfe87ad78704ea26a665fe69584369733cce Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 8 Jul 2026 18:26:52 +0200 Subject: [PATCH] feat(api): GET /api/collections/{id}/items (paged) + confirm POST-items 422 guard (#155) Adds a paged collection-items endpoint reusing LibraryBrowseItemResponseModel so the SPA lists a manual collection's full contents (all media kinds), replacing the lossy Lucene name-based preview. Confirms POST /items already returns 422 for bogus ids (guarded by ValidateMediaItems, fb3f2856); adds endpoint-level coverage. fixes #155 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../LibraryBrowse/LibraryBrowseItemMapper.cs | 620 +++++++++++++++++ .../Queries/GetLibraryBrowseItemsHandler.cs | 644 ++---------------- .../Queries/GetCollectionItems.cs | 7 + .../Queries/GetCollectionItemsHandler.cs | 56 ++ .../CollectionHandlerTests.cs | 90 +++ .../Controllers/CollectionControllerTests.cs | 29 + .../OpenApiErrorResponseContractTests.cs | 1 + .../Controllers/Api/CollectionController.cs | 23 + ErsatzTV/wwwroot/openapi/v1.json | 79 +++ docs/blazor-route-parity.md | 10 +- web/src/api/collections.test.ts | 32 +- web/src/api/collections.ts | 32 +- web/src/screens/CollectionsScreen.test.tsx | 34 +- web/src/screens/CollectionsScreen.tsx | 54 +- 14 files changed, 1045 insertions(+), 666 deletions(-) create mode 100644 ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetCollectionItems.cs create mode 100644 ErsatzTV.Application/MediaCollections/Queries/GetCollectionItemsHandler.cs diff --git a/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs b/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs new file mode 100644 index 000000000..245101502 --- /dev/null +++ b/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs @@ -0,0 +1,620 @@ +using ErsatzTV.Core.Api.LibraryBrowse; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Jellyfin; +using ErsatzTV.Infrastructure.Data; +using Flurl; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.LibraryBrowse; + +// Shared MediaItem -> LibraryBrowseItemResponseModel projection used by both the library-browse +// search handler and the collection-items handler (#155). Keeping the per-kind hydration and the +// rooted-artwork logic in one place avoids duplicating the Blazor-vs-SPA artwork rooting rules +// (see the Artwork helper below and docs/api-conventions.md §4). +internal static class LibraryBrowseItemMapper +{ + // Hydrates an arbitrary set of media item ids (any kinds mixed) into response models. MediaItem + // ids are globally unique across kinds, so passing the full id list to every per-kind query is + // safe: each query only matches its own kind. Callers order/page the result themselves. + public static async Task> HydrateMediaItemsByIds( + TvContext dbContext, + IReadOnlyList ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + var idList = ids.Distinct().ToList(); + + var results = new List(); + results.AddRange(await GetMovies(dbContext, idList, cancellationToken)); + results.AddRange(await GetShows(dbContext, idList, cancellationToken)); + results.AddRange(await GetSeasons(dbContext, idList, cancellationToken)); + results.AddRange(await GetArtists(dbContext, idList, cancellationToken)); + results.AddRange(await GetEpisodes(dbContext, idList, cancellationToken)); + results.AddRange(await GetMusicVideos(dbContext, idList, cancellationToken)); + results.AddRange(await GetSongs(dbContext, idList, cancellationToken)); + results.AddRange(await GetOtherVideos(dbContext, idList, cancellationToken)); + results.AddRange(await GetImages(dbContext, idList, cancellationToken)); + results.AddRange(await GetRemoteStreams(dbContext, idList, cancellationToken)); + return results; + } + + public static async Task> GetMovies( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + return await dbContext.MovieMetadata + .AsNoTracking() + .Where(mm => ids.Contains(mm.MovieId)) + .Include(mm => mm.Artwork) + .Include(mm => mm.Movie) + .ThenInclude(m => m.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(mm => mm.Movie) + .ThenInclude(m => m.MediaVersions) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(mm => mm.MovieId) + .Select(g => g.OrderBy(mm => mm.Id).First()) + .Map(mm => new LibraryBrowseItemResponseModel( + mm.MovieId, + LibraryBrowseMediaType.Movie, + mm.Title ?? string.Empty, + mm.Movie.LibraryPath.LibraryId, + mm.Movie.LibraryPath.Library.Name, + Artwork(mm, ArtworkKind.Poster), + BestDuration(mm.Movie.MediaVersions), + 1, + null, + CollectionType.Movie, + null, + null, + null, + null, + mm.MovieId, + null)).ToList()); + } + + public static async Task> GetShows( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + Dictionary counts = await dbContext.Episodes + .AsNoTracking() + .Where(e => ids.Contains(e.Season.ShowId)) + .GroupBy(e => e.Season.ShowId) + .Select(g => new { ShowId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken); + + return await dbContext.ShowMetadata + .AsNoTracking() + .Where(sm => ids.Contains(sm.ShowId)) + .Include(sm => sm.Artwork) + .Include(sm => sm.Show) + .ThenInclude(s => s.LibraryPath) + .ThenInclude(lp => lp.Library) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(sm => sm.ShowId) + .Select(g => g.OrderBy(sm => sm.Id).First()) + .Map(sm => new LibraryBrowseItemResponseModel( + sm.ShowId, + LibraryBrowseMediaType.TelevisionShow, + sm.Title ?? string.Empty, + sm.Show.LibraryPath.LibraryId, + sm.Show.LibraryPath.Library.Name, + Artwork(sm, ArtworkKind.Poster), + null, + counts.TryGetValue(sm.ShowId, out int count) ? count : 0, + null, + CollectionType.TelevisionShow, + null, + null, + null, + null, + sm.ShowId, + null)).ToList()); + } + + public static async Task> GetSeasons( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + Dictionary counts = await dbContext.Episodes + .AsNoTracking() + .Where(e => ids.Contains(e.SeasonId)) + .GroupBy(e => e.SeasonId) + .Select(g => new { SeasonId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken); + + return await dbContext.SeasonMetadata + .AsNoTracking() + .Where(sm => ids.Contains(sm.SeasonId)) + .Include(sm => sm.Artwork) + .Include(sm => sm.Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(shm => shm.Artwork) + .Include(sm => sm.Season) + .ThenInclude(s => s.LibraryPath) + .ThenInclude(lp => lp.Library) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(sm => sm.SeasonId) + .Select(g => g.OrderBy(sm => sm.Id).First()) + .Map(sm => new LibraryBrowseItemResponseModel( + sm.SeasonId, + LibraryBrowseMediaType.TelevisionSeason, + SeasonTitle(sm), + sm.Season.LibraryPath.LibraryId, + sm.Season.LibraryPath.Library.Name, + SeasonArtwork(sm), + null, + counts.TryGetValue(sm.SeasonId, out int count) ? count : 0, + null, + CollectionType.TelevisionSeason, + null, + null, + null, + null, + sm.SeasonId, + null)).ToList()); + } + + public static async Task> GetArtists( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + Dictionary counts = await dbContext.MusicVideos + .AsNoTracking() + .Where(mv => ids.Contains(mv.ArtistId)) + .GroupBy(mv => mv.ArtistId) + .Select(g => new { ArtistId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken); + + return await dbContext.ArtistMetadata + .AsNoTracking() + .Where(am => ids.Contains(am.ArtistId)) + .Include(am => am.Artwork) + .Include(am => am.Artist) + .ThenInclude(a => a.LibraryPath) + .ThenInclude(lp => lp.Library) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(am => am.ArtistId) + .Select(g => g.OrderBy(am => am.Id).First()) + .Map(am => new LibraryBrowseItemResponseModel( + am.ArtistId, + LibraryBrowseMediaType.Artist, + am.Title ?? string.Empty, + am.Artist.LibraryPath.LibraryId, + am.Artist.LibraryPath.Library.Name, + Artwork(am, ArtworkKind.Thumbnail), + null, + counts.TryGetValue(am.ArtistId, out int count) ? count : 0, + null, + CollectionType.Artist, + null, + null, + null, + null, + am.ArtistId, + null)).ToList()); + } + + public static async Task> GetEpisodes( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + return await dbContext.EpisodeMetadata + .AsNoTracking() + .Where(em => ids.Contains(em.EpisodeId)) + .Include(em => em.Artwork) + .Include(em => em.Episode) + .ThenInclude(e => e.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(em => em.Episode) + .ThenInclude(e => e.MediaVersions) + .Include(em => em.Episode) + .ThenInclude(e => e.Season) + .ThenInclude(s => s.Show) + .ThenInclude(sh => sh.ShowMetadata) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(em => em.EpisodeId) + .Select(g => g.OrderBy(em => em.Id).First()) + .Map(em => new LibraryBrowseItemResponseModel( + em.EpisodeId, + LibraryBrowseMediaType.Episode, + em.Title ?? string.Empty, + em.Episode.LibraryPath.LibraryId, + em.Episode.LibraryPath.Library.Name, + ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster), + BestDuration(em.Episode.MediaVersions), + 1, + null, + CollectionType.Episode, + null, + null, + null, + null, + em.EpisodeId, + null, + EpisodeSubtitle(em))).ToList()); + } + + public static async Task> GetMusicVideos( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + return await dbContext.MusicVideoMetadata + .AsNoTracking() + .Where(mvm => ids.Contains(mvm.MusicVideoId)) + .Include(mvm => mvm.Artwork) + .Include(mvm => mvm.MusicVideo) + .ThenInclude(mv => mv.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(mvm => mvm.MusicVideo) + .ThenInclude(mv => mv.MediaVersions) + .Include(mvm => mvm.MusicVideo) + .ThenInclude(mv => mv.Artist) + .ThenInclude(a => a.ArtistMetadata) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(mvm => mvm.MusicVideoId) + .Select(g => g.OrderBy(mvm => mvm.Id).First()) + .Map(mvm => new LibraryBrowseItemResponseModel( + mvm.MusicVideoId, + LibraryBrowseMediaType.MusicVideo, + mvm.Title ?? string.Empty, + mvm.MusicVideo.LibraryPath.LibraryId, + mvm.MusicVideo.LibraryPath.Library.Name, + ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster), + BestDuration(mvm.MusicVideo.MediaVersions), + 1, + null, + CollectionType.MusicVideo, + null, + null, + null, + null, + mvm.MusicVideoId, + null, + MusicVideoSubtitle(mvm))).ToList()); + } + + public static async Task> GetSongs( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + return await dbContext.SongMetadata + .AsNoTracking() + .Where(sm => ids.Contains(sm.SongId)) + .Include(sm => sm.Artwork) + .Include(sm => sm.Song) + .ThenInclude(s => s.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(sm => sm.Song) + .ThenInclude(s => s.MediaVersions) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(sm => sm.SongId) + .Select(g => g.OrderBy(sm => sm.Id).First()) + .Map(sm => new LibraryBrowseItemResponseModel( + sm.SongId, + LibraryBrowseMediaType.Song, + sm.Title ?? string.Empty, + sm.Song.LibraryPath.LibraryId, + sm.Song.LibraryPath.Library.Name, + ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster), + BestDuration(sm.Song.MediaVersions), + 1, + null, + CollectionType.Song, + null, + null, + null, + null, + sm.SongId, + null, + SongSubtitle(sm))).ToList()); + } + + public static async Task> GetOtherVideos( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + return await dbContext.OtherVideoMetadata + .AsNoTracking() + .Where(ovm => ids.Contains(ovm.OtherVideoId)) + .Include(ovm => ovm.Artwork) + .Include(ovm => ovm.OtherVideo) + .ThenInclude(ov => ov.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(ovm => ovm.OtherVideo) + .ThenInclude(ov => ov.MediaVersions) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(ovm => ovm.OtherVideoId) + .Select(g => g.OrderBy(ovm => ovm.Id).First()) + .Map(ovm => new LibraryBrowseItemResponseModel( + ovm.OtherVideoId, + LibraryBrowseMediaType.OtherVideo, + ovm.Title ?? string.Empty, + ovm.OtherVideo.LibraryPath.LibraryId, + ovm.OtherVideo.LibraryPath.Library.Name, + ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster), + BestDuration(ovm.OtherVideo.MediaVersions), + 1, + null, + CollectionType.OtherVideo, + null, + null, + null, + null, + ovm.OtherVideoId, + null, + string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList()); + } + + public static async Task> GetImages( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + return await dbContext.ImageMetadata + .AsNoTracking() + .Where(im => ids.Contains(im.ImageId)) + .Include(im => im.Artwork) + .Include(im => im.Image) + .ThenInclude(i => i.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(im => im.Image) + .ThenInclude(i => i.MediaVersions) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(im => im.ImageId) + .Select(g => g.OrderBy(im => im.Id).First()) + .Map(im => new LibraryBrowseItemResponseModel( + im.ImageId, + LibraryBrowseMediaType.Image, + im.Title ?? string.Empty, + im.Image.LibraryPath.LibraryId, + im.Image.LibraryPath.Library.Name, + ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail), + BestDuration(im.Image.MediaVersions), + 1, + null, + CollectionType.Image, + null, + null, + null, + null, + im.ImageId, + null, + string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList()); + } + + public static async Task> GetRemoteStreams( + TvContext dbContext, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + return await dbContext.RemoteStreamMetadata + .AsNoTracking() + .Where(rsm => ids.Contains(rsm.RemoteStreamId)) + .Include(rsm => rsm.Artwork) + .Include(rsm => rsm.RemoteStream) + .ThenInclude(rs => rs.LibraryPath) + .ThenInclude(lp => lp.Library) + .Include(rsm => rsm.RemoteStream) + .ThenInclude(rs => rs.MediaVersions) + .ToListAsync(cancellationToken) + .Map(list => list + .GroupBy(rsm => rsm.RemoteStreamId) + .Select(g => g.OrderBy(rsm => rsm.Id).First()) + .Map(rsm => new LibraryBrowseItemResponseModel( + rsm.RemoteStreamId, + LibraryBrowseMediaType.RemoteStream, + rsm.Title ?? string.Empty, + rsm.RemoteStream.LibraryPath.LibraryId, + rsm.RemoteStream.LibraryPath.Library.Name, + ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster), + BestDuration(rsm.RemoteStream.MediaVersions), + 1, + null, + CollectionType.RemoteStream, + null, + null, + null, + null, + rsm.RemoteStreamId, + null, + string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList()); + } + + public static TimeSpan? BestDuration(IEnumerable versions) + { + TimeSpan duration = versions + .Select(v => v.Duration) + .Where(d => d > TimeSpan.Zero) + .DefaultIfEmpty() + .Max(); + return duration > TimeSpan.Zero ? duration : null; + } + + // Returns a rooted, directly-usable artwork URL for the SPA's . Blazor pages rely on + // GetPosterUrl to prefix "artwork/posters/" and resolve relative to , but the SPA + // renders the value raw from under /app/, so the API must root the URL itself (issue #180). + public static string Artwork(Metadata metadata, ArtworkKind artworkKind) + { + string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) + .Match(a => a.Path, string.Empty); + + if (string.IsNullOrWhiteSpace(artwork)) + { + return string.Empty; + } + + // Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard). + if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return artwork; + } + + string folder = artworkKind is ArtworkKind.Thumbnail ? "thumbnails" : "posters"; + + if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) + { + Url url = JellyfinUrl.RelativeProxyForArtwork(artwork); + if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) + { + url.SetQueryParam("fillHeight", 440); + } + + return $"/artwork/{folder}/{url}"; + } + + if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) + { + Url url = EmbyUrl.RelativeProxyForArtwork(artwork); + if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) + { + url.SetQueryParam("maxHeight", 440); + } + + return $"/artwork/{folder}/{url}"; + } + + return $"/artwork/{folder}/{artwork}"; + } + + private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback) + { + string artwork = Artwork(metadata, primary); + return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork; + } + + private static string SeasonTitle(SeasonMetadata metadata) + { + string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone() + .Map(sm => sm.Title ?? string.Empty) + .IfNone(string.Empty); + string seasonTitle = metadata.Season.SeasonNumber == 0 + ? "Specials" + : $"Season {metadata.Season.SeasonNumber}"; + return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}"; + } + + // Seasons often have no poster of their own; fall back to the parent show's poster (issue #180). + private static string SeasonArtwork(SeasonMetadata metadata) + { + string artwork = Artwork(metadata, ArtworkKind.Poster); + if (!string.IsNullOrWhiteSpace(artwork)) + { + return artwork; + } + + return metadata.Season.Show.ShowMetadata.HeadOrNone() + .Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty); + } + + private static string EpisodeSubtitle(EpisodeMetadata metadata) + { + string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone() + .Map(sm => sm.Title ?? string.Empty) + .IfNone(string.Empty); + int seasonNumber = metadata.Episode.Season.SeasonNumber; + string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}"; + return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}"; + } + + private static string MusicVideoSubtitle(MusicVideoMetadata metadata) + { + string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone() + .Map(am => am.Title ?? string.Empty) + .IfNone(string.Empty); + string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album; + if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album)) + { + return $"{artist} - {album}"; + } + + return string.IsNullOrWhiteSpace(artist) ? album : artist; + } + + private static string SongSubtitle(SongMetadata metadata) + { + string artists = string.Join(", ", metadata.Artists ?? []); + string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album; + if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album)) + { + return $"{artists} - {album}"; + } + + return string.IsNullOrWhiteSpace(artists) ? album : artists; + } +} diff --git a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs index 52753b80c..9a595469d 100644 --- a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs +++ b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs @@ -1,12 +1,9 @@ using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Emby; using ErsatzTV.Core.Interfaces.Search; -using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Search; -using Flurl; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.LibraryBrowse; @@ -167,53 +164,62 @@ public class GetLibraryBrowseItemsHandler( Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = []; - foreach (LibraryBrowseItemResponseModel item in await GetMovies(dbContext, movieIds, cancellationToken)) + foreach (LibraryBrowseItemResponseModel item in + await LibraryBrowseItemMapper.GetMovies(dbContext, movieIds, cancellationToken)) { hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item; } - foreach (LibraryBrowseItemResponseModel item in await GetShows(dbContext, showIds, cancellationToken)) + foreach (LibraryBrowseItemResponseModel item in + await LibraryBrowseItemMapper.GetShows(dbContext, showIds, cancellationToken)) { hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item; } - foreach (LibraryBrowseItemResponseModel item in await GetSeasons(dbContext, seasonIds, cancellationToken)) + foreach (LibraryBrowseItemResponseModel item in + await LibraryBrowseItemMapper.GetSeasons(dbContext, seasonIds, cancellationToken)) { hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item; } - foreach (LibraryBrowseItemResponseModel item in await GetArtists(dbContext, artistIds, cancellationToken)) + foreach (LibraryBrowseItemResponseModel item in + await LibraryBrowseItemMapper.GetArtists(dbContext, artistIds, cancellationToken)) { hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item; } - foreach (LibraryBrowseItemResponseModel item in await GetEpisodes(dbContext, episodeIds, cancellationToken)) + foreach (LibraryBrowseItemResponseModel item in + await LibraryBrowseItemMapper.GetEpisodes(dbContext, episodeIds, cancellationToken)) { hydrated[(LuceneSearchIndex.EpisodeType, item.Id)] = item; } - foreach (LibraryBrowseItemResponseModel item in await GetMusicVideos(dbContext, musicVideoIds, cancellationToken)) + foreach (LibraryBrowseItemResponseModel item in + await LibraryBrowseItemMapper.GetMusicVideos(dbContext, musicVideoIds, cancellationToken)) { hydrated[(LuceneSearchIndex.MusicVideoType, item.Id)] = item; } - foreach (LibraryBrowseItemResponseModel item in await GetSongs(dbContext, songIds, cancellationToken)) + foreach (LibraryBrowseItemResponseModel item in + await LibraryBrowseItemMapper.GetSongs(dbContext, songIds, cancellationToken)) { hydrated[(LuceneSearchIndex.SongType, item.Id)] = item; } - foreach (LibraryBrowseItemResponseModel item in await GetOtherVideos(dbContext, otherVideoIds, cancellationToken)) + foreach (LibraryBrowseItemResponseModel item in + await LibraryBrowseItemMapper.GetOtherVideos(dbContext, otherVideoIds, cancellationToken)) { hydrated[(LuceneSearchIndex.OtherVideoType, item.Id)] = item; } - foreach (LibraryBrowseItemResponseModel item in await GetImages(dbContext, imageIds, cancellationToken)) + foreach (LibraryBrowseItemResponseModel item in + await LibraryBrowseItemMapper.GetImages(dbContext, imageIds, cancellationToken)) { hydrated[(LuceneSearchIndex.ImageType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in - await GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken)) + await LibraryBrowseItemMapper.GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken)) { hydrated[(LuceneSearchIndex.RemoteStreamType, item.Id)] = item; } @@ -224,95 +230,6 @@ public class GetLibraryBrowseItemsHandler( .ToList(); } - private static async Task> GetMovies( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - return await dbContext.MovieMetadata - .AsNoTracking() - .Where(mm => ids.Contains(mm.MovieId)) - .Include(mm => mm.Artwork) - .Include(mm => mm.Movie) - .ThenInclude(m => m.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(mm => mm.Movie) - .ThenInclude(m => m.MediaVersions) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(mm => mm.MovieId) - .Select(g => g.OrderBy(mm => mm.Id).First()) - .Map(mm => new LibraryBrowseItemResponseModel( - mm.MovieId, - LibraryBrowseMediaType.Movie, - mm.Title ?? string.Empty, - mm.Movie.LibraryPath.LibraryId, - mm.Movie.LibraryPath.Library.Name, - Artwork(mm, ArtworkKind.Poster), - BestDuration(mm.Movie.MediaVersions), - 1, - null, - CollectionType.Movie, - null, - null, - null, - null, - mm.MovieId, - null)).ToList()); - } - - private static async Task> GetShows( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - Dictionary counts = await dbContext.Episodes - .AsNoTracking() - .Where(e => ids.Contains(e.Season.ShowId)) - .GroupBy(e => e.Season.ShowId) - .Select(g => new { ShowId = g.Key, Count = g.Count() }) - .ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken); - - return await dbContext.ShowMetadata - .AsNoTracking() - .Where(sm => ids.Contains(sm.ShowId)) - .Include(sm => sm.Artwork) - .Include(sm => sm.Show) - .ThenInclude(s => s.LibraryPath) - .ThenInclude(lp => lp.Library) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(sm => sm.ShowId) - .Select(g => g.OrderBy(sm => sm.Id).First()) - .Map(sm => new LibraryBrowseItemResponseModel( - sm.ShowId, - LibraryBrowseMediaType.TelevisionShow, - sm.Title ?? string.Empty, - sm.Show.LibraryPath.LibraryId, - sm.Show.LibraryPath.Library.Name, - Artwork(sm, ArtworkKind.Poster), - null, - counts.TryGetValue(sm.ShowId, out int count) ? count : 0, - null, - CollectionType.TelevisionShow, - null, - null, - null, - null, - sm.ShowId, - null)).ToList()); - } - private static async Task BrowseSeasonsForShow( TvContext dbContext, GetLibraryBrowseItems request, @@ -331,7 +248,8 @@ public class GetLibraryBrowseItemsHandler( .Take(request.PageSize) .ToList(); - List seasons = await GetSeasons(dbContext, pageIds, cancellationToken); + List seasons = + await LibraryBrowseItemMapper.GetSeasons(dbContext, pageIds, cancellationToken); // GetSeasons groups by season id, so restore the requested season-number order. Dictionary byId = seasons.ToDictionary(s => s.Id); @@ -366,7 +284,8 @@ public class GetLibraryBrowseItemsHandler( .Take(request.PageSize) .ToList(); - List episodes = await GetEpisodes(dbContext, pageIds, cancellationToken); + List episodes = + await LibraryBrowseItemMapper.GetEpisodes(dbContext, pageIds, cancellationToken); Dictionary byId = episodes.ToDictionary(e => e.Id); List ordered = pageIds @@ -401,7 +320,8 @@ public class GetLibraryBrowseItemsHandler( .Take(request.PageSize) .ToList(); - List musicVideos = await GetMusicVideos(dbContext, pageIds, cancellationToken); + List musicVideos = + await LibraryBrowseItemMapper.GetMusicVideos(dbContext, pageIds, cancellationToken); Dictionary byId = musicVideos.ToDictionary(mv => mv.Id); List ordered = pageIds @@ -412,405 +332,6 @@ public class GetLibraryBrowseItemsHandler( return new PagedLibraryBrowseItemsResponseModel(total, ordered); } - private static async Task> GetSeasons( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - Dictionary counts = await dbContext.Episodes - .AsNoTracking() - .Where(e => ids.Contains(e.SeasonId)) - .GroupBy(e => e.SeasonId) - .Select(g => new { SeasonId = g.Key, Count = g.Count() }) - .ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken); - - return await dbContext.SeasonMetadata - .AsNoTracking() - .Where(sm => ids.Contains(sm.SeasonId)) - .Include(sm => sm.Artwork) - .Include(sm => sm.Season) - .ThenInclude(s => s.Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(shm => shm.Artwork) - .Include(sm => sm.Season) - .ThenInclude(s => s.LibraryPath) - .ThenInclude(lp => lp.Library) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(sm => sm.SeasonId) - .Select(g => g.OrderBy(sm => sm.Id).First()) - .Map(sm => new LibraryBrowseItemResponseModel( - sm.SeasonId, - LibraryBrowseMediaType.TelevisionSeason, - SeasonTitle(sm), - sm.Season.LibraryPath.LibraryId, - sm.Season.LibraryPath.Library.Name, - SeasonArtwork(sm), - null, - counts.TryGetValue(sm.SeasonId, out int count) ? count : 0, - null, - CollectionType.TelevisionSeason, - null, - null, - null, - null, - sm.SeasonId, - null)).ToList()); - } - - private static async Task> GetArtists( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - Dictionary counts = await dbContext.MusicVideos - .AsNoTracking() - .Where(mv => ids.Contains(mv.ArtistId)) - .GroupBy(mv => mv.ArtistId) - .Select(g => new { ArtistId = g.Key, Count = g.Count() }) - .ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken); - - return await dbContext.ArtistMetadata - .AsNoTracking() - .Where(am => ids.Contains(am.ArtistId)) - .Include(am => am.Artwork) - .Include(am => am.Artist) - .ThenInclude(a => a.LibraryPath) - .ThenInclude(lp => lp.Library) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(am => am.ArtistId) - .Select(g => g.OrderBy(am => am.Id).First()) - .Map(am => new LibraryBrowseItemResponseModel( - am.ArtistId, - LibraryBrowseMediaType.Artist, - am.Title ?? string.Empty, - am.Artist.LibraryPath.LibraryId, - am.Artist.LibraryPath.Library.Name, - Artwork(am, ArtworkKind.Thumbnail), - null, - counts.TryGetValue(am.ArtistId, out int count) ? count : 0, - null, - CollectionType.Artist, - null, - null, - null, - null, - am.ArtistId, - null)).ToList()); - } - - private static async Task> GetEpisodes( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - return await dbContext.EpisodeMetadata - .AsNoTracking() - .Where(em => ids.Contains(em.EpisodeId)) - .Include(em => em.Artwork) - .Include(em => em.Episode) - .ThenInclude(e => e.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(em => em.Episode) - .ThenInclude(e => e.MediaVersions) - .Include(em => em.Episode) - .ThenInclude(e => e.Season) - .ThenInclude(s => s.Show) - .ThenInclude(sh => sh.ShowMetadata) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(em => em.EpisodeId) - .Select(g => g.OrderBy(em => em.Id).First()) - .Map(em => new LibraryBrowseItemResponseModel( - em.EpisodeId, - LibraryBrowseMediaType.Episode, - em.Title ?? string.Empty, - em.Episode.LibraryPath.LibraryId, - em.Episode.LibraryPath.Library.Name, - ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster), - BestDuration(em.Episode.MediaVersions), - 1, - null, - CollectionType.Episode, - null, - null, - null, - null, - em.EpisodeId, - null, - EpisodeSubtitle(em))).ToList()); - } - - private static async Task> GetMusicVideos( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - return await dbContext.MusicVideoMetadata - .AsNoTracking() - .Where(mvm => ids.Contains(mvm.MusicVideoId)) - .Include(mvm => mvm.Artwork) - .Include(mvm => mvm.MusicVideo) - .ThenInclude(mv => mv.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(mvm => mvm.MusicVideo) - .ThenInclude(mv => mv.MediaVersions) - .Include(mvm => mvm.MusicVideo) - .ThenInclude(mv => mv.Artist) - .ThenInclude(a => a.ArtistMetadata) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(mvm => mvm.MusicVideoId) - .Select(g => g.OrderBy(mvm => mvm.Id).First()) - .Map(mvm => new LibraryBrowseItemResponseModel( - mvm.MusicVideoId, - LibraryBrowseMediaType.MusicVideo, - mvm.Title ?? string.Empty, - mvm.MusicVideo.LibraryPath.LibraryId, - mvm.MusicVideo.LibraryPath.Library.Name, - ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster), - BestDuration(mvm.MusicVideo.MediaVersions), - 1, - null, - CollectionType.MusicVideo, - null, - null, - null, - null, - mvm.MusicVideoId, - null, - MusicVideoSubtitle(mvm))).ToList()); - } - - private static async Task> GetSongs( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - return await dbContext.SongMetadata - .AsNoTracking() - .Where(sm => ids.Contains(sm.SongId)) - .Include(sm => sm.Artwork) - .Include(sm => sm.Song) - .ThenInclude(s => s.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(sm => sm.Song) - .ThenInclude(s => s.MediaVersions) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(sm => sm.SongId) - .Select(g => g.OrderBy(sm => sm.Id).First()) - .Map(sm => new LibraryBrowseItemResponseModel( - sm.SongId, - LibraryBrowseMediaType.Song, - sm.Title ?? string.Empty, - sm.Song.LibraryPath.LibraryId, - sm.Song.LibraryPath.Library.Name, - ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster), - BestDuration(sm.Song.MediaVersions), - 1, - null, - CollectionType.Song, - null, - null, - null, - null, - sm.SongId, - null, - SongSubtitle(sm))).ToList()); - } - - private static async Task> GetOtherVideos( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - return await dbContext.OtherVideoMetadata - .AsNoTracking() - .Where(ovm => ids.Contains(ovm.OtherVideoId)) - .Include(ovm => ovm.Artwork) - .Include(ovm => ovm.OtherVideo) - .ThenInclude(ov => ov.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(ovm => ovm.OtherVideo) - .ThenInclude(ov => ov.MediaVersions) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(ovm => ovm.OtherVideoId) - .Select(g => g.OrderBy(ovm => ovm.Id).First()) - .Map(ovm => new LibraryBrowseItemResponseModel( - ovm.OtherVideoId, - LibraryBrowseMediaType.OtherVideo, - ovm.Title ?? string.Empty, - ovm.OtherVideo.LibraryPath.LibraryId, - ovm.OtherVideo.LibraryPath.Library.Name, - ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster), - BestDuration(ovm.OtherVideo.MediaVersions), - 1, - null, - CollectionType.OtherVideo, - null, - null, - null, - null, - ovm.OtherVideoId, - null, - string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList()); - } - - private static async Task> GetImages( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - return await dbContext.ImageMetadata - .AsNoTracking() - .Where(im => ids.Contains(im.ImageId)) - .Include(im => im.Artwork) - .Include(im => im.Image) - .ThenInclude(i => i.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(im => im.Image) - .ThenInclude(i => i.MediaVersions) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(im => im.ImageId) - .Select(g => g.OrderBy(im => im.Id).First()) - .Map(im => new LibraryBrowseItemResponseModel( - im.ImageId, - LibraryBrowseMediaType.Image, - im.Title ?? string.Empty, - im.Image.LibraryPath.LibraryId, - im.Image.LibraryPath.Library.Name, - ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail), - BestDuration(im.Image.MediaVersions), - 1, - null, - CollectionType.Image, - null, - null, - null, - null, - im.ImageId, - null, - string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList()); - } - - private static async Task> GetRemoteStreams( - TvContext dbContext, - List ids, - CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - return await dbContext.RemoteStreamMetadata - .AsNoTracking() - .Where(rsm => ids.Contains(rsm.RemoteStreamId)) - .Include(rsm => rsm.Artwork) - .Include(rsm => rsm.RemoteStream) - .ThenInclude(rs => rs.LibraryPath) - .ThenInclude(lp => lp.Library) - .Include(rsm => rsm.RemoteStream) - .ThenInclude(rs => rs.MediaVersions) - .ToListAsync(cancellationToken) - .Map(list => list - .GroupBy(rsm => rsm.RemoteStreamId) - .Select(g => g.OrderBy(rsm => rsm.Id).First()) - .Map(rsm => new LibraryBrowseItemResponseModel( - rsm.RemoteStreamId, - LibraryBrowseMediaType.RemoteStream, - rsm.Title ?? string.Empty, - rsm.RemoteStream.LibraryPath.LibraryId, - rsm.RemoteStream.LibraryPath.Library.Name, - ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster), - BestDuration(rsm.RemoteStream.MediaVersions), - 1, - null, - CollectionType.RemoteStream, - null, - null, - null, - null, - rsm.RemoteStreamId, - null, - string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList()); - } - - private static string EpisodeSubtitle(EpisodeMetadata metadata) - { - string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone() - .Map(sm => sm.Title ?? string.Empty) - .IfNone(string.Empty); - int seasonNumber = metadata.Episode.Season.SeasonNumber; - string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}"; - return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}"; - } - - private static string MusicVideoSubtitle(MusicVideoMetadata metadata) - { - string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone() - .Map(am => am.Title ?? string.Empty) - .IfNone(string.Empty); - string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album; - if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album)) - { - return $"{artist} - {album}"; - } - - return string.IsNullOrWhiteSpace(artist) ? album : artist; - } - - private static string SongSubtitle(SongMetadata metadata) - { - string artists = string.Join(", ", metadata.Artists ?? []); - string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album; - if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album)) - { - return $"{artists} - {album}"; - } - - return string.IsNullOrWhiteSpace(artists) ? album : artists; - } - private static async Task CountCollections( TvContext dbContext, GetLibraryBrowseItems request, @@ -1140,16 +661,6 @@ public class GetLibraryBrowseItemsHandler( private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) => requestType is null || requestType == itemType; - private static TimeSpan? BestDuration(IEnumerable versions) - { - TimeSpan duration = versions - .Select(v => v.Duration) - .Where(d => d > TimeSpan.Zero) - .DefaultIfEmpty() - .Max(); - return duration > TimeSpan.Zero ? duration : null; - } - private static async Task> GetManualCollectionDurations( TvContext dbContext, List collectionIds, @@ -1165,7 +676,7 @@ public class GetLibraryBrowseItemsHandler( .Include(m => m.MediaVersions) .ToListAsync(cancellationToken)) { - TimeSpan? duration = BestDuration(movie.MediaVersions); + TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(movie.MediaVersions); if (duration.HasValue) { mediaItemDurations[movie.Id] = duration.Value; @@ -1178,7 +689,7 @@ public class GetLibraryBrowseItemsHandler( .Include(e => e.MediaVersions) .ToListAsync(cancellationToken)) { - TimeSpan? duration = BestDuration(episode.MediaVersions); + TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(episode.MediaVersions); if (duration.HasValue) { mediaItemDurations[episode.Id] = duration.Value; @@ -1191,7 +702,7 @@ public class GetLibraryBrowseItemsHandler( .Include(mv => mv.MediaVersions) .ToListAsync(cancellationToken)) { - TimeSpan? duration = BestDuration(musicVideo.MediaVersions); + TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(musicVideo.MediaVersions); if (duration.HasValue) { mediaItemDurations[musicVideo.Id] = duration.Value; @@ -1204,7 +715,7 @@ public class GetLibraryBrowseItemsHandler( .Include(ov => ov.MediaVersions) .ToListAsync(cancellationToken)) { - TimeSpan? duration = BestDuration(otherVideo.MediaVersions); + TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(otherVideo.MediaVersions); if (duration.HasValue) { mediaItemDurations[otherVideo.Id] = duration.Value; @@ -1217,7 +728,7 @@ public class GetLibraryBrowseItemsHandler( .Include(s => s.MediaVersions) .ToListAsync(cancellationToken)) { - TimeSpan? duration = BestDuration(song.MediaVersions); + TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(song.MediaVersions); if (duration.HasValue) { mediaItemDurations[song.Id] = duration.Value; @@ -1230,7 +741,7 @@ public class GetLibraryBrowseItemsHandler( .Include(i => i.MediaVersions) .ToListAsync(cancellationToken)) { - TimeSpan? duration = BestDuration(image.MediaVersions); + TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(image.MediaVersions); if (duration.HasValue) { mediaItemDurations[image.Id] = duration.Value; @@ -1243,7 +754,7 @@ public class GetLibraryBrowseItemsHandler( .Include(rs => rs.MediaVersions) .ToListAsync(cancellationToken)) { - TimeSpan? duration = BestDuration(remoteStream.MediaVersions); + TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(remoteStream.MediaVersions); if (duration.HasValue) { mediaItemDurations[remoteStream.Id] = duration.Value; @@ -1274,7 +785,7 @@ public class GetLibraryBrowseItemsHandler( .OrderBy(mm => mm.Id) .ToListAsync(cancellationToken)) { - string poster = Artwork(metadata, ArtworkKind.Poster); + string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId)) { mediaItemArtwork[metadata.MovieId] = poster; @@ -1288,7 +799,7 @@ public class GetLibraryBrowseItemsHandler( .OrderBy(sm => sm.Id) .ToListAsync(cancellationToken)) { - string poster = Artwork(metadata, ArtworkKind.Poster); + string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId)) { mediaItemArtwork[metadata.ShowId] = poster; @@ -1302,7 +813,7 @@ public class GetLibraryBrowseItemsHandler( .OrderBy(sm => sm.Id) .ToListAsync(cancellationToken)) { - string poster = Artwork(metadata, ArtworkKind.Poster); + string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId)) { mediaItemArtwork[metadata.SeasonId] = poster; @@ -1316,7 +827,7 @@ public class GetLibraryBrowseItemsHandler( .OrderBy(ovm => ovm.Id) .ToListAsync(cancellationToken)) { - string poster = Artwork(metadata, ArtworkKind.Poster); + string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId)) { mediaItemArtwork[metadata.OtherVideoId] = poster; @@ -1330,7 +841,7 @@ public class GetLibraryBrowseItemsHandler( .OrderBy(sm => sm.Id) .ToListAsync(cancellationToken)) { - string poster = Artwork(metadata, ArtworkKind.Poster); + string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId)) { mediaItemArtwork[metadata.SongId] = poster; @@ -1344,7 +855,7 @@ public class GetLibraryBrowseItemsHandler( .OrderBy(im => im.Id) .ToListAsync(cancellationToken)) { - string poster = Artwork(metadata, ArtworkKind.Poster); + string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId)) { mediaItemArtwork[metadata.ImageId] = poster; @@ -1358,7 +869,7 @@ public class GetLibraryBrowseItemsHandler( .OrderBy(rsm => rsm.Id) .ToListAsync(cancellationToken)) { - string poster = Artwork(metadata, ArtworkKind.Poster); + string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId)) { mediaItemArtwork[metadata.RemoteStreamId] = poster; @@ -1383,83 +894,6 @@ public class GetLibraryBrowseItemsHandler( .Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex)) .ToListAsync(cancellationToken); - private static string SeasonTitle(SeasonMetadata metadata) - { - string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone() - .Map(sm => sm.Title ?? string.Empty) - .IfNone(string.Empty); - string seasonTitle = metadata.Season.SeasonNumber == 0 - ? "Specials" - : $"Season {metadata.Season.SeasonNumber}"; - return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}"; - } - - // Seasons often have no poster of their own; fall back to the parent show's poster (issue #180). - private static string SeasonArtwork(SeasonMetadata metadata) - { - string artwork = Artwork(metadata, ArtworkKind.Poster); - if (!string.IsNullOrWhiteSpace(artwork)) - { - return artwork; - } - - return metadata.Season.Show.ShowMetadata.HeadOrNone() - .Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty); - } - - private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback) - { - string artwork = Artwork(metadata, primary); - return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork; - } - - // Returns a rooted, directly-usable artwork URL for the SPA's . Blazor pages rely on - // GetPosterUrl to prefix "artwork/posters/" and resolve relative to , but the SPA - // renders the value raw from under /app/, so the API must root the URL itself (issue #180). - private static string Artwork(Metadata metadata, ArtworkKind artworkKind) - { - string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) - .Match(a => a.Path, string.Empty); - - if (string.IsNullOrWhiteSpace(artwork)) - { - return string.Empty; - } - - // Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard). - if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) - { - return artwork; - } - - string folder = artworkKind is ArtworkKind.Thumbnail ? "thumbnails" : "posters"; - - if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) - { - Url url = JellyfinUrl.RelativeProxyForArtwork(artwork); - if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) - { - url.SetQueryParam("fillHeight", 440); - } - - return $"/artwork/{folder}/{url}"; - } - - if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) - { - Url url = EmbyUrl.RelativeProxyForArtwork(artwork); - if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) - { - url.SetQueryParam("maxHeight", 440); - } - - return $"/artwork/{folder}/{url}"; - } - - return $"/artwork/{folder}/{artwork}"; - } - private static string EscapeLike(string searchQuery) => searchQuery .Replace("\\", "\\\\", StringComparison.Ordinal) diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItems.cs b/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItems.cs new file mode 100644 index 000000000..cd741c82c --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItems.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Api.LibraryBrowse; + +namespace ErsatzTV.Application.MediaCollections; + +public record GetCollectionItems(int Id, int PageNum, int PageSize) + : IRequest>; diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItemsHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItemsHandler.cs new file mode 100644 index 000000000..2ad162178 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItemsHandler.cs @@ -0,0 +1,56 @@ +using ErsatzTV.Application.LibraryBrowse; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.LibraryBrowse; +using ErsatzTV.Core.Errors; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.MediaCollections; + +public class GetCollectionItemsHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetCollectionItems request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + bool exists = await dbContext.Collections + .AsNoTracking() + .AnyAsync(c => c.Id == request.Id, cancellationToken); + if (!exists) + { + return new NotFoundError($"Collection {request.Id} does not exist."); + } + + // The collection graph is bounded, so load every member id and hydrate them in one shared + // pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media kinds are supported + // because MediaItem ids are globally unique across kinds. + List mediaItemIds = await dbContext.CollectionItems + .AsNoTracking() + .Where(ci => ci.CollectionId == request.Id) + .Select(ci => ci.MediaItemId) + .ToListAsync(cancellationToken); + + List all = + await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken); + + // Stable title ordering mirrors the library-browse handler (which orders its rows by name), + // giving the SPA a deterministic, browsable list independent of collection insertion order. + List ordered = all + .OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Id) + .ToList(); + + int pageNum = Math.Max(0, request.PageNum); + int pageSize = Math.Clamp(request.PageSize, 1, 100); + + List page = ordered + .Skip(pageNum * pageSize) + .Take(pageSize) + .ToList(); + + return new PagedLibraryBrowseItemsResponseModel(ordered.Count, page); + } +} diff --git a/ErsatzTV.Tests/Application/MediaCollections/CollectionHandlerTests.cs b/ErsatzTV.Tests/Application/MediaCollections/CollectionHandlerTests.cs index 5aa19cce8..c65da9cfb 100644 --- a/ErsatzTV.Tests/Application/MediaCollections/CollectionHandlerTests.cs +++ b/ErsatzTV.Tests/Application/MediaCollections/CollectionHandlerTests.cs @@ -2,8 +2,11 @@ using ErsatzTV.Application; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.Search; using ErsatzTV.Core; +using ErsatzTV.Core.Api.LibraryBrowse; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; using LanguageExt; using ErsatzTV.Tests.Support; using NSubstitute; @@ -130,6 +133,93 @@ public class CollectionHandlerTests : MediaCollectionHandlerTestBase LeftOf(result).ShouldBeOfType(); } + [Test] + public async Task GetItems_Should_Return_NotFoundError_When_Collection_Missing() + { + var handler = new GetCollectionItemsHandler(Db.Factory); + + Either result = + await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task GetItems_Should_Return_Members_With_Rooted_Artwork() + { + await SeedCollectionWithMovie(collectionId: 1, movieId: 10, title: "Fake Movie", poster: "movie.jpg"); + var handler = new GetCollectionItemsHandler(Db.Factory); + + Either result = + await handler.Handle(new GetCollectionItems(1, 0, 100), CancellationToken.None); + + PagedLibraryBrowseItemsResponseModel page = result.Match( + Left: _ => throw new AssertionException("Expected a Right result"), + Right: value => value); + + page.TotalCount.ShouldBe(1); + page.Page.Count.ShouldBe(1); + page.Page[0].Title.ShouldBe("Fake Movie"); + page.Page[0].MediaItemId.ShouldBe(10); + page.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Movie); + page.Page[0].Artwork.ShouldBe("/artwork/posters/movie.jpg"); + } + + private async Task SeedCollectionWithMovie(int collectionId, int movieId, string title, string poster) + { + await using TvContext context = Db.CreateContext(); + var library = new LocalLibrary + { + Id = collectionId, + Name = "Local", + MediaKind = LibraryMediaKind.Movies, + Paths = [] + }; + var path = new LibraryPath + { + Id = collectionId, + Path = "/media", + Library = library, + LibraryFolders = [], + MediaItems = [] + }; + library.Paths.Add(path); + + var collection = new Collection { Id = collectionId, Name = "Collection", MediaItems = [] }; + var movie = new Movie + { + Id = movieId, + LibraryPath = path, + Collections = [collection], + CollectionItems = [], + TraktListItems = [], + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(42) }], + MovieMetadata = + [ + new MovieMetadata + { + Title = title, + SortTitle = title, + Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }], + Genres = [], + Tags = [], + Studios = [], + Actors = [], + Guids = [], + Subtitles = [], + Directors = [], + Writers = [] + } + ] + }; + collection.MediaItems.Add(movie); + + context.LocalLibraries.Add(library); + context.Collections.Add(collection); + context.Movies.Add(movie); + await context.SaveChangesAsync(); + } + private static AddItemsToCollection MakeAddItems(int collectionId, List? movieIds = null) => new( collectionId, diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs index 96a87014e..8db793dc0 100644 --- a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs @@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; +using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using LanguageExt; @@ -35,6 +36,7 @@ public class CollectionControllerTests { ShouldHaveActionRoute(nameof(CollectionController.GetAll), "GET", "/api/collections"); ShouldHaveActionRoute(nameof(CollectionController.GetById), "GET", "/api/collections/{id:int}"); + ShouldHaveActionRoute(nameof(CollectionController.GetItems), "GET", "/api/collections/{id:int}/items"); ShouldHaveActionRoute(nameof(CollectionController.Create), "POST", "/api/collections"); ShouldHaveActionRoute(nameof(CollectionController.Update), "PUT", "/api/collections/{id:int}"); ShouldHaveActionRoute(nameof(CollectionController.Delete), "DELETE", "/api/collections/{id:int}"); @@ -238,6 +240,33 @@ public class CollectionControllerTests problemDetails.Title.ShouldBe("Resource not found"); } + [Test] + public async Task GetItems_Should_Return_200_With_Page_And_Clamp_Paging() + { + var page = new PagedLibraryBrowseItemsResponseModel(0, []); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(page)); + + IActionResult result = await _controller.GetItems(7, -5, 999, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(page); + await _mediator.Received(1).Send( + Arg.Is(q => q.Id == 7 && q.PageNum == 0 && q.PageSize == 100), + Arg.Any()); + } + + [Test] + public async Task GetItems_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.GetItems(99, 0, 100, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(404); + } + private static MediaCollectionViewModel MakeVm(int id, string name) => new(CollectionType.Collection, id, name, false, MediaItemState.Normal); diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 22c14c643..a37e4f746 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -126,6 +126,7 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/channel-templates/{id}", "delete", "404")] [TestCase("/api/channel-templates/{id}", "delete", "422")] [TestCase("/api/collections/{id}", "get", "404")] + [TestCase("/api/collections/{id}/items", "get", "404")] [TestCase("/api/collections", "post", "404")] [TestCase("/api/collections", "post", "422")] [TestCase("/api/collections/{id}", "put", "404")] diff --git a/ErsatzTV/Controllers/Api/CollectionController.cs b/ErsatzTV/Controllers/Api/CollectionController.cs index 90c3fff48..983774b94 100644 --- a/ErsatzTV/Controllers/Api/CollectionController.cs +++ b/ErsatzTV/Controllers/Api/CollectionController.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.MediaCollections; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; +using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; @@ -32,6 +33,28 @@ public class CollectionController(IMediator mediator) : ControllerBase return result.ToGetResult(); } + [HttpGet("/api/collections/{id:int}/items", Name = "GetCollectionItems")] + [Tags("Collections")] + [EndpointSummary("Get the items in a manual collection")] + [EndpointDescription("Returns a manual collection's full contents (all media kinds), paged.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetItems( + int id, + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + CancellationToken cancellationToken = default) + { + int clampedPageNum = Math.Max(0, pageNum); + int clampedPageSize = Math.Clamp(pageSize, 1, 100); + + Either result = await mediator.Send( + new GetCollectionItems(id, clampedPageNum, clampedPageSize), + cancellationToken); + return result.ToUpdatedResult(); + } + [HttpPost("/api/collections")] [Tags("Collections")] [EndpointSummary("Create a collection")] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index f70274272..054b42379 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2626,6 +2626,85 @@ } }, "/api/collections/{id}/items": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get the items in a manual collection", + "description": "Returns a manual collection's full contents (all media kinds), paged.", + "operationId": "GetCollectionItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedLibraryBrowseItemsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedLibraryBrowseItemsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedLibraryBrowseItemsResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, "post": { "tags": [ "Collections" diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index 361bf5431..cea456395 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -100,13 +100,19 @@ been added to the redirect map yet. ## Section 3 — BLAZOR-ONLY (blocking issues) -### Multi/rerun collections & playlist variants — API gaps #151/#152/#153/#155 +### Multi/rerun collections & playlist variants — API gaps #151/#152/#153 | Blazor route | File | Blocking issue | |---|---|---| | `/media/multi-collections`(`/add`, `/{Id}/edit`) | `MultiCollections.razor`, `MultiCollectionEditor.razor` | #151 (multi-collection management API) | | `/media/rerun-collections`(`/add`, `/{Id}/edit`) | `RerunCollections.razor`, `RerunCollectionEditor.razor` | #152 (rerun-collection management API) | -| `/media/playlists`(`/{Id}`) editing depth beyond what `/app/collections` covers | `Playlists.razor`, `PlaylistEditor.razor` | #153/#155 (playlist variant management API + collection-items editing depth) | +| `/media/playlists`(`/{Id}`) editing depth beyond what `/app/collections` covers | `Playlists.razor`, `PlaylistEditor.razor` | #153 (playlist variant management API) | + +**#155 RESOLVED** (collection-items enumeration): `GET /api/collections/{id}/items` (paged) now returns a +manual collection's full contents across all media kinds (reusing `LibraryBrowseItemResponseModel`), so the +SPA `/app/collections` items view lists real members instead of the old lossy Lucene `collection:"name"` +search preview. The `POST /api/collections/{id}/items` bogus-id case already returns 422 (guarded by +`AddItemsToCollectionHandler.ValidateMediaItems`), not 500. ### Playback troubleshooting — #145 diff --git a/web/src/api/collections.test.ts b/web/src/api/collections.test.ts index 2fc4e7f78..61fe660d8 100644 --- a/web/src/api/collections.test.ts +++ b/web/src/api/collections.test.ts @@ -6,7 +6,7 @@ import { deleteCollection, deleteSmartCollection, emptyAddItemsRequest, - getCollectionItemsPreview, + getCollectionItems, getCollections, getSmartCollections, removeItemFromCollection, @@ -158,23 +158,31 @@ describe('collections api client', () => { expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' }); }); - it('getCollectionItemsPreview issues a quoted collection: Lucene query', async () => { + it('getCollectionItems requests the paged collection-items endpoint', async () => { + const fetchMock = vi + .spyOn(window, 'fetch') + .mockResolvedValue(jsonResponse({ totalCount: 1, page: [browseItem(5, 'Movie')] })); + + const result = await getCollectionItems(7, 2, 50); + + const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost'); + expect(url.pathname).toBe('/api/collections/7/items'); + expect(url.searchParams.get('pageNum')).toBe('2'); + expect(url.searchParams.get('pageSize')).toBe('50'); + expect(result.totalCount).toBe(1); + }); + + it('getCollectionItems defaults to the first page of 100', async () => { const fetchMock = vi .spyOn(window, 'fetch') .mockResolvedValue(jsonResponse({ totalCount: 0, page: [] })); - await getCollectionItemsPreview('The Office'); + await getCollectionItems(9); const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost'); - expect(url.pathname).toBe('/api/library/browse'); - expect(url.searchParams.get('query')).toBe('collection:"The Office"'); - }); - - it('getCollectionItemsPreview returns [] for a blank name without calling the API', async () => { - const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ totalCount: 0, page: [] })); - - await expect(getCollectionItemsPreview(' ')).resolves.toEqual([]); - expect(fetchMock).not.toHaveBeenCalled(); + expect(url.pathname).toBe('/api/collections/9/items'); + expect(url.searchParams.get('pageNum')).toBe('0'); + expect(url.searchParams.get('pageSize')).toBe('100'); }); }); diff --git a/web/src/api/collections.ts b/web/src/api/collections.ts index 1db3815f6..5c476e545 100644 --- a/web/src/api/collections.ts +++ b/web/src/api/collections.ts @@ -1,6 +1,6 @@ import { ApiError, request } from './client'; import type { components } from './generated/v1'; -import { getLibraryBrowseItems, type LibraryBrowseItem } from './libraryBrowse'; +import type { LibraryBrowseItem, PagedLibraryBrowseItems } from './libraryBrowse'; export type MediaCollection = components['schemas']['MediaCollectionViewModel']; export type SmartCollection = components['schemas']['SmartCollectionViewModel']; @@ -40,6 +40,20 @@ export function removeItemFromCollection(id: number, mediaItemId: number): Promi return request(`/api/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' }); } +// Lists a manual collection's full contents (all media kinds), paged. Backed by +// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape. +export function getCollectionItems( + id: number, + pageNum = 0, + pageSize = 100 +): Promise { + const params = new URLSearchParams({ + pageNum: String(pageNum), + pageSize: String(pageSize) + }); + return request(`/api/collections/${id}/items?${params.toString()}`); +} + /* ---------- smart collections ---------- */ export function getSmartCollections(): Promise { @@ -116,22 +130,6 @@ export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollect return requestBody; } -// Best-effort partial listing of a manual collection's members. No API returns a manual -// collection's items by id; the only path is the Lucene `collection:"name"` search field -// via library-browse, which covers Movie / Show / Season / Artist only. Callers must treat -// this as an incomplete preview, never as the authoritative contents. -export async function getCollectionItemsPreview(name: string): Promise { - const trimmed = name.trim(); - - if (!trimmed) { - return []; - } - - const escaped = trimmed.replace(/"/g, '\\"'); - const result = await getLibraryBrowseItems({ pageSize: 100, query: `collection:"${escaped}"` }); - return result.page ?? []; -} - export function messageFromCollectionError(error: unknown, fallback = 'Unable to load collections'): string { if (error instanceof ApiError) { return error.detail ?? error.message; diff --git a/web/src/screens/CollectionsScreen.test.tsx b/web/src/screens/CollectionsScreen.test.tsx index 2ebab47e4..3d262eb9c 100644 --- a/web/src/screens/CollectionsScreen.test.tsx +++ b/web/src/screens/CollectionsScreen.test.tsx @@ -51,6 +51,10 @@ function mockApi(options: MockOptions = {}) { return Promise.resolve(jsonResponse({ page: [], totalCount: 0 })); } + if (/^\/api\/collections\/\d+\/items/.test(url) && method === 'GET') { + return Promise.resolve(jsonResponse({ page: [], totalCount: 0 })); + } + return Promise.resolve(new Response(null, { status: 204 })); }); } @@ -237,15 +241,14 @@ describe('CollectionsScreen', () => { }); }); - it('opens a manual collection and previews its items via a collection: query', async () => { + it('opens a manual collection and lists its items via the collection-items endpoint', async () => { const fetchMock = mockApi({ - onRequest: (url) => { - if (url.startsWith('/api/library/browse')) { - const q = new URL(url, 'http://localhost').searchParams.get('query') ?? ''; - - if (q.startsWith('collection:')) { - return jsonResponse({ page: [{ id: 5, mediaItemId: 5, mediaType: 'Movie', title: 'Inception' }], totalCount: 1 }); - } + onRequest: (url, method) => { + if (/^\/api\/collections\/1\/items/.test(url) && method === 'GET') { + return jsonResponse({ + page: [{ id: 5, mediaItemId: 5, mediaType: 'Movie', title: 'Inception' }], + totalCount: 1 + }); } return null; @@ -258,12 +261,15 @@ describe('CollectionsScreen', () => { fireEvent.click(screen.getByText('Favorites')); expect(await screen.findByText('Inception')).toBeInTheDocument(); - expect(screen.getByText(/best-effort search preview/)).toBeInTheDocument(); + // The lossy best-effort search-preview banner is gone now that a real endpoint exists. + expect(screen.queryByText(/best-effort search preview/)).not.toBeInTheDocument(); - const browseCall = fetchMock.mock.calls.find(([u]) => - u.toString().includes('collection%3A') - ); - expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"'); + const itemsCall = fetchMock.mock.calls.find(([u]) => /\/api\/collections\/1\/items/.test(u.toString())); + expect(itemsCall).toBeDefined(); + const itemsUrl = new URL(String(itemsCall?.[0]), 'http://localhost'); + expect(itemsUrl.pathname).toBe('/api/collections/1/items'); + expect(itemsUrl.searchParams.get('pageNum')).toBe('0'); + expect(itemsUrl.searchParams.get('pageSize')).toBe('100'); }); const addItemsByType: Record = { @@ -301,7 +307,7 @@ describe('CollectionsScreen', () => { await screen.findByText('Favorites'); fireEvent.click(screen.getByText('Favorites')); - await screen.findByText(/best-effort search preview/); + await screen.findByRole('button', { name: 'Add items' }); fireEvent.click(screen.getByRole('button', { name: 'Add items' })); return screen.getByRole('dialog'); diff --git a/web/src/screens/CollectionsScreen.tsx b/web/src/screens/CollectionsScreen.tsx index 73d3989f8..d2a2fe16c 100644 --- a/web/src/screens/CollectionsScreen.tsx +++ b/web/src/screens/CollectionsScreen.tsx @@ -29,7 +29,7 @@ import { createSmartCollection, deleteCollection, deleteSmartCollection, - getCollectionItemsPreview, + getCollectionItems, getCollections, getLibraryBrowseItems, getSmartCollections, @@ -482,20 +482,24 @@ function ManualItemsView({ collection: MediaCollection; onBack: () => void; }) { + const PAGE_SIZE = 100; const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [removing, setRemoving] = useState(null); const activeRef = useRef(true); // No synchronous setState here: `loading` starts true and flips false in `finally`, so - // this is safe to call from an effect. Reloads (after add/remove) keep the list visible. + // this is safe to call from an effect. Reloads (after add/remove) reset to the first page. const load = useCallback(() => { - getCollectionItemsPreview(collection.name ?? '') - .then((preview) => { + getCollectionItems(collection.id, 0, PAGE_SIZE) + .then((result) => { if (activeRef.current) { - setItems(preview); + setItems(result.page ?? []); + setTotal(result.totalCount ?? 0); setError(null); } }) @@ -509,7 +513,23 @@ function ManualItemsView({ setLoading(false); } }); - }, [collection.name]); + }, [collection.id]); + + const loadMore = async () => { + const nextPage = Math.floor(items.length / PAGE_SIZE); + setLoadingMore(true); + try { + const result = await getCollectionItems(collection.id, nextPage, PAGE_SIZE); + if (activeRef.current) { + setItems((prev) => [...prev, ...(result.page ?? [])]); + setTotal(result.totalCount ?? 0); + } + } catch (moreError) { + setError(messageFromCollectionError(moreError, 'Unable to load more items')); + } finally { + setLoadingMore(false); + } + }; useEffect(() => { activeRef.current = true; @@ -547,15 +567,6 @@ function ManualItemsView({ -
-
- {error && (
) : items.length === 0 ? ( -
No previewable items in this collection.
+
No items in this collection.
) : ( items.map((item, index) => (
+ {!loading && items.length < total && ( +
+ + Showing {items.length} of {total} + + +
+ )} +