merge main into feat/145-block-history (post-#181); regenerate OpenAPI artifacts
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:25:49 +02:00
co-authored by Claude Fable 5
13 changed files with 632 additions and 42 deletions
@@ -7,4 +7,5 @@ public record GetLibraryBrowseItems(
int? LibraryId, int? LibraryId,
LibraryBrowseMediaType? MediaType, LibraryBrowseMediaType? MediaType,
int PageNum, int PageNum,
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>; int PageSize,
int? ParentId = null) : IRequest<PagedLibraryBrowseItemsResponseModel>;
@@ -22,6 +22,14 @@ public class GetLibraryBrowseItemsHandler(
GetLibraryBrowseItems request, GetLibraryBrowseItems request,
CancellationToken cancellationToken) 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; int offset = request.PageNum * request.PageSize;
SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken); SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken);
@@ -285,6 +293,36 @@ public class GetLibraryBrowseItemsHandler(
null)).ToList()); null)).ToList());
} }
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseSeasonsForShow(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
List<int> 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<int> pageIds = allSeasonIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> seasons = await GetSeasons(dbContext, pageIds, cancellationToken);
// GetSeasons groups by season id, so restore the requested season-number order.
Dictionary<int, LibraryBrowseItemResponseModel> byId = seasons.ToDictionary(s => s.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons( private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
TvContext dbContext, TvContext dbContext,
List<int> ids, List<int> ids,
@@ -309,6 +347,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(sm => sm.Season) .Include(sm => sm.Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
.ThenInclude(shm => shm.Artwork)
.Include(sm => sm.Season) .Include(sm => sm.Season)
.ThenInclude(s => s.LibraryPath) .ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
@@ -322,7 +361,7 @@ public class GetLibraryBrowseItemsHandler(
SeasonTitle(sm), SeasonTitle(sm),
sm.Season.LibraryPath.LibraryId, sm.Season.LibraryPath.LibraryId,
sm.Season.LibraryPath.Library.Name, sm.Season.LibraryPath.Library.Name,
Artwork(sm, ArtworkKind.Poster), SeasonArtwork(sm),
null, null,
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0, counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
null, null,
@@ -1266,17 +1305,47 @@ public class GetLibraryBrowseItemsHandler(
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}"; 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) private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
{ {
string artwork = Artwork(metadata, primary); string artwork = Artwork(metadata, primary);
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork; return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
} }
// Returns a rooted, directly-usable artwork URL for the SPA's <img src>. Blazor pages rely on
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, 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) private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
{ {
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty); .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)) if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{ {
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork); Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
@@ -1285,7 +1354,7 @@ public class GetLibraryBrowseItemsHandler(
url.SetQueryParam("fillHeight", 440); url.SetQueryParam("fillHeight", 440);
} }
return url; return $"/artwork/{folder}/{url}";
} }
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
@@ -1296,10 +1365,10 @@ public class GetLibraryBrowseItemsHandler(
url.SetQueryParam("maxHeight", 440); url.SetQueryParam("maxHeight", 440);
} }
return url; return $"/artwork/{folder}/{url}";
} }
return artwork; return $"/artwork/{folder}/{artwork}";
} }
private static string EscapeLike(string searchQuery) => private static string EscapeLike(string searchQuery) =>
@@ -86,7 +86,7 @@ public class GetLibraryBrowseItemsHandlerTests
result.Page[0].MediaItemId.ShouldBe(20); result.Page[0].MediaItemId.ShouldBe(20);
result.Page[1].MediaType.ShouldBe(LibraryBrowseMediaType.Movie); result.Page[1].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
result.Page[1].Title.ShouldBe("Collision 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].Duration.ShouldBe(TimeSpan.FromMinutes(95));
result.Page[1].MediaItemId.ShouldBe(10); result.Page[1].MediaItemId.ShouldBe(10);
} }
@@ -203,7 +203,7 @@ public class GetLibraryBrowseItemsHandlerTests
result.TotalCount.ShouldBe(1); result.TotalCount.ShouldBe(1);
result.Page.Count.ShouldBe(1); result.Page.Count.ShouldBe(1);
result.Page[0].Title.ShouldBe("Manual Metadata"); 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].Duration.ShouldBe(TimeSpan.FromMinutes(75));
result.Page[0].ItemCount.ShouldBe(2); result.Page[0].ItemCount.ShouldBe(2);
} }
@@ -236,9 +236,62 @@ public class GetLibraryBrowseItemsHandlerTests
result.Page[0].ItemCount.ShouldBe(2); result.Page[0].ItemCount.ShouldBe(2);
result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason); result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason);
result.Page[0].MediaItemId.ShouldBe(301); 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].Title.ShouldBe("Season Show - Specials");
result.Page[1].ItemCount.ShouldBe(1); result.Page[1].ItemCount.ShouldBe(1);
result.Page[1].MediaItemId.ShouldBe(302); 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<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Root_Jellyfin_Artwork_Urls_With_FillHeight()
{
await SeedJellyfinMovieGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.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] [Test]
@@ -262,7 +315,7 @@ public class GetLibraryBrowseItemsHandlerTests
result.TotalCount.ShouldBe(1); result.TotalCount.ShouldBe(1);
result.Page[0].Title.ShouldBe("Counted Artist"); result.Page[0].Title.ShouldBe("Counted Artist");
result.Page[0].ItemCount.ShouldBe(2); 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].CollectionType.ShouldBe(CollectionType.Artist);
result.Page[0].MediaItemId.ShouldBe(401); result.Page[0].MediaItemId.ShouldBe(401);
} }
@@ -456,8 +509,8 @@ public class GetLibraryBrowseItemsHandlerTests
Seasons = [], Seasons = [],
ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")] ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")]
}; };
var season = MakeSeason(301, path, show, 2); var season = MakeSeason(301, path, show, 2, "season-2.jpg");
var specials = MakeSeason(302, path, show, 0); var specials = MakeSeason(302, path, show, 0, string.Empty);
season.Episodes.AddRange([ season.Episodes.AddRange([
MakeEpisode(311, path, season), MakeEpisode(311, path, season),
MakeEpisode(312, path, season) MakeEpisode(312, path, season)
@@ -497,6 +550,50 @@ public class GetLibraryBrowseItemsHandlerTests
await context.SaveChangesAsync(); 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() private async Task SeedLibraryFilteredCollectionsGraph()
{ {
await using TvContext context = _db.CreateContext(); await using TvContext context = _db.CreateContext();
@@ -563,7 +660,7 @@ public class GetLibraryBrowseItemsHandlerTests
MediaVersions = [new MediaVersion { Duration = duration }] 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() new()
{ {
Id = id, Id = id,
@@ -574,7 +671,7 @@ public class GetLibraryBrowseItemsHandlerTests
CollectionItems = [], CollectionItems = [],
TraktListItems = [], TraktListItems = [],
Episodes = [], Episodes = [],
SeasonMetadata = [MakeSeasonMetadata()] SeasonMetadata = [MakeSeasonMetadata(poster)]
}; };
private static Episode MakeEpisode(int id, LibraryPath path, Season season) => private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
@@ -633,12 +730,14 @@ public class GetLibraryBrowseItemsHandlerTests
Subtitles = [] Subtitles = []
}; };
private static SeasonMetadata MakeSeasonMetadata() => private static SeasonMetadata MakeSeasonMetadata(string poster = "") =>
new() new()
{ {
Title = string.Empty, Title = string.Empty,
SortTitle = string.Empty, SortTitle = string.Empty,
Artwork = [], Artwork = string.IsNullOrEmpty(poster)
? []
: [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
Genres = [], Genres = [],
Tags = [], Tags = [],
Studios = [], Studios = [],
@@ -48,6 +48,7 @@ public class LibraryBrowseControllerTests
LibraryBrowseMediaType.Movie, LibraryBrowseMediaType.Movie,
-1, -1,
500, 500,
77,
CancellationToken.None); CancellationToken.None);
await _mediator.Received(1).Send( await _mediator.Received(1).Send(
@@ -56,7 +57,8 @@ public class LibraryBrowseControllerTests
q.LibraryId == 123 && q.LibraryId == 123 &&
q.MediaType == LibraryBrowseMediaType.Movie && q.MediaType == LibraryBrowseMediaType.Movie &&
q.PageNum == 0 && q.PageNum == 0 &&
q.PageSize == 100), q.PageSize == 100 &&
q.ParentId == 77),
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }
@@ -1,3 +1,4 @@
using System.ComponentModel;
using ErsatzTV.Application.LibraryBrowse; using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Api.LibraryBrowse;
using MediatR; using MediatR;
@@ -22,12 +23,15 @@ public class LibraryBrowseController(IMediator mediator) : ControllerBase
[FromQuery] LibraryBrowseMediaType? mediaType = null, [FromQuery] LibraryBrowseMediaType? mediaType = null,
[FromQuery] int pageNum = 0, [FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100, [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) CancellationToken cancellationToken = default)
{ {
int clampedPageNum = Math.Max(0, pageNum); int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
return await mediator.Send( return await mediator.Send(
new GetLibraryBrowseItems(query, libraryId, mediaType, clampedPageNum, clampedPageSize), new GetLibraryBrowseItems(query, libraryId, mediaType, clampedPageNum, clampedPageSize, parentId),
cancellationToken); cancellationToken);
} }
} }
+9
View File
@@ -4968,6 +4968,15 @@
"format": "int32", "format": "int32",
"default": 100 "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": { "responses": {
+58
View File
@@ -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<typeof vi.spyOn>): 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);
});
});
+6
View File
@@ -10,6 +10,8 @@ export interface GetLibraryBrowseItemsParams {
mediaType?: LibraryBrowseMediaType; mediaType?: LibraryBrowseMediaType;
pageNum?: number; pageNum?: number;
pageSize?: number; pageSize?: number;
// Only meaningful with mediaType 'TelevisionSeason': filters seasons to the given show id.
parentId?: number;
query?: string; query?: string;
} }
@@ -36,6 +38,10 @@ export function getLibraryBrowseItems(params: GetLibraryBrowseItemsParams = {}):
searchParams.set('pageSize', String(params.pageSize)); searchParams.set('pageSize', String(params.pageSize));
} }
if (params.parentId != null) {
searchParams.set('parentId', String(params.parentId));
}
const queryString = searchParams.toString(); const queryString = searchParams.toString();
return request<PagedLibraryBrowseItems>(`/api/library/browse${queryString ? `?${queryString}` : ''}`); return request<PagedLibraryBrowseItems>(`/api/library/browse${queryString ? `?${queryString}` : ''}`);
+64
View File
@@ -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<typeof mockBuilderApi>): 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(<ChannelBuilderScreen />);
// 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);
});
});
+137 -6
View File
@@ -140,14 +140,15 @@ const COLLECTION_MEDIA_TYPES: LibraryBrowseMediaType[] = [
'Playlist' 'Playlist'
]; ];
// The pre-#168 API default: browsing a library shows the "pickable" top-level // Browsing a library shows the "pickable" top-level kinds only, not every
// kinds only (shows/seasons/movies/artists), not every episode/song/etc. // episode/song/etc. nested underneath them. Kept explicit here since
// nested underneath them. Kept explicit here since `GET /api/library/browse` // `GET /api/library/browse` now spans all 10 media kinds when `mediaType` is
// now spans all 10 media kinds when `mediaType` is omitted. // 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[] = [ const LIBRARY_MEDIA_TYPES: LibraryBrowseMediaType[] = [
'Movie', 'Movie',
'TelevisionShow', 'TelevisionShow',
'TelevisionSeason',
'Artist' 'Artist'
]; ];
@@ -326,6 +327,7 @@ function LibCard({
added, added,
compact, compact,
onAdd, onAdd,
onSeasons,
onDragStart, onDragStart,
onDragEnd onDragEnd
}: { }: {
@@ -333,6 +335,7 @@ function LibCard({
added: boolean; added: boolean;
compact: boolean; compact: boolean;
onAdd: () => void; onAdd: () => void;
onSeasons?: () => void;
onDragStart: () => void; onDragStart: () => void;
onDragEnd: () => void; onDragEnd: () => void;
}) { }) {
@@ -369,6 +372,18 @@ function LibCard({
)} )}
</div> </div>
</div> </div>
{onSeasons && (
<IconButton
size="sm"
title="Browse seasons"
onClick={(event) => {
event.stopPropagation();
onSeasons();
}}
>
<FolderTree size={15} aria-hidden="true" />
</IconButton>
)}
<IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={onAdd}> <IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={onAdd}>
{added ? ( {added ? (
<Check size={15} color="var(--action-primary)" aria-hidden="true" /> <Check size={15} color="var(--action-primary)" aria-hidden="true" />
@@ -399,11 +414,111 @@ function LibCard({
<Check size={14} aria-hidden="true" /> <Check size={14} aria-hidden="true" />
</div> </div>
)} )}
{onSeasons && (
<button
type="button"
className="ctv-builder-seasons-btn ctv-press"
title="Browse seasons"
onClick={(event) => {
event.stopPropagation();
onSeasons();
}}
>
<FolderTree size={13} aria-hidden="true" />
Seasons
</button>
)}
</div> </div>
</div> </div>
); );
} }
// ---- Seasons drill-in ------------------------------------------------------
// Expands a TelevisionShow tile into its seasons so a specific season can be
// added to the lineup without flooding the main grid with per-season tiles
// (issue #180). Mounted fresh per show (keyed on show id) so initial state is
// 'loading' and the fetch effect only ever calls setState in its callbacks.
function SeasonsDialog({
show,
addedKeys,
onAdd,
onClose
}: {
show: LibraryBrowseItem;
addedKeys: Set<string>;
onAdd: (item: LibraryBrowseItem) => void;
onClose: () => void;
}) {
const [seasons, setSeasons] = useState<LibraryBrowseItem[]>([]);
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: show.id, pageSize: 100 })
.then((result) => {
if (active) {
setSeasons(result.page ?? []);
setStatus('success');
}
})
.catch((loadError: unknown) => {
if (active) {
setError(messageFromError(loadError));
setStatus('error');
}
});
return () => {
active = false;
};
}, [show.id]);
return (
<Dialog
open
onClose={onClose}
title={`Seasons — ${show.title}`}
width={520}
footer={
<Button variant="secondary" onClick={onClose}>
Done
</Button>
}
>
{status === 'loading' ? (
<div className="ctv-builder-empty">
<Spinner size={18} tone="accent" />
</div>
) : status === 'error' ? (
<div className="ctv-builder-empty" role="alert">
{error}
</div>
) : seasons.length === 0 ? (
<div className="ctv-builder-empty">This show has no seasons.</div>
) : (
<div className="ctv-builder-seasons-list">
{seasons.map((season) => {
const added = addedKeys.has(lineupKey(season));
return (
<div key={lineupKey(season)} className="ctv-builder-seasons-row">
<Poster item={season} width={40} height={54} mini />
<div className="ctv-builder-seasons-row-title">{season.title}</div>
<IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={() => onAdd(season)}>
{added ? (
<Check size={15} color="var(--action-primary)" aria-hidden="true" />
) : (
<Plus size={15} aria-hidden="true" />
)}
</IconButton>
</div>
);
})}
</div>
)}
</Dialog>
);
}
// ---- Library browse data hook --------------------------------------------- // ---- Library browse data hook ---------------------------------------------
interface BrowseState { interface BrowseState {
status: 'loading' | 'error' | 'success'; status: 'loading' | 'error' | 'success';
@@ -422,7 +537,7 @@ async function loadCollections(query: string): Promise<{ page: LibraryBrowseItem
return { page: merged, totalCount: merged.length }; return { page: merged, totalCount: merged.length };
} }
// Fan out across the four pickable library kinds (movies/shows/seasons/artists) // Fan out across the pickable top-level library kinds (movies/shows/artists)
// instead of the unscoped browse, which now also returns episodes/songs/etc. // instead of the unscoped browse, which now also returns episodes/songs/etc.
// Mirrors loadCollections's per-kind Promise.all pattern, but preserves real // Mirrors loadCollections's per-kind Promise.all pattern, but preserves real
// paging (each kind keeps its own pageNum/totalCount, summed across kinds) so // paging (each kind keeps its own pageNum/totalCount, summed across kinds) so
@@ -674,6 +789,9 @@ function ChannelBuilder({
const [compact, setCompact] = useState(false); const [compact, setCompact] = useState(false);
const [libraryId, setLibraryId] = useState<number | null>(null); const [libraryId, setLibraryId] = useState<number | null>(null);
// Show whose seasons are being browsed in the drill-in dialog (issue #180).
const [seasonsShow, setSeasonsShow] = useState<LibraryBrowseItem | null>(null);
// debounce search input -> query // debounce search input -> query
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => setQuery(searchInput.trim()), 280); const timer = window.setTimeout(() => setQuery(searchInput.trim()), 280);
@@ -1144,6 +1262,9 @@ function ChannelBuilder({
compact={compact} compact={compact}
added={addedKeys.has(lineupKey(item))} added={addedKeys.has(lineupKey(item))}
onAdd={() => addItem(item)} onAdd={() => addItem(item)}
onSeasons={
item.mediaType === 'TelevisionShow' ? () => setSeasonsShow(item) : undefined
}
onDragStart={() => setDragLib(lineupKey(item))} onDragStart={() => setDragLib(lineupKey(item))}
onDragEnd={() => { onDragEnd={() => {
if (dragLib != null) { if (dragLib != null) {
@@ -1571,6 +1692,16 @@ function ChannelBuilder({
</aside> </aside>
</div> </div>
{seasonsShow && (
<SeasonsDialog
key={seasonsShow.id}
show={seasonsShow}
addedKeys={addedKeys}
onAdd={addItem}
onClose={() => setSeasonsShow(null)}
/>
)}
<ConfirmDialog <ConfirmDialog
open={confirmClear} open={confirmClear}
title="Clear the lineup?" title="Clear the lineup?"
+46 -16
View File
@@ -266,15 +266,15 @@ describe('CollectionsScreen', () => {
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"'); expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
}); });
it('fans the add-items search out over the 4 addable kinds and merges the results', async () => { const addItemsByType: Record<string, { id: number; mediaType: string; title: string }> = {
const byType: Record<string, { id: number; mediaType: string; title: string }> = { Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' }, TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' }, TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' }, Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' } };
};
const fetchMock = mockApi({ function mockAddItemsApi() {
return mockApi({
onRequest: (url) => { onRequest: (url) => {
if (url.startsWith('/api/library/browse')) { if (url.startsWith('/api/library/browse')) {
const params = new URL(url, 'http://localhost').searchParams; const params = new URL(url, 'http://localhost').searchParams;
@@ -287,14 +287,16 @@ describe('CollectionsScreen', () => {
} }
const mediaType = params.get('mediaType'); const mediaType = params.get('mediaType');
const item = mediaType ? byType[mediaType] : undefined; const item = mediaType ? addItemsByType[mediaType] : undefined;
return jsonResponse(item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 }); return jsonResponse(item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 });
} }
return null; return null;
} }
}); });
}
async function openAddItemsDialog() {
render(<CollectionsScreen />); render(<CollectionsScreen />);
await screen.findByText('Favorites'); await screen.findByText('Favorites');
@@ -302,8 +304,21 @@ describe('CollectionsScreen', () => {
await screen.findByText(/best-effort search preview/); await screen.findByText(/best-effort search preview/);
fireEvent.click(screen.getByRole('button', { name: 'Add items' })); fireEvent.click(screen.getByRole('button', { name: 'Add items' }));
return screen.getByRole('dialog');
}
const dialog = screen.getByRole('dialog'); function addItemsBrowseTypes(fetchMock: ReturnType<typeof mockApi>): 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 !== '');
}
it('default add-items search excludes seasons and fans out over movies/shows/artists', async () => {
const fetchMock = mockAddItemsApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), { fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'a' } target: { value: 'a' }
}); });
@@ -311,15 +326,30 @@ describe('CollectionsScreen', () => {
expect(await within(dialog).findByText('Adventure Time')).toBeInTheDocument(); expect(await within(dialog).findByText('Adventure Time')).toBeInTheDocument();
expect(within(dialog).getByText('Zathura')).toBeInTheDocument(); expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
expect(within(dialog).getByText('Melon Season 1')).toBeInTheDocument();
expect(within(dialog).getByText('Between Movie and Show')).toBeInTheDocument(); expect(within(dialog).getByText('Between Movie and Show')).toBeInTheDocument();
// Seasons are excluded from the default fan-out (issue #180).
expect(within(dialog).queryByText('Melon Season 1')).not.toBeInTheDocument();
const browseTypes = fetchMock.mock.calls expect(new Set(addItemsBrowseTypes(fetchMock))).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
});
it('selecting the Seasons filter searches TelevisionSeason and surfaces seasons', async () => {
const fetchMock = mockAddItemsApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'a' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Seasons' }));
expect(await within(dialog).findByText('Melon Season 1')).toBeInTheDocument();
// The explicit filter narrows the fan-out to seasons only.
expect(within(dialog).queryByText('Zathura')).not.toBeInTheDocument();
const seasonCall = fetchMock.mock.calls
.map(([u]) => u.toString()) .map(([u]) => u.toString())
.filter((u) => u.startsWith('/api/library/browse')) .filter((u) => u.startsWith('/api/library/browse'))
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType')) .some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'TelevisionSeason');
.filter((mediaType): mediaType is string => mediaType != null); expect(seasonCall).toBe(true);
expect(new Set(browseTypes)).toEqual(new Set(['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']));
}); });
}); });
+41 -4
View File
@@ -46,9 +46,25 @@ import { TYPE_LABEL } from '../media/mediaKinds';
type Tab = 'manual' | 'smart'; type Tab = 'manual' | 'smart';
// Every kind that can be added to a manual collection from the picker.
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']; const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist'];
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST); const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
// The default fan-out excludes seasons so a multi-season show doesn't flood the
// results with per-season rows (issue #180); seasons stay reachable via the
// explicit media-kind filter below.
const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist'];
type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType'];
const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [
{ label: 'All', value: 'all' },
{ label: 'Movies', value: 'Movie' },
{ label: 'Shows', value: 'TelevisionShow' },
{ label: 'Seasons', value: 'TelevisionSeason' },
{ label: 'Artists', value: 'Artist' }
];
function sortByName<T extends { name?: null | string }>(items: T[]): T[] { function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? '')); return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
} }
@@ -301,15 +317,17 @@ function AddItemsDialog({
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map()); const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [kindFilter, setKindFilter] = useState<MediaKindFilter>('all');
const runSearch = async () => { const runSearch = async (filter: MediaKindFilter = kindFilter) => {
setSearching(true); setSearching(true);
setError(null); setError(null);
try { try {
const trimmed = query.trim(); const trimmed = query.trim();
const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter];
const perKind = await Promise.all( const perKind = await Promise.all(
ADDABLE_TYPE_LIST.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType })) kinds.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
); );
const merged = perKind const merged = perKind
.flatMap((result) => result.page ?? []) .flatMap((result) => result.page ?? [])
@@ -323,6 +341,11 @@ function AddItemsDialog({
} }
}; };
const selectKindFilter = (filter: MediaKindFilter) => {
setKindFilter(filter);
void runSearch(filter);
};
const toggle = (item: LibraryBrowseItem) => { const toggle = (item: LibraryBrowseItem) => {
const key = `${item.mediaType}:${item.id}`; const key = `${item.mediaType}:${item.id}`;
setSelected((current) => { setSelected((current) => {
@@ -397,9 +420,23 @@ function AddItemsDialog({
Search Search
</Button> </Button>
</form> </form>
<div className="ctv-collections-picker-filters" role="group" aria-label="Filter by media kind">
{MEDIA_KIND_FILTERS.map((filter) => (
<button
aria-pressed={kindFilter === filter.value}
className={`ctv-collections-picker-filter ctv-press${kindFilter === filter.value ? ' ctv-collections-picker-filter-active' : ''}`}
key={filter.value}
onClick={() => selectKindFilter(filter.value)}
type="button"
>
{filter.label}
</button>
))}
</div>
<p className="ctv-collections-picker-note"> <p className="ctv-collections-picker-note">
The library search returns movies, shows, seasons and artists. Episodes, music, images and other item kinds Add movies, shows, seasons and artists. &ldquo;All&rdquo; searches movies, shows and artists; pick
can't be added from here yet. &ldquo;Seasons&rdquo; to find a specific season. Episodes, music, images and other item kinds can&rsquo;t be
added from here yet.
</p> </p>
{error && ( {error && (
<span className="ctv-field-error" role="alert"> <span className="ctv-field-error" role="alert">
+80
View File
@@ -2432,6 +2432,57 @@
animation: ctv-pop 260ms cubic-bezier(0.16, 1, 0.3, 1); animation: ctv-pop 260ms cubic-bezier(0.16, 1, 0.3, 1);
} }
/* Seasons drill-in affordance on show tiles (issue #180) */
.ctv-builder-seasons-btn {
position: absolute;
left: 6px;
bottom: 6px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-hairline);
background: color-mix(in srgb, var(--surface-card) 82%, transparent);
color: var(--text-primary);
font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans);
cursor: pointer;
backdrop-filter: blur(2px);
}
.ctv-builder-seasons-btn:hover {
border-color: color-mix(in srgb, var(--action-primary) 45%, transparent);
color: var(--action-primary);
}
.ctv-builder-seasons-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 420px;
overflow-y: auto;
}
.ctv-builder-seasons-row {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--surface-card);
border: 1px solid var(--border-hairline);
}
.ctv-builder-seasons-row-title {
flex: 1;
min-width: 0;
font: var(--weight-medium) var(--text-xs) / 1.15 var(--font-sans);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes ctv-pop { @keyframes ctv-pop {
from { from {
transform: scale(0.2); transform: scale(0.2);
@@ -3703,6 +3754,35 @@
color: var(--text-disabled); color: var(--text-disabled);
} }
/* Media-kind filter chips in the add-items dialog (issue #180) */
.ctv-collections-picker-filters {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ctv-collections-picker-filter {
padding: 4px 12px;
border-radius: var(--radius-pill, 999px);
border: 1px solid var(--border-hairline);
background: var(--surface-card);
color: var(--text-secondary);
font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans);
cursor: pointer;
}
.ctv-collections-picker-filter:hover {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--action-primary) 35%, transparent);
}
.ctv-collections-picker-filter-active {
background: color-mix(in srgb, var(--action-primary) 14%, transparent);
border-color: color-mix(in srgb, var(--action-primary) 45%, transparent);
color: var(--action-primary);
}
.ctv-collections-picker-results { .ctv-collections-picker-results {
margin-top: 10px; margin-top: 10px;
display: flex; display: flex;