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..3dcb7f1d3 100644 --- a/ErsatzTV/Controllers/Api/LibraryBrowseController.cs +++ b/ErsatzTV/Controllers/Api/LibraryBrowseController.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using ErsatzTV.Application.LibraryBrowse; using ErsatzTV.Core.Api.LibraryBrowse; using MediatR; @@ -22,12 +23,15 @@ public class LibraryBrowseController(IMediator mediator) : ControllerBase [FromQuery] LibraryBrowseMediaType? mediaType = null, [FromQuery] int pageNum = 0, [FromQuery] int pageSize = 100, + [FromQuery] + [Description("Parent television show id; only used with mediaType=TelevisionSeason (lists that show's seasons), ignored otherwise")] + 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 a3520c252..894ac4dce 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -4968,6 +4968,15 @@ "format": "int32", "default": 100 } + }, + { + "name": "parentId", + "in": "query", + "description": "Parent television show id; only used with mediaType=TelevisionSeason (lists that show's seasons), ignored otherwise", + "schema": { + "type": "integer", + "format": "int32" + } } ], "responses": { diff --git a/web/src/api/libraryBrowse.test.ts b/web/src/api/libraryBrowse.test.ts new file mode 100644 index 000000000..d28e4c595 --- /dev/null +++ b/web/src/api/libraryBrowse.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getLibraryBrowseItems } from './libraryBrowse'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json' }, + status + }); +} + +function browseUrl(fetchMock: ReturnType): URL { + return new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost'); +} + +describe('getLibraryBrowseItems', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('maps the paging/library/mediaType params into the query string', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); + + await getLibraryBrowseItems({ + query: 'star', + libraryId: 5, + mediaType: 'TelevisionShow', + pageNum: 2, + pageSize: 25 + }); + + const url = browseUrl(fetchMock); + expect(url.pathname).toBe('/api/library/browse'); + expect(url.searchParams.get('query')).toBe('star'); + expect(url.searchParams.get('libraryId')).toBe('5'); + expect(url.searchParams.get('mediaType')).toBe('TelevisionShow'); + expect(url.searchParams.get('pageNum')).toBe('2'); + expect(url.searchParams.get('pageSize')).toBe('25'); + expect(url.searchParams.has('parentId')).toBe(false); + }); + + it('sends parentId to scope seasons to a specific show', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); + + await getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: 42, pageSize: 100 }); + + const url = browseUrl(fetchMock); + expect(url.searchParams.get('mediaType')).toBe('TelevisionSeason'); + expect(url.searchParams.get('parentId')).toBe('42'); + }); + + it('omits parentId when it is not provided', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 })); + + await getLibraryBrowseItems({ mediaType: 'Movie' }); + + expect(browseUrl(fetchMock).searchParams.has('parentId')).toBe(false); + }); +}); diff --git a/web/src/api/libraryBrowse.ts b/web/src/api/libraryBrowse.ts index 75d4d50ec..b466b91e9 100644 --- a/web/src/api/libraryBrowse.ts +++ b/web/src/api/libraryBrowse.ts @@ -10,6 +10,8 @@ export interface GetLibraryBrowseItemsParams { mediaType?: LibraryBrowseMediaType; pageNum?: number; pageSize?: number; + // Only meaningful with mediaType 'TelevisionSeason': filters seasons to the given show id. + parentId?: number; query?: string; } @@ -36,6 +38,10 @@ export function getLibraryBrowseItems(params: GetLibraryBrowseItemsParams = {}): searchParams.set('pageSize', String(params.pageSize)); } + if (params.parentId != null) { + searchParams.set('parentId', String(params.parentId)); + } + const queryString = searchParams.toString(); return request(`/api/library/browse${queryString ? `?${queryString}` : ''}`); diff --git a/web/src/builder/ChannelBuilder.test.tsx b/web/src/builder/ChannelBuilder.test.tsx new file mode 100644 index 000000000..4e75306d8 --- /dev/null +++ b/web/src/builder/ChannelBuilder.test.tsx @@ -0,0 +1,64 @@ +import { cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChannelBuilderScreen } from './ChannelBuilder'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json' }, + status + }); +} + +// Mocks every endpoint the builder loads on mount so it can render its library +// browser, plus /api/library/browse which the browse hook fans out across. +function mockBuilderApi() { + return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = input.toString(); + + if (url.startsWith('/api/library/browse')) { + return Promise.resolve(jsonResponse({ page: [], totalCount: 0 })); + } + + if (url === '/api/channel-templates/default') { + // Builder treats a 404 here as "no default template". + return Promise.resolve(jsonResponse({ status: 404, title: 'Not Found' }, 404)); + } + + // channels, channel-templates, ffmpeg/profiles, filler-presets, watermarks, media-sources + return Promise.resolve(jsonResponse([])); + }); +} + +function browseMediaTypes(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .map(([u]) => u.toString()) + .filter((u) => u.startsWith('/api/library/browse')) + .map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType')) + .filter((mediaType): mediaType is string => mediaType != null && mediaType !== ''); +} + +describe('ChannelBuilder library browse', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + beforeEach(() => { + window.localStorage.clear(); + }); + + it('fans out over movies/shows/artists and never requests TelevisionSeason', async () => { + const fetchMock = mockBuilderApi(); + + render(); + + // Wait until the initial library browse fan-out has fired. + await waitFor(() => { + expect(browseMediaTypes(fetchMock).length).toBeGreaterThan(0); + }); + + const types = new Set(browseMediaTypes(fetchMock)); + expect(types).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist'])); + expect(types.has('TelevisionSeason')).toBe(false); + }); +}); diff --git a/web/src/builder/ChannelBuilder.tsx b/web/src/builder/ChannelBuilder.tsx index edaa49ecd..aad213d87 100644 --- a/web/src/builder/ChannelBuilder.tsx +++ b/web/src/builder/ChannelBuilder.tsx @@ -140,14 +140,15 @@ const COLLECTION_MEDIA_TYPES: LibraryBrowseMediaType[] = [ 'Playlist' ]; -// The pre-#168 API default: browsing a library shows the "pickable" top-level -// kinds only (shows/seasons/movies/artists), not every episode/song/etc. -// nested underneath them. Kept explicit here since `GET /api/library/browse` -// now spans all 10 media kinds when `mediaType` is omitted. +// Browsing a library shows the "pickable" top-level kinds only, not every +// episode/song/etc. nested underneath them. Kept explicit here since +// `GET /api/library/browse` now spans all 10 media kinds when `mediaType` is +// omitted. TelevisionSeason is intentionally excluded so a multi-season show +// renders as a single tile instead of flooding the grid with per-season tiles +// (issue #180); seasons are reachable via the show tile's Seasons drill-in. const LIBRARY_MEDIA_TYPES: LibraryBrowseMediaType[] = [ 'Movie', 'TelevisionShow', - 'TelevisionSeason', 'Artist' ]; @@ -326,6 +327,7 @@ function LibCard({ added, compact, onAdd, + onSeasons, onDragStart, onDragEnd }: { @@ -333,6 +335,7 @@ function LibCard({ added: boolean; compact: boolean; onAdd: () => void; + onSeasons?: () => void; onDragStart: () => void; onDragEnd: () => void; }) { @@ -369,6 +372,18 @@ function LibCard({ )} + {onSeasons && ( + { + event.stopPropagation(); + onSeasons(); + }} + > + + )} {added ? (