From 54b18e73cd394f7588616fff2381db401a9f4e8e Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 21:42:10 +0200 Subject: [PATCH] fix(api): root library-browse artwork URLs and add season drill-in (#180) Part of the #180 library picker fixes (API side). Broken artwork: GetLibraryBrowseItemsHandler.Artwork returned Blazor-convention values (a bare cache filename, or a RELATIVE jellyfin/emby proxy path). Blazor's GetPosterUrl prefixes those with "artwork/posters/" against , but the React SPA renders item.artwork raw as from under /app/, so every image 404'd. The handler now returns rooted, directly-usable URLs (/artwork/posters/... or /artwork/thumbnails/... per ArtworkKind; jellyfin/emby mapped to their proxy routes with fillHeight/maxHeight; absolute http(s) URLs passed through; empty stays empty). Seasons with no poster of their own now fall back to the parent show's poster (SeasonArtwork + the extra ShowMetadata.Artwork include). Season drill-in: GET /api/library/browse gains an optional `parentId` query param that, with mediaType=TelevisionSeason, returns that show's seasons (season-number order, bypassing Lucene) so the SPA can expand a show into its seasons. OpenAPI spec regenerated. Co-Authored-By: Claude Fable 5 --- .../Queries/GetLibraryBrowseItems.cs | 3 +- .../Queries/GetLibraryBrowseItemsHandler.cs | 77 +++++++++++- .../GetLibraryBrowseItemsHandlerTests.cs | 117 ++++++++++++++++-- .../LibraryBrowseControllerTests.cs | 4 +- .../Api/LibraryBrowseController.cs | 3 +- ErsatzTV/wwwroot/openapi/v1.json | 8 ++ 6 files changed, 196 insertions(+), 16 deletions(-) diff --git a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItems.cs b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItems.cs index eb8a49d2e..be43a816b 100644 --- a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItems.cs +++ b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItems.cs @@ -7,4 +7,5 @@ public record GetLibraryBrowseItems( int? LibraryId, LibraryBrowseMediaType? MediaType, int PageNum, - int PageSize) : IRequest; + int PageSize, + int? ParentId = null) : IRequest; diff --git a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs index defa3a442..9c12f2eb7 100644 --- a/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs +++ b/ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs @@ -22,6 +22,14 @@ public class GetLibraryBrowseItemsHandler( GetLibraryBrowseItems request, CancellationToken cancellationToken) { + // Drill-in: seasons of a specific show. Bypasses Lucene and reads the show's seasons directly + // (in season-number order) so the SPA can expand a show tile into its seasons (issue #180). + if (request.ParentId.HasValue && request.MediaType == LibraryBrowseMediaType.TelevisionSeason) + { + await using TvContext seasonContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + return await BrowseSeasonsForShow(seasonContext, request, cancellationToken); + } + int offset = request.PageNum * request.PageSize; SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken); @@ -285,6 +293,36 @@ public class GetLibraryBrowseItemsHandler( null)).ToList()); } + private static async Task BrowseSeasonsForShow( + TvContext dbContext, + GetLibraryBrowseItems request, + CancellationToken cancellationToken) + { + List allSeasonIds = await dbContext.Seasons + .AsNoTracking() + .Where(s => s.ShowId == request.ParentId.Value) + .OrderBy(s => s.SeasonNumber) + .Select(s => s.Id) + .ToListAsync(cancellationToken); + + int total = allSeasonIds.Count; + List pageIds = allSeasonIds + .Skip(request.PageNum * request.PageSize) + .Take(request.PageSize) + .ToList(); + + List seasons = await GetSeasons(dbContext, pageIds, cancellationToken); + + // GetSeasons groups by season id, so restore the requested season-number order. + Dictionary byId = seasons.ToDictionary(s => s.Id); + List ordered = pageIds + .Where(byId.ContainsKey) + .Select(id => byId[id]) + .ToList(); + + return new PagedLibraryBrowseItemsResponseModel(total, ordered); + } + private static async Task> GetSeasons( TvContext dbContext, List ids, @@ -309,6 +347,7 @@ public class GetLibraryBrowseItemsHandler( .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) @@ -322,7 +361,7 @@ public class GetLibraryBrowseItemsHandler( SeasonTitle(sm), sm.Season.LibraryPath.LibraryId, sm.Season.LibraryPath.Library.Name, - Artwork(sm, ArtworkKind.Poster), + SeasonArtwork(sm), null, counts.TryGetValue(sm.SeasonId, out int count) ? count : 0, null, @@ -1266,17 +1305,47 @@ public class GetLibraryBrowseItemsHandler( 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); @@ -1285,7 +1354,7 @@ public class GetLibraryBrowseItemsHandler( url.SetQueryParam("fillHeight", 440); } - return url; + return $"/artwork/{folder}/{url}"; } if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) @@ -1296,10 +1365,10 @@ public class GetLibraryBrowseItemsHandler( url.SetQueryParam("maxHeight", 440); } - return url; + return $"/artwork/{folder}/{url}"; } - return artwork; + return $"/artwork/{folder}/{artwork}"; } private static string EscapeLike(string searchQuery) => diff --git a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs index c34ad3890..01868c795 100644 --- a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs +++ b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs @@ -86,7 +86,7 @@ public class GetLibraryBrowseItemsHandlerTests result.Page[0].MediaItemId.ShouldBe(20); result.Page[1].MediaType.ShouldBe(LibraryBrowseMediaType.Movie); result.Page[1].Title.ShouldBe("Collision Movie"); - result.Page[1].Artwork.ShouldBe("movie-poster.jpg"); + result.Page[1].Artwork.ShouldBe("/artwork/posters/movie-poster.jpg"); result.Page[1].Duration.ShouldBe(TimeSpan.FromMinutes(95)); result.Page[1].MediaItemId.ShouldBe(10); } @@ -203,7 +203,7 @@ public class GetLibraryBrowseItemsHandlerTests result.TotalCount.ShouldBe(1); result.Page.Count.ShouldBe(1); result.Page[0].Title.ShouldBe("Manual Metadata"); - result.Page[0].Artwork.ShouldBe("first-poster.jpg"); + result.Page[0].Artwork.ShouldBe("/artwork/posters/first-poster.jpg"); result.Page[0].Duration.ShouldBe(TimeSpan.FromMinutes(75)); result.Page[0].ItemCount.ShouldBe(2); } @@ -236,9 +236,62 @@ public class GetLibraryBrowseItemsHandlerTests result.Page[0].ItemCount.ShouldBe(2); result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason); result.Page[0].MediaItemId.ShouldBe(301); + // Season 301 has its own poster. + result.Page[0].Artwork.ShouldBe("/artwork/posters/season-2.jpg"); result.Page[1].Title.ShouldBe("Season Show - Specials"); result.Page[1].ItemCount.ShouldBe(1); result.Page[1].MediaItemId.ShouldBe(302); + // Specials has no poster of its own, so it falls back to the show's poster. + result.Page[1].Artwork.ShouldBe("/artwork/posters/show.jpg"); + } + + [Test] + public async Task Handle_Should_Browse_Seasons_For_A_Specific_Show_By_ParentId() + { + await SeedSeasonGraph(); + // ParentId drill-in bypasses the search index entirely. + var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel result = await handler.Handle( + new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.TelevisionSeason, 0, 10, 300), + CancellationToken.None); + + result.TotalCount.ShouldBe(2); + // Ordered by season number: Specials (0) then Season 2. + result.Page[0].Title.ShouldBe("Season Show - Specials"); + result.Page[0].MediaItemId.ShouldBe(302); + result.Page[1].Title.ShouldBe("Season Show - Season 2"); + result.Page[1].MediaItemId.ShouldBe(301); + + await _searchIndex.DidNotReceive().Search( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [Test] + public async Task Handle_Should_Root_Jellyfin_Artwork_Urls_With_FillHeight() + { + await SeedJellyfinMovieGraph(); + _searchIndex.Search( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(new SearchResult([new SearchItem(LuceneSearchIndex.MovieType, 601)], 1)); + var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel result = await handler.Handle( + new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Movie, 0, 10), + CancellationToken.None); + + result.TotalCount.ShouldBe(1); + result.Page[0].Artwork.ShouldBe("/artwork/posters/jellyfin/Items/abc/Images/Primary?tag=xyz&fillHeight=440"); } [Test] @@ -262,7 +315,7 @@ public class GetLibraryBrowseItemsHandlerTests result.TotalCount.ShouldBe(1); result.Page[0].Title.ShouldBe("Counted Artist"); result.Page[0].ItemCount.ShouldBe(2); - result.Page[0].Artwork.ShouldBe("artist-thumb.jpg"); + result.Page[0].Artwork.ShouldBe("/artwork/thumbnails/artist-thumb.jpg"); result.Page[0].CollectionType.ShouldBe(CollectionType.Artist); result.Page[0].MediaItemId.ShouldBe(401); } @@ -456,8 +509,8 @@ public class GetLibraryBrowseItemsHandlerTests Seasons = [], ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")] }; - var season = MakeSeason(301, path, show, 2); - var specials = MakeSeason(302, path, show, 0); + var season = MakeSeason(301, path, show, 2, "season-2.jpg"); + var specials = MakeSeason(302, path, show, 0, string.Empty); season.Episodes.AddRange([ MakeEpisode(311, path, season), MakeEpisode(312, path, season) @@ -497,6 +550,50 @@ public class GetLibraryBrowseItemsHandlerTests await context.SaveChangesAsync(); } + private async Task SeedJellyfinMovieGraph() + { + await using TvContext context = _db.CreateContext(); + (LocalLibrary library, LibraryPath path) = MakeLibrary(600, "Jellyfin Library"); + var movie = new Movie + { + Id = 601, + LibraryPath = path, + Collections = [], + CollectionItems = [], + TraktListItems = [], + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(100) }], + MovieMetadata = + [ + new MovieMetadata + { + Title = "Jellyfin Movie", + SortTitle = "Jellyfin Movie", + Artwork = + [ + new Artwork + { + Path = "jellyfin://Items/abc/Images/Primary?tag=xyz", + ArtworkKind = ArtworkKind.Poster + } + ], + Genres = [], + Tags = [], + Studios = [], + Actors = [], + Guids = [], + Subtitles = [], + Directors = [], + Writers = [] + } + ] + }; + path.MediaItems.Add(movie); + + context.LocalLibraries.Add(library); + context.Movies.Add(movie); + await context.SaveChangesAsync(); + } + private async Task SeedLibraryFilteredCollectionsGraph() { await using TvContext context = _db.CreateContext(); @@ -563,7 +660,7 @@ public class GetLibraryBrowseItemsHandlerTests MediaVersions = [new MediaVersion { Duration = duration }] }; - private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber) => + private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber, string poster) => new() { Id = id, @@ -574,7 +671,7 @@ public class GetLibraryBrowseItemsHandlerTests CollectionItems = [], TraktListItems = [], Episodes = [], - SeasonMetadata = [MakeSeasonMetadata()] + SeasonMetadata = [MakeSeasonMetadata(poster)] }; private static Episode MakeEpisode(int id, LibraryPath path, Season season) => @@ -633,12 +730,14 @@ public class GetLibraryBrowseItemsHandlerTests Subtitles = [] }; - private static SeasonMetadata MakeSeasonMetadata() => + private static SeasonMetadata MakeSeasonMetadata(string poster = "") => new() { Title = string.Empty, SortTitle = string.Empty, - Artwork = [], + Artwork = string.IsNullOrEmpty(poster) + ? [] + : [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }], Genres = [], Tags = [], Studios = [], diff --git a/ErsatzTV.Tests/Controllers/LibraryBrowseControllerTests.cs b/ErsatzTV.Tests/Controllers/LibraryBrowseControllerTests.cs index 13011dabb..b7f0b829d 100644 --- a/ErsatzTV.Tests/Controllers/LibraryBrowseControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/LibraryBrowseControllerTests.cs @@ -48,6 +48,7 @@ public class LibraryBrowseControllerTests LibraryBrowseMediaType.Movie, -1, 500, + 77, CancellationToken.None); await _mediator.Received(1).Send( @@ -56,7 +57,8 @@ public class LibraryBrowseControllerTests q.LibraryId == 123 && q.MediaType == LibraryBrowseMediaType.Movie && q.PageNum == 0 && - q.PageSize == 100), + q.PageSize == 100 && + q.ParentId == 77), Arg.Any()); } diff --git a/ErsatzTV/Controllers/Api/LibraryBrowseController.cs b/ErsatzTV/Controllers/Api/LibraryBrowseController.cs index 669c305aa..935144ca2 100644 --- a/ErsatzTV/Controllers/Api/LibraryBrowseController.cs +++ b/ErsatzTV/Controllers/Api/LibraryBrowseController.cs @@ -22,12 +22,13 @@ public class LibraryBrowseController(IMediator mediator) : ControllerBase [FromQuery] LibraryBrowseMediaType? mediaType = null, [FromQuery] int pageNum = 0, [FromQuery] int pageSize = 100, + [FromQuery] int? parentId = null, CancellationToken cancellationToken = default) { int clampedPageNum = Math.Max(0, pageNum); int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); return await mediator.Send( - new GetLibraryBrowseItems(query, libraryId, mediaType, clampedPageNum, clampedPageSize), + new GetLibraryBrowseItems(query, libraryId, mediaType, clampedPageNum, clampedPageSize, parentId), cancellationToken); } } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 77259c594..3f2495fe5 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -4968,6 +4968,14 @@ "format": "int32", "default": 100 } + }, + { + "name": "parentId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } } ], "responses": {