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,
LibraryBrowseMediaType? MediaType,
int PageNum,
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
int PageSize,
int? ParentId = null) : IRequest<PagedLibraryBrowseItemsResponseModel>;
@@ -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<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(
TvContext dbContext,
List<int> 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 <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)
{
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) =>
@@ -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<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]
@@ -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 = [],
@@ -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<CancellationToken>());
}
@@ -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);
}
}
+9
View File
@@ -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": {
+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;
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<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'
];
// 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({
)}
</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}>
{added ? (
<Check size={15} color="var(--action-primary)" aria-hidden="true" />
@@ -399,11 +414,111 @@ function LibCard({
<Check size={14} aria-hidden="true" />
</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>
);
}
// ---- 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 ---------------------------------------------
interface BrowseState {
status: 'loading' | 'error' | 'success';
@@ -422,7 +537,7 @@ async function loadCollections(query: string): Promise<{ page: LibraryBrowseItem
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.
// Mirrors loadCollections's per-kind Promise.all pattern, but preserves real
// paging (each kind keeps its own pageNum/totalCount, summed across kinds) so
@@ -674,6 +789,9 @@ function ChannelBuilder({
const [compact, setCompact] = useState(false);
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
useEffect(() => {
const timer = window.setTimeout(() => setQuery(searchInput.trim()), 280);
@@ -1144,6 +1262,9 @@ function ChannelBuilder({
compact={compact}
added={addedKeys.has(lineupKey(item))}
onAdd={() => addItem(item)}
onSeasons={
item.mediaType === 'TelevisionShow' ? () => setSeasonsShow(item) : undefined
}
onDragStart={() => setDragLib(lineupKey(item))}
onDragEnd={() => {
if (dragLib != null) {
@@ -1571,6 +1692,16 @@ function ChannelBuilder({
</aside>
</div>
{seasonsShow && (
<SeasonsDialog
key={seasonsShow.id}
show={seasonsShow}
addedKeys={addedKeys}
onAdd={addItem}
onClose={() => setSeasonsShow(null)}
/>
)}
<ConfirmDialog
open={confirmClear}
title="Clear the lineup?"
+41 -11
View File
@@ -266,15 +266,15 @@ describe('CollectionsScreen', () => {
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 byType: Record<string, { id: number; mediaType: string; title: string }> = {
const addItemsByType: Record<string, { id: number; mediaType: string; title: string }> = {
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
};
const fetchMock = mockApi({
function mockAddItemsApi() {
return mockApi({
onRequest: (url) => {
if (url.startsWith('/api/library/browse')) {
const params = new URL(url, 'http://localhost').searchParams;
@@ -287,14 +287,16 @@ describe('CollectionsScreen', () => {
}
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 null;
}
});
}
async function openAddItemsDialog() {
render(<CollectionsScreen />);
await screen.findByText('Favorites');
@@ -302,8 +304,21 @@ describe('CollectionsScreen', () => {
await screen.findByText(/best-effort search preview/);
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…'), {
target: { value: 'a' }
});
@@ -311,15 +326,30 @@ describe('CollectionsScreen', () => {
expect(await within(dialog).findByText('Adventure Time')).toBeInTheDocument();
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
expect(within(dialog).getByText('Melon Season 1')).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())
.filter((u) => u.startsWith('/api/library/browse'))
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType'))
.filter((mediaType): mediaType is string => mediaType != null);
expect(new Set(browseTypes)).toEqual(new Set(['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']));
.some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'TelevisionSeason');
expect(seasonCall).toBe(true);
});
});
+41 -4
View File
@@ -46,9 +46,25 @@ import { TYPE_LABEL } from '../media/mediaKinds';
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_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[] {
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 [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [kindFilter, setKindFilter] = useState<MediaKindFilter>('all');
const runSearch = async () => {
const runSearch = async (filter: MediaKindFilter = kindFilter) => {
setSearching(true);
setError(null);
try {
const trimmed = query.trim();
const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter];
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
.flatMap((result) => result.page ?? [])
@@ -323,6 +341,11 @@ function AddItemsDialog({
}
};
const selectKindFilter = (filter: MediaKindFilter) => {
setKindFilter(filter);
void runSearch(filter);
};
const toggle = (item: LibraryBrowseItem) => {
const key = `${item.mediaType}:${item.id}`;
setSelected((current) => {
@@ -397,9 +420,23 @@ function AddItemsDialog({
Search
</Button>
</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">
The library search returns movies, shows, seasons and artists. Episodes, music, images and other item kinds
can't be added from here yet.
Add movies, shows, seasons and artists. &ldquo;All&rdquo; searches movies, shows and artists; pick
&ldquo;Seasons&rdquo; to find a specific season. Episodes, music, images and other item kinds can&rsquo;t be
added from here yet.
</p>
{error && (
<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);
}
/* 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 {
from {
transform: scale(0.2);
@@ -3703,6 +3754,35 @@
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 {
margin-top: 10px;
display: flex;