Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5995707c84 | ||
|
|
fcdecd16c0 | ||
|
|
2b09bffab8 | ||
|
|
1f3385a2ff | ||
|
|
c21827667a | ||
|
|
39455a96ed | ||
|
|
2362850559 | ||
|
|
7e5a6b75ce | ||
|
|
fbb659b19f | ||
|
|
7319e31fa1 | ||
|
|
d3f71faf66 |
@@ -6,18 +6,18 @@
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
|
||||
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
|
||||
<PackageVersion Include="BlazorSortable" Version="5.2.1" />
|
||||
<PackageVersion Include="BlazorSortable" Version="6.0.2" />
|
||||
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.2" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.66" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.79" />
|
||||
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
|
||||
<PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
|
||||
<PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
|
||||
<PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
|
||||
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
|
||||
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" />
|
||||
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6053" />
|
||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageVersion Include="Flurl" Version="4.0.0" />
|
||||
|
||||
@@ -141,6 +141,9 @@
|
||||
<data name="ButtonSettingsXmltv" xml:space="preserve">
|
||||
<value>XMLTV</value>
|
||||
</data>
|
||||
<data name="ButtonOpensInChicoryTv" xml:space="preserve">
|
||||
<value>Opens in ChicoryTV</value>
|
||||
</data>
|
||||
<data name="ButtonSupport" xml:space="preserve">
|
||||
<value>Support</value>
|
||||
</data>
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
{
|
||||
<MudDrawer @bind-Open="@_drawerIsOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="channels">@StringLocalizer["ButtonChannels"]</MudNavLink>
|
||||
<MudNavLink Href="channels">@StringLocalizer["ButtonChannels"]@ChicoryCue</MudNavLink>
|
||||
<MudNavLink Href="ffmpeg">@StringLocalizer["ButtonFFmpegProfiles"]</MudNavLink>
|
||||
<MudNavLink Href="watermarks">@StringLocalizer["ButtonWatermarks"]</MudNavLink>
|
||||
<MudNavGroup Title="@StringLocalizer["ButtonMediaSources"]">
|
||||
@@ -127,7 +127,7 @@
|
||||
<MudNavLink Href="media/sources/plex">@StringLocalizer["ButtonMediaSourcesPlex"]</MudNavLink>
|
||||
</MudNavGroup>
|
||||
<MudNavGroup Title="@StringLocalizer["ButtonMedia"]">
|
||||
<MudNavLink Href="media/libraries">@StringLocalizer["ButtonMediaLibraries"]</MudNavLink>
|
||||
<MudNavLink Href="media/libraries">@StringLocalizer["ButtonMediaLibraries"]@ChicoryCue</MudNavLink>
|
||||
<MudNavLink Href="media/trash">@StringLocalizer["ButtonMediaTrash"]</MudNavLink>
|
||||
<MudNavLink Href="media/tv/shows">@StringLocalizer["ButtonMediaTvShows"]</MudNavLink>
|
||||
<MudNavLink Href="media/movies">@StringLocalizer["ButtonMediaMovies"]</MudNavLink>
|
||||
@@ -161,7 +161,7 @@
|
||||
}
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
<MudNavLink Href="schedules">@StringLocalizer["ButtonSchedulingSchedules"]</MudNavLink>
|
||||
<MudNavLink Href="schedules">@StringLocalizer["ButtonSchedulingSchedules"]@ChicoryCue</MudNavLink>
|
||||
<MudNavLink Href="blocks">@StringLocalizer["ButtonSchedulingBlocks"]</MudNavLink>
|
||||
<MudNavLink Href="templates">@StringLocalizer["ButtonSchedulingTemplates"]</MudNavLink>
|
||||
<MudNavLink Href="decos">@StringLocalizer["ButtonSchedulingDecos"]</MudNavLink>
|
||||
@@ -180,17 +180,18 @@
|
||||
{
|
||||
@StringLocalizer["ButtonSchedulingPlayouts"]
|
||||
}
|
||||
@ChicoryCue
|
||||
</MudNavLink>
|
||||
</ChildContent>
|
||||
</MudNavGroup>
|
||||
<MudNavGroup Title="@StringLocalizer["ButtonSettings"]">
|
||||
<MudNavLink Href="settings/ffmpeg">@StringLocalizer["ButtonSettingsFFmpeg"]</MudNavLink>
|
||||
<MudNavLink Href="settings/logging">@StringLocalizer["ButtonSettingsLogging"]</MudNavLink>
|
||||
<MudNavLink Href="settings/hdhr">@StringLocalizer["ButtonSettingsHdHomeRun"]</MudNavLink>
|
||||
<MudNavLink Href="settings/scanner">@StringLocalizer["ButtonSettingsScanner"]</MudNavLink>
|
||||
<MudNavLink Href="settings/playout">@StringLocalizer["ButtonSettingsPlayout"]</MudNavLink>
|
||||
<MudNavLink Href="settings/ui">@StringLocalizer["ButtonSettingsUi"]</MudNavLink>
|
||||
<MudNavLink Href="settings/xmltv">@StringLocalizer["ButtonSettingsXmltv"]</MudNavLink>
|
||||
<MudNavLink Href="settings/ffmpeg">@StringLocalizer["ButtonSettingsFFmpeg"]@ChicoryCue</MudNavLink>
|
||||
<MudNavLink Href="settings/logging">@StringLocalizer["ButtonSettingsLogging"]@ChicoryCue</MudNavLink>
|
||||
<MudNavLink Href="settings/hdhr">@StringLocalizer["ButtonSettingsHdHomeRun"]@ChicoryCue</MudNavLink>
|
||||
<MudNavLink Href="settings/scanner">@StringLocalizer["ButtonSettingsScanner"]@ChicoryCue</MudNavLink>
|
||||
<MudNavLink Href="settings/playout">@StringLocalizer["ButtonSettingsPlayout"]@ChicoryCue</MudNavLink>
|
||||
<MudNavLink Href="settings/ui">@StringLocalizer["ButtonSettingsUi"]@ChicoryCue</MudNavLink>
|
||||
<MudNavLink Href="settings/xmltv">@StringLocalizer["ButtonSettingsXmltv"]@ChicoryCue</MudNavLink>
|
||||
</MudNavGroup>
|
||||
<MudNavGroup Expanded="true">
|
||||
<TitleContent>
|
||||
@@ -260,6 +261,15 @@
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
// Lightweight cue (ersatztv#147): marks nav entries that 302-redirect into the
|
||||
// ChicoryTV SPA (see LegacyUiRedirects.Map) so it's clear before clicking that the
|
||||
// entry leaves this Blazor UI. Non-redirecting entries in the same MudNavGroup are
|
||||
// left bare.
|
||||
private RenderFragment ChicoryCue =>
|
||||
@<MudTooltip Text="@StringLocalizer["ButtonOpensInChicoryTv"]">
|
||||
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" Class="ml-1" Style="opacity:.55;vertical-align:text-bottom"/>
|
||||
</MudTooltip>;
|
||||
|
||||
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
|
||||
private static readonly string BuildConfiguration = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyConfigurationAttribute>()?.Configuration?.ToLower() ?? "unset";
|
||||
|
||||
|
||||
@@ -2,272 +2,113 @@
|
||||
|
||||
Paste the prompt below into a fresh session to work the next item. Each session ends by
|
||||
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it
|
||||
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
|
||||
filed backend gap issues #100–#111; a 7–9-way parallel workflow build once exhausted RAM, so
|
||||
builds are limited to 2–3 concurrent, never wide fan-outs. Backend gaps all landed by
|
||||
2026-07-04 (PRs #113–#119); merge pass PR #120; live-data screens: #109 Dashboard (PR #123),
|
||||
#84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127), #88 Libraries
|
||||
(PR #128), #85 Guide/EPG (PR #129); #62 prerequisites: #65 (PR #130), #64 (PR #133), #63
|
||||
(PR #134) — epic #62 COMPLETE; #89 Channel Builder (PR #136); #93 Settings (PR #138) —
|
||||
first screen through the full design-first workflow; #92 design-sync round-trip verified and
|
||||
documented (`docs/design-sync.md`); **#90 rebrand (PR #139) — SPA fully presents as
|
||||
ChicoryTV; v26.4.0 tagged at this merge (first app-change release → prod)**; **#91 phase (a)
|
||||
root flip (PR #148) — SPA is the default UI; phase (b) Blazor removal blocked on parity
|
||||
#140–#147**.
|
||||
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic);
|
||||
all backend gap issues (#100–#111) and all SPA screens (#84–#89, #93, #109), the rebrand (#90)
|
||||
and the cutover root-flip (#91 phase a, PR #148) are MERGED. v26.5.0 tagged + DEPLOYED to prod
|
||||
2026-07-07 (verified live: image :26.5.0, / 302→/app, ChicoryTV title, /api/version 26.5.0,
|
||||
M3U serving). Blazor removal (#91 phase b) is blocked on parity issues #140–#147.
|
||||
|
||||
**PROCESS (2026-07-06, binding — supersedes 07-05)**: Claude Code ONLY — Codex is retired
|
||||
(usage exhausted). Fable is the orchestrator in the main session and is EXPENSIVE — use it
|
||||
SPARINGLY: delegate implementation to the best-fitting subagent models (haiku for mechanical
|
||||
churn, sonnet for standard components/tests, opus for judgment-heavy logic/orchestration
|
||||
code; fable only for the hardest design calls and the final review fork). Reviews stay
|
||||
multi-lens via subagents (fable correctness fork + cheaper contract/tests lens + a
|
||||
design-system lens for frontend work), plus a fork verification pass over any fix diff.
|
||||
Review fixes are applied by fitting subagents, never inline. `npm ci` in each fresh worktree
|
||||
before web/ verification. Merges need explicit user consent per PR — NOTE: the permission
|
||||
classifier requires consent IN-CONVERSATION; the standing consent written here does not
|
||||
satisfy it, so ask a quick merge question each time (learned #134). Subagents killed by
|
||||
transient API errors CAN be resumed via SendMessage with their agentId — resume instead of
|
||||
relaunching (their edits are saved; learned #89). NEW (#93): LIVE-E2E the new screen against
|
||||
a real fresh local server BEFORE the review lenses — it caught two ship-blockers jsdom
|
||||
can't see (see Lessons: local-run recipe).
|
||||
|
||||
**RELEASE CHECKPOINT (standing, added 2026-07-06)**: prod cutover to the fork is DONE —
|
||||
prod container `ersatztv` on bumblebee runs `192.168.1.95:3000/timothy/ersatztv:prod`
|
||||
(= v26.3.1, app-identical to upstream 26.3.0); `ersatztv-test` tracks `:latest` (main).
|
||||
Prod only advances on `v*` tags. **v26.4.0 TAGGED 2026-07-07** on 65b1a5e3 (the #90 merge,
|
||||
user-consented) — first app-change release; prod image = full API + all SPA screens +
|
||||
ChicoryTV branding. At future milestone merges, flag the user for the NEXT tag
|
||||
(v26.4.1/v26.5.0 — #91 cutover is the obvious next tag point). Tagging needs explicit user
|
||||
consent; NEVER `[skip ci]` a commit you'll tag.
|
||||
|
||||
**Session state (2026-07-07, post-#91 phase a)**: main = **d04769cc** (PR #148 merged).
|
||||
**#91 phase (a) LANDED**: root `/` + 12 legacy Blazor routes with SPA equivalents 302 to
|
||||
`/app/...` via `ErsatzTV/LegacyUiRedirects.cs` (exact-match map, single source of truth for
|
||||
"what has migrated") + middleware in Startup's blazor branch before UseRouting; query strings
|
||||
+ `ETV_BASE_URL` PathBase preserved (302 NOT 301 — deliberate, rollback-safe); docker smoke
|
||||
now asserts `/app/` serves ChicoryTV. **#91 stays OPEN — phase (b) (delete Blazor/MudBlazor)
|
||||
is BLOCKED on SPA parity**: recon found ~55 Blazor-only routes; gaps filed as **#140
|
||||
(collections — /app/collections is a placeholder!), #141 (media browse/search/trash), #142
|
||||
(trakt), #143 (ffmpeg profiles/filler/watermarks), #144 (blocks/decos/templates + playout
|
||||
detail editors), #145 (logs/troubleshooting), #146 (channel edit + numbers), #147 (SPA
|
||||
escape-hatch link to legacy UI)**. Blazor home escape hatch = `/system/health` (deliberately
|
||||
un-redirected). OIDC note (correctness fork): default landing changes from challenged-Blazor
|
||||
to open SPA — no NEW exposure (GET /api/* + /app were already unauthenticated), but SPA auth
|
||||
is a phase-(b) design gap. Baselines: **ErsatzTV.Tests 527** (495 + 32 redirect tests),
|
||||
Core.Tests **493** (+1 skip), **web tests 145** (web/ untouched this session). CLAUDE.md
|
||||
architecture/conventions updated for the SPA-default reality (Blazor sections of
|
||||
docs/contributing.md left for phase (b)). Worktree .worktrees/issue-91-cutover now sits on
|
||||
main (doc commit); issue-90-rebrand worktree removed. Main checkout still sits on
|
||||
docs/59-ui-redesign-brief — do NOT touch it.
|
||||
**Session state (2026-07-07, post-housekeeping)**: main = 7e5a6b75 (+ this doc commit).
|
||||
Housekeeping session did:
|
||||
- Dep batch: PR #61 (BlazorSortable 6.0.2 + CliWrap 3.10.2 + Dapper 2.1.79, rebased) and
|
||||
PR #132 (EFProfiler.Appender 6.0.6053, rebased) MERGED; #21/#48/#49 closed as superseded by
|
||||
#61; #131 (Scriban 7.2.0) closed obsolete — main already has Scriban 7.2.5 (47c3c3b5).
|
||||
- MCP PR #76 (#58): rebased onto main (clean, purely additive ErsatzTV.Mcp project), 12 tests
|
||||
green locally, pushed (head d1c04030). Still OPEN — needs a content refresh against the full
|
||||
API surface before merge, plus review.
|
||||
- Filed #149: CI flake — migrations job MySQL service publishes fixed host port 3306:3306, so
|
||||
two concurrent runs collide ("port is already allocated"). Fix = drop the `ports:` mapping
|
||||
(job connects via `Server=mysql` service name); verify MySql drift/apply still passes.
|
||||
- Worktrees: removed issue-91-cutover; .worktrees/deps was this session's scratch (remove it).
|
||||
**.worktrees/parity-147 exists** (branch feat/147-classic-ui-link off 1cf4a7a9, web/ npm ci
|
||||
already done) — rebase onto current main and use it for #147.
|
||||
- PARITY NOT STARTED — recon done though; facts baked into the prompt below.
|
||||
|
||||
**Lessons for all remaining prompts** (accumulated):
|
||||
- NEW (#91) — `UsePathBase` only rewrites the REQUEST side (Request.Path/PathBase); it never
|
||||
touches redirect `Location` headers — any `Response.Redirect` to an absolute path must
|
||||
prepend `context.Request.PathBase` (precedent: IptvController.cs:56,69,305).
|
||||
- NEW (#91) — Blazor's MainLayout has a not-ready gate (`MainLayout.razor:391`): while the
|
||||
DB/search index initializes, EVERY non-root Blazor page prerender 302s to `/`. Live-E2E
|
||||
probes must wait for FULL readiness (log line "Done migrating search index"), not just
|
||||
`/api/health` 200 — probing early produces phantom `302 → /` results.
|
||||
- NEW (#91) — the local-run host guard (`Startup.cs:679`) matches `Host.StartsWith("localhost")`;
|
||||
curling `127.0.0.1:8409` 404s everything except IPTV — always curl `localhost` in the #93
|
||||
live-E2E recipe.
|
||||
- NEW (#91) — SPA channel edit is a DEAD END: the Channels pencil navigates to
|
||||
`/app/new-channel?edit={id}` but ChannelBuilderScreen never reads `edit` (noted on #146);
|
||||
PlayoutsScreen has no path to playout creation/detail editors (noted on #144).
|
||||
- NEW (#93) — Local live-E2E recipe: `npm run build` (outputs to gitignored
|
||||
`ErsatzTV/wwwroot/app/`), then `ln -sfn <worktree>/ErsatzTV/wwwroot/app
|
||||
ErsatzTV/bin/Debug/net10.0/wwwroot/app` (Program.cs sets ContentRoot to the ASSEMBLY dir,
|
||||
so the SPA static-file provider reads bin's wwwroot — publish/Docker copy it, `dotnet run`
|
||||
doesn't), then `ETV_CONFIG_FOLDER=<scratch> ASPNETCORE_URLS=http://127.0.0.1:8409 dotnet
|
||||
run --project ErsatzTV`. Fresh DB migrates in seconds; ffmpeg autodetected from PATH.
|
||||
update-openapi.sh FAILS while an instance runs (single-instance mutex) — kill it first.
|
||||
- NEW (#93) — Dapper + Microsoft.Data.Sqlite infers expression columns (COUNT(*)) as BLOB
|
||||
when the result set is EMPTY → incompatible deserializer → 500 on every fresh DB. Prefer
|
||||
EF LINQ GroupBy for aggregates in Api handlers; regression-test the empty-DB path.
|
||||
- NEW (#93) — Screen hooks must TIER their loads: the screen's own resources gate
|
||||
loading/error; reference data (pickers, sources, health, version) settles per-resource
|
||||
(allSettled) with inline "Couldn't load X" notes. Precedent: settings.ts. Also: render the
|
||||
error branch BEFORE the loading branch — a draft-null loading guard ahead of the error
|
||||
check made the error state unreachable (infinite spinner).
|
||||
- NEW (#93) — ApiResults maps ONLY NotFoundError→404; plain BaseError→422. Handlers that
|
||||
collapse "missing" and "invalid state" into one query filter can't 404 — split the lookup
|
||||
(precedent: DeleteCustomResolutionHandler). Request DTOs in Controllers/Api/Requests
|
||||
deliberately have NO `#nullable enable` (only RESPONSE DTOs get it). Startup's
|
||||
UseStringEnumSchemas registers non-Core enums (OutputFormatKind, LogEventLevel)
|
||||
INDIVIDUALLY — assembly-wide reflection over ErsatzTV.FFmpeg throws
|
||||
ReflectionTypeLoadException (optional NvEncSharp natives). Core↔Application enum twins
|
||||
bridge via exhaustive switch expressions, never int casts.
|
||||
- NEW (#93) — `npm run check:api` diff-guards v1.d.ts against the LAST COMMIT — it fails
|
||||
mid-branch after a backend OpenAPI change until the regenerated file is committed;
|
||||
regen-idempotence (running generate:api twice → no diff) is the real sync check.
|
||||
- NEW (#93) — OSV advisories can break ALL CI overnight: NuGetAudit + warnings-as-errors
|
||||
turns a fresh critical advisory into NU1904 restore failures on every branch. Fix = a
|
||||
one-line central bump PR straight off main, merged before feature PRs (PR #137,
|
||||
Scriban 6→7 validated by both suites + unchanged XMLTV goldens).
|
||||
- NEW (#89) — Dialog/portal components: key open-effects on `[open]` ONLY and read callbacks
|
||||
through a latest-ref; an effect depending on an inline `onClose` re-runs (and re-focuses)
|
||||
on every parent render — the focus-steal makes dialog inputs untypeable, and jsdom tests
|
||||
can't catch it (fireEvent.change needs no focus).
|
||||
- NEW (#89) — before offering a "None"/clear affordance for any field the backend resolves
|
||||
with `x ?? fallback`, check whether null actually MEANS clear — for from-lineup advanced
|
||||
overrides null = INHERIT (see #135), so honest UI is "Inherit from template", not "None".
|
||||
- NEW (#89) — `<label onClick={...}>` wrapping a labelable control (button/input) double-fires
|
||||
in real browsers (label activation forwarding + bubble); jsdom does not emulate it, tests
|
||||
stay green. Use a `<div>` row with the control as the single accessible element.
|
||||
- NEW (#89) — `/api/library/browse` `mediaType` is single-valued: a Collections-style picker
|
||||
needs 5 typed parallel calls (Collection/Smart/Multi/Rerun/Playlist) merged client-side.
|
||||
ApiResults 422 title is ALWAYS "Validation failed" — fixtures must not invent titles.
|
||||
- Multiple Dynamic-start Flood schedule items are NON-VIABLE (#134): PlayoutModeSchedulerFlood
|
||||
only yields to a next item with StartType.Fixed (`PlayoutModeSchedulerFlood.cs:50-53`) and
|
||||
never advances on the hard stop — an ordered multi-source lineup must be ONE generated
|
||||
`IsSystem` Playlist (PlayAll=true per entry, entries in Index order) behind a single Flood
|
||||
item. PlaylistItem supports Movie/Show/Season/Artist/Collection/Smart/Multi but has NO
|
||||
RerunCollectionId and NO nested-playlist support (CollectionKey.ForPlaylistItem +
|
||||
MediaCollectionRepository.GetPlaylistItemMap are the two switches that define support).
|
||||
- Validation must see the SAME data the build path uses (#134): normalizing on a `with {}`
|
||||
copy inside the validator let raw request values reach persistence (FK violation → opaque
|
||||
422). Normalize the whole input once up front; both validation and build consume the
|
||||
normalized form.
|
||||
- Any handler that SYNTHESIZES names into unique-indexed columns needs de-collision (" 2",
|
||||
" 3"…, max-length-safe) — deleting a channel doesn't cascade its generated
|
||||
schedule/playlist, so recreate-after-delete is a routine path, not an edge case (#134).
|
||||
- `SelectOneAsync` re-applies `.OrderBy(keySelector)` INTERNALLY, which REPLACES any ordering
|
||||
the caller composed before it (#133) — never pre-`OrderBy` into SelectOneAsync; write the
|
||||
explicit `.Where(...).OrderBy(...).FirstOrDefaultAsync(...)` when ordering matters.
|
||||
- Normalize user input ONCE (#133): validate uniqueness/lengths against the SAME normalized
|
||||
(e.g. trimmed) value you persist, or a whitespace variant slips past validation and dies on
|
||||
the unique index as an unhandled 500.
|
||||
- Application command/query records + handlers live in `<Domain>/Commands/` and
|
||||
`<Domain>/Queries/` subfolders (contributing §2); namespace stays
|
||||
`ErsatzTV.Application.<Domain>` regardless of subfolder (#133).
|
||||
- Deferral wording must ENUMERATE what is deferred (#130): "aggregate collection metadata is
|
||||
deferred" quietly swallowed manual collections, which are a cheap direct join — the review
|
||||
had to split the deferral. Cheap-vs-expensive is per collection kind, not per feature.
|
||||
- Merged-source paging pattern (#130): Lucene supplies media ids+total, EF supplies
|
||||
collection-likes; page = media first, then a skip cascade through each collection type
|
||||
(`remainingSkip`/`take` threading). Stale Lucene entries can drift collection paging for a
|
||||
scan window — accepted, documented in-code. Any similar dual-source endpoint should copy
|
||||
the GetLibraryBrowseItemsHandler pattern AND its multi-type-overflow paging test.
|
||||
- User text into BOTH Lucene and SQL needs per-side treatment (#130): raw query text is the
|
||||
established Lucene idiom (parser falls back to escaped-literal on ParseException — malformed
|
||||
input degrades to empty/literal results, never throws), but the same text in EF LIKE needs
|
||||
`%`/`_`/escape-char escaping or semantics diverge between the two halves.
|
||||
- Direct `*Metadata` DbSet queries need a deterministic winner (#130): items can carry >1
|
||||
metadata row; either go through the navigation + HeadOrNone() idiom or
|
||||
GroupBy(itemId).OrderBy(Id).First().
|
||||
- NULL FIELDS ARE OMITTED ON THE WIRE (#129): Startup.cs sets Newtonsoft
|
||||
`NullValueHandling.Ignore` globally, so any null DTO property is ABSENT from the JSON →
|
||||
`undefined` in the browser, even though generated types say `| null`. Frontend guards must
|
||||
use truthiness (`!x`), NEVER `=== null`; fixtures for null cases must OMIT the key (test
|
||||
precedent: "renders the Guide screen when an on-air channel omits nowPlaying").
|
||||
- Cross-endpoint correlation needs shared ids (#129): /api/guide titles are show-only
|
||||
(ChannelGuideMetadata.GetTitle) while /api/channels/state nowPlaying uses GetDisplayTitle
|
||||
("Show - s01e01 - Ep") — string matching across endpoints can never work for episodes.
|
||||
Live match is now timestamps-only; the real fix is a shared programme/playout-item id on
|
||||
both endpoints (backlog).
|
||||
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
|
||||
returns UNDER THE QUERY THE CLIENT SENDS. `percent` is a 0–1 fraction despite its name.
|
||||
Enum-with-None fields are never truthiness-checked. Verify UNITS/scale of numeric wire
|
||||
fields against the producing code, not the field name.
|
||||
- Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; poll while
|
||||
pending∪active nonempty with a grace window; drain grace on persistent errors.
|
||||
- setState updaters must be PURE — no fetches (#127), no ref mutations (#128); StrictMode
|
||||
double-invokes updaters and the test renderer doesn't, so reviewers must catch it.
|
||||
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
|
||||
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback
|
||||
= the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't;
|
||||
#93: handoff said --status-warn-soft, the SPA token is --ctv-warn-soft — Toast precedent).
|
||||
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped.
|
||||
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based
|
||||
double-submit guards; mutations never refetch the world (#125/#127).
|
||||
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures
|
||||
are BARE 4xx/5xx unless the controller returns ProblemDetails); mount call-counts assert
|
||||
the DELTA across navigation; status-dot state never color-only (StatusDot has a label).
|
||||
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need `#nullable
|
||||
enable`; Application has NO nullable context. NSubstitute+ConfigElementKey:
|
||||
`Arg.Any<ConfigElementKey>()` + `<T>`. `Option<T>.ToNullable()` → `MatchUnsafe`.
|
||||
update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check.
|
||||
Validation.Apply ERASES NotFoundError subtypes (#44 gotcha) — multi-check validation that
|
||||
must 404 stays early-return.
|
||||
- Backlog nits (unfiled): unclamped pageSize (browse is clamped; older endpoints aren't);
|
||||
PlayoutController Create/Delete lack Name=; heavy GetItems pre-check; >30 MB uploads →
|
||||
bare 413; artwork content-type trusted (#66); schedule estimator materializes collections
|
||||
per GET; /api/health TTL cache; `LibraryScanStatusResponseModel.percent` 0–1 under a
|
||||
percent name; 1 pre-existing --text-faint usage in shell.css. From #129: shared
|
||||
programme/playout-item id on /api/guide + /api/channels/state; extract the duplicated
|
||||
channel-state poll loop into a shared helper; EPG grid re-renders unmemoized on every tick;
|
||||
/api/guide 21-include eager-load untrimmed. From #130: the two manual-collection metadata
|
||||
helpers each fetch CollectionItems (share one fetch); very large manual collections make
|
||||
the browse duration sum heavy. From #134: pre-existing non-system PlaylistGroup named
|
||||
"Channel Lineups" breaks multi-item creates with a generic 422; non-DbUpdateException
|
||||
create failures surface as bare 500. From #89: undefined-vs-null lineup keys in the create
|
||||
body; reduced-motion block lists a now-no-op .ctv-builder-libcard. From #93: enumeration
|
||||
endpoints missing for MPEG-TS scripts / audio language codes / UI cultures (settings fields
|
||||
are free-text meanwhile); media sources have no status/reachability signal in the API
|
||||
(Settings omits the StatusDot); full FFmpeg profile editor screen; edits made to an
|
||||
already-saved group DURING an in-flight save can be overwritten by the returned DTO
|
||||
(narrow race, noted by the verification fork). Filed: #126, #135.
|
||||
- The main checkout (/Users/timothy/ersatztv) sits on the STALE docs/59-ui-redesign-brief
|
||||
branch. NEVER recon/edit there — point subagents at a worktree pinned to origin/main, or
|
||||
they report placeholders that no longer exist (this bit a recon agent on 2026-07-07).
|
||||
- Gitea 1.24 has no rerun-run API; retrigger CI with an empty commit. Runs on the single
|
||||
runner queue mostly sequentially; concurrent migration jobs collide on port 3306 until #149
|
||||
lands.
|
||||
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` (else OpenAPI emits
|
||||
`["null","string"]` and SPA typegen degrades). ErsatzTV.Application has NO nullable context —
|
||||
`string?` there trips CS8632.
|
||||
- NSubstitute + ConfigElementKey: keys are fresh instances per access — stub with
|
||||
`Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>`.
|
||||
- `Option<T>.ToNullable()` doesn't exist; use `MatchUnsafe(v => (T?)v, () => null)`.
|
||||
- ./scripts/update-openapi.sh needs a prior normal `dotnet build ErsatzTV/ErsatzTV.csproj`.
|
||||
- Id-taking child-collection GETs 404 on unknown parent via pre-check + ApiResults + an
|
||||
OpenApiErrorResponseContractTests [TestCase] entry.
|
||||
- Backlog nits: pageSize unclamped on paged endpoints; artwork content-type trusted (#66);
|
||||
GET /api/guide runs the full 21-include eager-load per channel per request; `fillerKind`
|
||||
almost always `None` in JSON guide (merged into adjacent programmes).
|
||||
|
||||
---
|
||||
|
||||
# PROMPT — Post-cutover housekeeping + parity kickoff
|
||||
# PROMPT — Parity kickoff: #147 escape hatch → #146 channel edit → #140 collections
|
||||
|
||||
You are Fable, the ORCHESTRATOR in the main Claude Code session (Claude Code only). Fable is
|
||||
EXPENSIVE: delegate to fitting subagents (recon → Explore/haiku; mechanical work → sonnet;
|
||||
judgment-heavy code → opus; fable for the hardest calls + review forks). Read CLAUDE.md and
|
||||
the PROCESS + Lessons sections of this file first.
|
||||
EXPENSIVE: delegate (recon → Explore/haiku; mechanical → sonnet; judgment-heavy code → opus;
|
||||
fable forks for review). Read CLAUDE.md + the Lessons above first.
|
||||
|
||||
HARD CONSTRAINTS:
|
||||
- Work in a worktree off origin/main (`git worktree add .worktrees/<name> -b <branch>
|
||||
origin/main`); never touch the main checkout (docs/59-ui-redesign-brief). Remove the
|
||||
now-merged .worktrees/issue-91-cutover worktree first (it sits on main after the doc
|
||||
commit). `cd web && npm ci` in fresh worktrees before web verification.
|
||||
- Work in worktrees off origin/main; NEVER touch /Users/timothy/ersatztv (stale branch).
|
||||
.worktrees/parity-147 exists (npm ci done) — rebase feat/147-classic-ui-link onto
|
||||
origin/main first. `cd web && npm ci` in any NEW worktree.
|
||||
- Max 2–3 concurrent builds; ONE dotnet build at a time. NEVER set ETV_UPDATE_GOLDENS.
|
||||
- Merge consent in-conversation per PR. Live-E2E new screens per the #93 recipe (curl
|
||||
`localhost`, NOT 127.0.0.1 — host guard; wait for "Done migrating search index").
|
||||
- Merge consent in-conversation per PR. CI reruns = empty commit (no rerun API).
|
||||
- Live-E2E new screens per the #93 recipe (curl `localhost`, NOT 127.0.0.1 — host guard;
|
||||
wait for "Done migrating search index").
|
||||
|
||||
## Task (in order; each item is small — batch several into this session)
|
||||
1. RELEASE CHECK: **DONE 2026-07-07** — v26.5.0 tagged (21ede492, run 546) AND deployed to
|
||||
prod via Komodo GitOps: the server-management compose
|
||||
(docker/bumblebee/stacks/media-servers/compose.yaml) now PINS
|
||||
`ersatztv:26.5.0` (was floating `:prod`); future releases = bump that pin + push
|
||||
(pre-deploy backup hook fires on the ersatztv block change; snapshot 20260707T093726Z
|
||||
taken). Verified live: appVersion 26.5.0, / 302→/app ChicoryTV, 43 channels, Blazor-only
|
||||
routes intact. Nothing to do unless prod misbehaves.
|
||||
2. Dep-PR batch pass: open Renovate/dep PRs (#21, #48, #49, #61, #131 security, #132) —
|
||||
check freshness, rebase/retrigger, merge the green ones (consent per PR).
|
||||
3. MCP PR #76 (#58): rebase/refresh onto current main (post-cutover); it predates the full
|
||||
API surface.
|
||||
4. Then START PARITY (unblocks #91 phase b — work top-down by user value): #147 (SPA
|
||||
escape-hatch link — tiny web/ change, do first), #146 (channel edit dead-end — the
|
||||
Channels pencil sends `edit=` that ChannelBuilderScreen ignores), then #140 (collections
|
||||
screen — biggest gap, /app/collections is a placeholder).
|
||||
5. Update THIS handoff: record what merged (PRs + main SHA + baselines), pop done items,
|
||||
write the next prompt (likely: continue parity queue #140–#145). Commit to main. Print
|
||||
the next prompt in a fenced block.
|
||||
## Recon facts (2026-07-07, verified against main)
|
||||
- Routing: web/src/App.tsx (~150–252). /app/collections renders PlaceholderScreen
|
||||
(App.tsx ~2776). Manual pushState routing, no router lib.
|
||||
- #147: web/src/screens/SettingsScreen.tsx, System pane About card (~852) is the natural home
|
||||
for a "Classic UI" link to /system/health; reuse the existing `LegacyCallout` component
|
||||
(ExternalLink icon + "Legacy UI" badge, used by the Media sources card ~840). Remove again
|
||||
in #91 phase (b).
|
||||
- #146: Channels screen edit pencil already navigates
|
||||
navigateToPath(`/app/new-channel?edit=${channel.id}`) (App.tsx ~1216) but
|
||||
web/src/builder/ChannelBuilder.tsx never reads query params → dead-end. Backend ready:
|
||||
GET /api/channels/{id} → full ChannelViewModel; PUT /api/channels/{id} takes
|
||||
UpdateChannelRequest (ChannelController.cs). Issue #146 also covers /channels/numbers bulk
|
||||
renumbering — bulkRenumberChannels already exists in web/src/api/channels.ts (check what UI
|
||||
exposes it). Decide: hydrate the builder for edit vs a dedicated edit view — the builder's
|
||||
lineup model (from-lineup composite create) does NOT map 1:1 onto editing an existing
|
||||
channel's 30+ settings; a form-style edit view calling PUT may be the honest shape.
|
||||
- #140: manual + smart collections have FULL API CRUD (CollectionController incl. items
|
||||
add/remove; SmartCollectionController). NO API exists for multi-collections, playlists,
|
||||
rerun collections → those need backend gap issues/endpoints for full Blazor parity
|
||||
(/media/multi-collections, /media/playlists, /media/rerun-collections). SPA has
|
||||
getCollections()/getSmartCollections() in web/src/api/schedules.ts (no collections.ts yet).
|
||||
Tests: Vitest + testing-library; App.test.tsx mocks fetch by path (mockDashboardApi).
|
||||
|
||||
## Task (in order)
|
||||
1. #147 [small, sonnet]: Classic UI link in Settings → System About card (+ optional
|
||||
"opens in ChicoryTV" cue on migrated Blazor sidebar links, per the issue). Vitest
|
||||
coverage. PR, CI, consent, merge.
|
||||
2. #146 [opus]: channel edit path. Scope call first (builder hydration vs edit form —
|
||||
recommend and confirm in an issue comment), then implement + tests + live E2E. Include or
|
||||
explicitly defer /channels/numbers with an issue comment. PR, consent, merge.
|
||||
3. #140 [opus, biggest]: collections screen for manual + smart collections (list/create/
|
||||
edit/delete/items). File follow-up issues for multi/playlist/rerun API gaps and cover
|
||||
those types read-only if feasible (they surface in GET /api/collections by CollectionType).
|
||||
PR, consent, merge.
|
||||
4. Adversarial review fork over each PR diff before asking merge consent.
|
||||
5. Update THIS handoff: merged PRs + main SHA, pop done items, next prompt = remaining parity
|
||||
(#141–#145). Commit to main. Print the next prompt in a fenced block.
|
||||
|
||||
---
|
||||
|
||||
## Issue queue (work top-down)
|
||||
0. HOUSEKEEPING ← PROMPT above (v26.5.0 tag check; dep PRs #21/#48/#49/#61/#131/#132; MCP
|
||||
PR #76 refresh; #99 stays open for /api/channels/state onAir wiring; #126 + #135 remain
|
||||
backend slot-fillers).
|
||||
1. SPA parity for #91 phase (b) — order: #147 (escape hatch, tiny) → #146 (channel edit) →
|
||||
#140 (collections) → #144 (blocks/decos/templates + playout editors) → #143 (ffmpeg
|
||||
profiles/filler/watermarks) → #141 (media browse/search/trash) → #145 (logs/
|
||||
troubleshooting) → #142 (trakt). Each: SPA screen over existing/gap-filling API,
|
||||
then REMOVE the now-covered routes from Blazor-only status by ADDING them to
|
||||
`ErsatzTV/LegacyUiRedirects.cs` (the map = the single source of truth for migration).
|
||||
2. #91 phase (b): delete Blazor/MudBlazor once #140–#146 are covered (recon report is in
|
||||
the 2026-07-07 session; key facts: delete Startup.cs:368-381 service regs +
|
||||
MapBlazorHub/MapFallbackToPage only, KEEP MapControllers/MapOpenApi/MapScalarApiReference
|
||||
/OIDC//callback/AccountController/hosted services; drop MudBlazor+BlazorSortable+
|
||||
Blazored.FluentValidation+Heron.MudCalendar pkg refs, RequiresAspNetWebAssets, razor
|
||||
NoWarn block, Locals/ resx, wwwroot css/lib Blazor assets; update
|
||||
StartupSpaHostingTests + docs/contributing.md Blazor sections; goldens must NOT change).
|
||||
Closes #91; then flag next release tag.
|
||||
Cross-refs: #66/#67 image-pipeline nice-to-haves; #68 independent; #25 (razor Sonar
|
||||
burn-down) becomes MOOT at phase (b) — close it then.
|
||||
Done recently: **PR #148 (#91 phase a root flip — merged 2026-07-07, main d04769cc; #91
|
||||
stays open for phase b)**, PR #139 (#90 rebrand, TAGGED v26.4.0), PR #138 (#93 Settings),
|
||||
PR #137 (Scriban GHSA CI-unblock).
|
||||
1. #147 SPA escape-hatch link (tiny) ← PROMPT above
|
||||
2. #146 channel edit + /channels/numbers (blocker for #91b)
|
||||
3. #140 collections management (biggest gap; spawns multi/playlist/rerun API follow-ups)
|
||||
4. #141–#145 remaining parity gaps (media browse/search, trakt, filler, watermarks, ffmpeg
|
||||
profiles, blocks/decos/templates, playout editors, logs, troubleshooting — see each issue)
|
||||
5. #91 phase (b): remove Blazor once #140–#147 close; also remove the #147 link then.
|
||||
6. MCP PR #76 (#58): refresh tool catalog against the full API surface, review, merge.
|
||||
7. #149 CI flake (migrations job host-port collision) — small, slot into any session.
|
||||
Cross-refs: #99 (TS/HLS-Direct sessions) stays backlog. Done this pass: dep PRs #61/#132
|
||||
merged; #21/#48/#49/#131 closed; prod verified on v26.5.0.
|
||||
|
||||
@@ -2728,6 +2728,15 @@ describe('Settings screen (#93)', () => {
|
||||
expect(await screen.findByText('Local')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a Classic UI link to the legacy Blazor app in the System pane (#147)', async () => {
|
||||
mockDashboardApi();
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
|
||||
|
||||
const link = await screen.findByRole('link', { name: /Open Classic UI/ });
|
||||
expect(link).toHaveAttribute('href', '/system/health');
|
||||
});
|
||||
|
||||
it('renders and stays editable when media sources (tier-2 reference data) fail to load', async () => {
|
||||
mockDashboardApi({ mediaSourcesFailuresBeforeSuccess: 99 });
|
||||
await openSettings();
|
||||
|
||||
+27
-1
@@ -54,6 +54,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import chicoryMarkUrl from '../../design-system/assets/chicory-mark.svg';
|
||||
import { ChannelBuilderScreen } from './builder/ChannelBuilder';
|
||||
import { ChannelEditScreen } from './screens/ChannelEditScreen';
|
||||
import { CollectionsScreen } from './screens/CollectionsScreen';
|
||||
import { SettingsScreen } from './screens/SettingsScreen';
|
||||
import { navigateToPath } from './routing';
|
||||
import {
|
||||
@@ -124,6 +126,7 @@ type ScreenId =
|
||||
| 'dashboard'
|
||||
| 'channels'
|
||||
| 'builder'
|
||||
| 'editChannel'
|
||||
| 'guide'
|
||||
| 'schedules'
|
||||
| 'playouts'
|
||||
@@ -181,6 +184,21 @@ const routes: ScreenRoute[] = [
|
||||
primaryAction: 'Create Channel',
|
||||
placeholder: 'Channel builder workspace'
|
||||
},
|
||||
{
|
||||
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
|
||||
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
|
||||
// sub-paths like /app/edit-channel/5.
|
||||
id: 'editChannel',
|
||||
path: '/app/edit-channel',
|
||||
label: 'Edit Channel',
|
||||
title: 'Edit Channel',
|
||||
kicker: 'Channel',
|
||||
description: 'Full channel editor: identity, playout, streaming, selection and branding.',
|
||||
icon: <Tv aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Save Changes',
|
||||
placeholder: 'Channel editor workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'guide',
|
||||
path: '/app/guide',
|
||||
@@ -1213,7 +1231,7 @@ function ChannelTableRow({
|
||||
<IconButton disabled size="sm" title={`Preview unavailable for ${channel.name}`}>
|
||||
<Play aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => navigateToPath(`/app/new-channel?edit=${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
|
||||
<IconButton onClick={() => navigateToPath(`/app/edit-channel/${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled size="sm" title={`Troubleshoot ${channel.name}`}>
|
||||
@@ -2841,6 +2859,10 @@ function ScreenContent({
|
||||
return <ChannelBuilderScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'editChannel') {
|
||||
return <ChannelEditScreen key={window.location.pathname} />;
|
||||
}
|
||||
|
||||
if (route.id === 'guide') {
|
||||
return <GuideScreen />;
|
||||
}
|
||||
@@ -2861,6 +2883,10 @@ function ScreenContent({
|
||||
return <SettingsScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'collections') {
|
||||
return <CollectionsScreen />;
|
||||
}
|
||||
|
||||
return <PlaceholderScreen route={route} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getChannelById, updateChannel, type UpdateChannelRequest } from './channels';
|
||||
|
||||
const sampleChannel = {
|
||||
id: 5,
|
||||
number: '5',
|
||||
name: 'Cartoons',
|
||||
group: 'ChicoryTV',
|
||||
categories: '',
|
||||
fFmpegProfileId: 1,
|
||||
slugSeconds: null,
|
||||
logo: { path: '', contentType: '' },
|
||||
streamSelectorMode: 'Default',
|
||||
streamSelector: null,
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
playoutSource: 'Generated',
|
||||
playoutMode: 'Continuous',
|
||||
mirrorSourceChannelId: null,
|
||||
playoutOffset: null,
|
||||
streamingMode: 'TransportStreamHybrid',
|
||||
watermarkId: null,
|
||||
fallbackFillerId: null,
|
||||
playoutCount: 0,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
subtitleMode: 'None',
|
||||
musicVideoCreditsMode: 'None',
|
||||
musicVideoCreditsTemplate: null,
|
||||
songVideoMode: 'Default',
|
||||
transcodeMode: 'OnDemand',
|
||||
idleBehavior: 'StopOnDisconnect',
|
||||
isEnabled: true,
|
||||
showInEpg: true
|
||||
};
|
||||
|
||||
describe('getChannelById', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('GETs the channel by id and returns the view model', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleChannel), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getChannelById(5)).resolves.toMatchObject({ id: 5, name: 'Cartoons' });
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/channels/5');
|
||||
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
|
||||
});
|
||||
|
||||
it('rejects with the ApiError status on 404', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 404, title: 'Not Found' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 404
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getChannelById(999)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateChannel', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('PUTs the request body to the channel id and returns the updated view model', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ...sampleChannel, name: 'Renamed' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
const body = { ...sampleChannel, name: 'Renamed' } as unknown as UpdateChannelRequest;
|
||||
|
||||
await expect(updateChannel(5, body)).resolves.toMatchObject({ name: 'Renamed' });
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/channels/5');
|
||||
expect((init?.method ?? '').toUpperCase()).toBe('PUT');
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({ name: 'Renamed', number: '5' });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import type { components } from './generated/v1';
|
||||
|
||||
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
|
||||
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
|
||||
export type Channel = components['schemas']['ChannelViewModel'];
|
||||
export type UpdateChannelRequest = components['schemas']['UpdateChannelRequest'];
|
||||
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
|
||||
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
|
||||
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
|
||||
@@ -33,6 +35,17 @@ export function getChannelStates(): Promise<ChannelState[]> {
|
||||
return request<ChannelState[]>('/api/channels/state');
|
||||
}
|
||||
|
||||
export function getChannelById(channelId: number): Promise<Channel> {
|
||||
return request<Channel>(`/api/channels/${channelId}`);
|
||||
}
|
||||
|
||||
export function updateChannel(channelId: number, body: UpdateChannelRequest): Promise<Channel> {
|
||||
return request<Channel>(`/api/channels/${channelId}`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
|
||||
const [channels, channelStates] = await Promise.all([
|
||||
getChannels(),
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
addItemsToCollection,
|
||||
createCollection,
|
||||
createSmartCollection,
|
||||
deleteCollection,
|
||||
deleteSmartCollection,
|
||||
emptyAddItemsRequest,
|
||||
getCollectionItemsPreview,
|
||||
getCollections,
|
||||
getSmartCollections,
|
||||
removeItemFromCollection,
|
||||
toAddItemsRequest,
|
||||
updateCollection,
|
||||
updateSmartCollection
|
||||
} from './collections';
|
||||
import type { LibraryBrowseItem } from './libraryBrowse';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function browseItem(id: number, mediaType: LibraryBrowseItem['mediaType']): LibraryBrowseItem {
|
||||
return {
|
||||
artwork: '',
|
||||
collectionId: null,
|
||||
collectionKind: null,
|
||||
collectionType: 'Collection',
|
||||
duration: null,
|
||||
id,
|
||||
itemCount: null,
|
||||
libraryId: null,
|
||||
libraryName: null,
|
||||
mediaItemId: id,
|
||||
mediaType,
|
||||
multiCollectionId: null,
|
||||
playlistId: null,
|
||||
rerunCollectionId: null,
|
||||
smartCollectionId: null,
|
||||
title: 'Item'
|
||||
};
|
||||
}
|
||||
|
||||
describe('collections api client', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('getCollections fetches the manual collections list', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse([{ id: 1, name: 'Movies', useCustomPlaybackOrder: false }]));
|
||||
|
||||
await expect(getCollections()).resolves.toHaveLength(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/collections', expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('createCollection POSTs the name and returns the created collection', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ id: 7, name: 'New', useCustomPlaybackOrder: false }, 201));
|
||||
|
||||
await expect(createCollection({ name: 'New' })).resolves.toMatchObject({ id: 7 });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/collections');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ name: 'New' });
|
||||
});
|
||||
|
||||
it('updateCollection PUTs name and useCustomPlaybackOrder', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ id: 3, name: 'Renamed', useCustomPlaybackOrder: true }));
|
||||
|
||||
await updateCollection(3, { name: 'Renamed', useCustomPlaybackOrder: true });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/collections/3');
|
||||
expect(init).toMatchObject({ method: 'PUT' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ name: 'Renamed', useCustomPlaybackOrder: true });
|
||||
});
|
||||
|
||||
it('deleteCollection issues a DELETE and resolves on 204', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
|
||||
await expect(deleteCollection(9)).resolves.toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/collections/9', expect.objectContaining({ method: 'DELETE' }));
|
||||
});
|
||||
|
||||
it('addItemsToCollection POSTs the bucketed request', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
const body = { ...emptyAddItemsRequest(), movieIds: [11, 12] };
|
||||
|
||||
await addItemsToCollection(5, body);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/collections/5/items');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body)).movieIds).toEqual([11, 12]);
|
||||
});
|
||||
|
||||
it('removeItemFromCollection DELETEs the item by media-item id', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
|
||||
await removeItemFromCollection(5, 42);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/collections/5/items/42', expect.objectContaining({ method: 'DELETE' }));
|
||||
});
|
||||
|
||||
it('rethrows API errors (e.g. 422 on delete)', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
jsonResponse({ status: 422, title: 'In use' }, 422)
|
||||
);
|
||||
|
||||
await expect(deleteCollection(1)).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('getSmartCollections / create / update / delete hit the smart-collection routes', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url === '/api/smart-collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse([{ id: 1, name: 'Action', query: 'genre:action' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections' && method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:scifi' }, 201));
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections/2' && method === 'PUT') {
|
||||
return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:"science fiction"' }));
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections/2' && method === 'DELETE') {
|
||||
return Promise.resolve(noContent());
|
||||
}
|
||||
|
||||
throw new Error(`unexpected ${method} ${url}`);
|
||||
});
|
||||
|
||||
await expect(getSmartCollections()).resolves.toHaveLength(1);
|
||||
await expect(createSmartCollection({ name: 'Sci-Fi', query: 'genre:scifi' })).resolves.toMatchObject({ id: 2 });
|
||||
await expect(
|
||||
updateSmartCollection(2, { name: 'Sci-Fi', query: 'genre:"science fiction"' })
|
||||
).resolves.toMatchObject({ query: 'genre:"science fiction"' });
|
||||
await expect(deleteSmartCollection(2)).resolves.toBeUndefined();
|
||||
|
||||
const createCall = fetchMock.mock.calls.find(([, init]) => (init?.method ?? '').toUpperCase() === 'POST');
|
||||
expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' });
|
||||
});
|
||||
|
||||
it('getCollectionItemsPreview issues a quoted collection: Lucene query', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
|
||||
|
||||
await getCollectionItemsPreview('The Office');
|
||||
|
||||
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
||||
expect(url.pathname).toBe('/api/library/browse');
|
||||
expect(url.searchParams.get('query')).toBe('collection:"The Office"');
|
||||
});
|
||||
|
||||
it('getCollectionItemsPreview returns [] for a blank name without calling the API', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
|
||||
|
||||
await expect(getCollectionItemsPreview(' ')).resolves.toEqual([]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAddItemsRequest bucket mapping', () => {
|
||||
it('routes each browse media type into its own bucket using item.id', () => {
|
||||
const result = toAddItemsRequest([
|
||||
browseItem(10, 'Movie'),
|
||||
browseItem(11, 'Movie'),
|
||||
browseItem(20, 'TelevisionShow'),
|
||||
browseItem(30, 'TelevisionSeason'),
|
||||
browseItem(40, 'Artist')
|
||||
]);
|
||||
|
||||
expect(result.movieIds).toEqual([10, 11]);
|
||||
expect(result.showIds).toEqual([20]);
|
||||
expect(result.seasonIds).toEqual([30]);
|
||||
expect(result.artistIds).toEqual([40]);
|
||||
});
|
||||
|
||||
it('skips kinds that are not addable media items (collections, playlists, etc.)', () => {
|
||||
const result = toAddItemsRequest([
|
||||
browseItem(1, 'Collection'),
|
||||
browseItem(2, 'SmartCollection'),
|
||||
browseItem(3, 'MultiCollection'),
|
||||
browseItem(4, 'RerunCollection'),
|
||||
browseItem(5, 'Playlist'),
|
||||
browseItem(6, 'Movie')
|
||||
]);
|
||||
|
||||
expect(result.movieIds).toEqual([6]);
|
||||
expect(result.showIds).toEqual([]);
|
||||
expect(result.seasonIds).toEqual([]);
|
||||
expect(result.artistIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('emptyAddItemsRequest leaves the picker-unreachable buckets empty', () => {
|
||||
const empty = emptyAddItemsRequest();
|
||||
|
||||
expect(empty.episodeIds).toEqual([]);
|
||||
expect(empty.musicVideoIds).toEqual([]);
|
||||
expect(empty.otherVideoIds).toEqual([]);
|
||||
expect(empty.songIds).toEqual([]);
|
||||
expect(empty.imageIds).toEqual([]);
|
||||
expect(empty.remoteStreamIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
import { getLibraryBrowseItems, type LibraryBrowseItem } from './libraryBrowse';
|
||||
|
||||
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
|
||||
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
|
||||
export type CreateCollectionRequest = components['schemas']['CreateCollectionRequest'];
|
||||
export type UpdateCollectionRequest = components['schemas']['UpdateCollectionRequest'];
|
||||
export type AddItemsToCollectionRequest = components['schemas']['AddItemsToCollectionRequest'];
|
||||
export type CreateSmartCollectionRequest = components['schemas']['CreateSmartCollectionRequest'];
|
||||
export type UpdateSmartCollectionRequest = components['schemas']['UpdateSmartCollectionRequest'];
|
||||
|
||||
/* ---------- manual collections ---------- */
|
||||
|
||||
export function getCollections(): Promise<MediaCollection[]> {
|
||||
return request<MediaCollection[]>('/api/collections');
|
||||
}
|
||||
|
||||
export function getCollection(id: number): Promise<MediaCollection> {
|
||||
return request<MediaCollection>(`/api/collections/${id}`);
|
||||
}
|
||||
|
||||
export function createCollection(body: CreateCollectionRequest): Promise<MediaCollection> {
|
||||
return request<MediaCollection>('/api/collections', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateCollection(id: number, body: UpdateCollectionRequest): Promise<MediaCollection> {
|
||||
return request<MediaCollection>(`/api/collections/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteCollection(id: number): Promise<void> {
|
||||
return request<void>(`/api/collections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function addItemsToCollection(id: number, body: AddItemsToCollectionRequest): Promise<void> {
|
||||
return request<void>(`/api/collections/${id}/items`, { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function removeItemFromCollection(id: number, mediaItemId: number): Promise<void> {
|
||||
return request<void>(`/api/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/* ---------- smart collections ---------- */
|
||||
|
||||
export function getSmartCollections(): Promise<SmartCollection[]> {
|
||||
return request<SmartCollection[]>('/api/smart-collections');
|
||||
}
|
||||
|
||||
export function getSmartCollection(id: number): Promise<SmartCollection> {
|
||||
return request<SmartCollection>(`/api/smart-collections/${id}`);
|
||||
}
|
||||
|
||||
export function createSmartCollection(body: CreateSmartCollectionRequest): Promise<SmartCollection> {
|
||||
return request<SmartCollection>('/api/smart-collections', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateSmartCollection(id: number, body: UpdateSmartCollectionRequest): Promise<SmartCollection> {
|
||||
return request<SmartCollection>(`/api/smart-collections/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteSmartCollection(id: number): Promise<void> {
|
||||
return request<void>(`/api/smart-collections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/* ---------- add-items bucket mapping ---------- */
|
||||
|
||||
// The add-items request buckets media-item ids by kind. The library-browse search only
|
||||
// surfaces four kinds (Movie / TelevisionShow / TelevisionSeason / Artist), so those are
|
||||
// the only buckets reachable from the picker. The remaining buckets (episodes, music
|
||||
// videos, songs, images, other videos, remote streams) can't be produced by browse and
|
||||
// are left empty here. See CollectionsScreen for the honest note about this limit.
|
||||
export function emptyAddItemsRequest(): AddItemsToCollectionRequest {
|
||||
return {
|
||||
artistIds: [],
|
||||
episodeIds: [],
|
||||
imageIds: [],
|
||||
movieIds: [],
|
||||
musicVideoIds: [],
|
||||
otherVideoIds: [],
|
||||
remoteStreamIds: [],
|
||||
seasonIds: [],
|
||||
showIds: [],
|
||||
songIds: []
|
||||
};
|
||||
}
|
||||
|
||||
// Buckets a set of browse results into an AddItemsToCollectionRequest. Movie / Show /
|
||||
// Season / Artist are the only kinds library-browse can return as concrete media; each
|
||||
// carries its media-item id in `id` (which equals `mediaItemId` for these types, since
|
||||
// they are all MediaItem subclasses). Ids must go into their type-specific bucket, since
|
||||
// the server validates each bucket against that entity type (a Show id in movieIds fails
|
||||
// validation). Any other kind (collections, smart/multi/rerun collections, playlists) is
|
||||
// not an addable media item and is skipped.
|
||||
export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollectionRequest {
|
||||
const requestBody = emptyAddItemsRequest();
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.mediaType) {
|
||||
case 'Movie':
|
||||
requestBody.movieIds?.push(item.id);
|
||||
break;
|
||||
case 'TelevisionShow':
|
||||
requestBody.showIds?.push(item.id);
|
||||
break;
|
||||
case 'TelevisionSeason':
|
||||
requestBody.seasonIds?.push(item.id);
|
||||
break;
|
||||
case 'Artist':
|
||||
requestBody.artistIds?.push(item.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
// Best-effort partial listing of a manual collection's members. No API returns a manual
|
||||
// collection's items by id; the only path is the Lucene `collection:"name"` search field
|
||||
// via library-browse, which covers Movie / Show / Season / Artist only. Callers must treat
|
||||
// this as an incomplete preview, never as the authoritative contents.
|
||||
export async function getCollectionItemsPreview(name: string): Promise<LibraryBrowseItem[]> {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const escaped = trimmed.replace(/"/g, '\\"');
|
||||
const result = await getLibraryBrowseItems({ pageSize: 100, query: `collection:"${escaped}"` });
|
||||
return result.page ?? [];
|
||||
}
|
||||
|
||||
export function messageFromCollectionError(error: unknown, fallback = 'Unable to load collections'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export * from './auth';
|
||||
export * from './channels';
|
||||
export * from './channelTemplates';
|
||||
export * from './client';
|
||||
export * from './collections';
|
||||
export * from './dashboard';
|
||||
export * from './guide';
|
||||
export * from './libraries';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
import { getCollections, getSmartCollections, type MediaCollection, type SmartCollection } from './collections';
|
||||
import { getFillerPresets, getWatermarks } from './pickers';
|
||||
import type { FillerPreset, Watermark } from './pickers';
|
||||
|
||||
@@ -21,8 +22,6 @@ export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemView
|
||||
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
|
||||
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
|
||||
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
|
||||
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
|
||||
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
|
||||
export type { FillerPreset, Watermark };
|
||||
|
||||
export interface SchedulePickerData {
|
||||
@@ -66,14 +65,6 @@ export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleIte
|
||||
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
|
||||
}
|
||||
|
||||
export function getCollections(): Promise<MediaCollection[]> {
|
||||
return request<MediaCollection[]>('/api/collections');
|
||||
}
|
||||
|
||||
export function getSmartCollections(): Promise<SmartCollection[]> {
|
||||
return request<SmartCollection[]>('/api/smart-collections');
|
||||
}
|
||||
|
||||
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
|
||||
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChannelEditScreen } from './ChannelEditScreen';
|
||||
|
||||
const channel = {
|
||||
id: 5,
|
||||
number: '5',
|
||||
name: 'Cartoons',
|
||||
group: 'ChicoryTV',
|
||||
categories: 'Kids',
|
||||
fFmpegProfileId: 1,
|
||||
slugSeconds: null,
|
||||
logo: { path: '', contentType: '' },
|
||||
streamSelectorMode: 'Default',
|
||||
streamSelector: null,
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
playoutSource: 'Generated',
|
||||
playoutMode: 'Continuous',
|
||||
mirrorSourceChannelId: null,
|
||||
playoutOffset: null,
|
||||
streamingMode: 'TransportStreamHybrid',
|
||||
watermarkId: null,
|
||||
fallbackFillerId: null,
|
||||
playoutCount: 0,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
subtitleMode: 'None',
|
||||
musicVideoCreditsMode: 'None',
|
||||
musicVideoCreditsTemplate: null,
|
||||
songVideoMode: 'Default',
|
||||
transcodeMode: 'OnDemand',
|
||||
idleBehavior: 'StopOnDisconnect',
|
||||
isEnabled: true,
|
||||
showInEpg: true
|
||||
};
|
||||
|
||||
// The PUT body is the GET response minus the read-only id / playoutCount fields.
|
||||
function expectedPutBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const { id, playoutCount, ...rest } = channel;
|
||||
void id;
|
||||
void playoutCount;
|
||||
return { ...rest, ...overrides };
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
channelStatus?: number;
|
||||
onPut?: (body: unknown) => void;
|
||||
}
|
||||
|
||||
function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url === '/api/channels/5' && method === 'PUT') {
|
||||
onPut?.(JSON.parse(init?.body as string));
|
||||
return Promise.resolve(json({ ...channel, name: 'Renamed' }));
|
||||
}
|
||||
|
||||
if (url === '/api/channels/5') {
|
||||
return channelStatus === 200
|
||||
? Promise.resolve(json(channel))
|
||||
: Promise.resolve(json({ status: channelStatus, title: 'Not Found' }, channelStatus));
|
||||
}
|
||||
|
||||
if (url === '/api/ffmpeg/profiles') {
|
||||
return Promise.resolve(json([{ id: 1, name: 'Default profile' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/watermarks') {
|
||||
return Promise.resolve(json([{ id: 2, name: 'Corner bug' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/filler-presets') {
|
||||
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/channels') {
|
||||
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
|
||||
}
|
||||
|
||||
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChannelEditScreen', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/app/edit-channel/5');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('hydrates the form from the loaded channel', async () => {
|
||||
mockApi();
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
expect(await screen.findByDisplayValue('Cartoons')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Kids')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves an edited field with a PUT carrying the updated body', async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({ onPut: (body) => puts.push(body) });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.change(nameInput, { target: { value: 'Renamed' } });
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
|
||||
expect(puts[0]).toEqual(expectedPutBody({ name: 'Renamed' }));
|
||||
|
||||
expect(await screen.findByText('Channel saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forces showInEpg off in the PUT body when Enabled is turned off', async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({ onPut: (body) => puts.push(body) });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
|
||||
// The General pane renders exactly two switches, in document order: Enabled, then Show in EPG.
|
||||
const [enabledSwitch, showInEpgSwitch] = screen.getAllByRole('switch');
|
||||
fireEvent.click(enabledSwitch);
|
||||
|
||||
expect(showInEpgSwitch).toHaveAttribute('aria-checked', 'false');
|
||||
expect(showInEpgSwitch).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
|
||||
expect(puts[0]).toEqual(expectedPutBody({ isEnabled: false, showInEpg: false }));
|
||||
});
|
||||
|
||||
it('shows an error state when the channel is not found', async () => {
|
||||
mockApi({ channelStatus: 404 });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
expect(await screen.findByText('Channel not found.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Back to channels' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,903 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
AudioLines,
|
||||
Check,
|
||||
CircleCheck,
|
||||
Image as ImageIcon,
|
||||
ListVideo,
|
||||
Music,
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
TriangleAlert,
|
||||
Upload
|
||||
} from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ChannelLogo, Input, Select, Switch } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
getChannelById,
|
||||
getChannels,
|
||||
getFFmpegProfiles,
|
||||
getFillerPresets,
|
||||
getWatermarks,
|
||||
messageFromError,
|
||||
updateChannel,
|
||||
uploadArtwork,
|
||||
type Channel,
|
||||
type ChannelSummary,
|
||||
type FFmpegProfile,
|
||||
type FillerPreset,
|
||||
type UpdateChannelRequest,
|
||||
type Watermark
|
||||
} from '../api';
|
||||
|
||||
const EDIT_BASE_PATH = '/app/edit-channel';
|
||||
|
||||
type SectionId = 'general' | 'playout' | 'streaming' | 'selection' | 'music' | 'branding';
|
||||
|
||||
interface SectionDef {
|
||||
hint: string;
|
||||
icon: ReactNode;
|
||||
id: SectionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SECTIONS: SectionDef[] = [
|
||||
{ hint: 'Number, name, group', icon: <Tv aria-hidden="true" size={16} />, id: 'general', label: 'General' },
|
||||
{ hint: 'Source & scheduling', icon: <ListVideo aria-hidden="true" size={16} />, id: 'playout', label: 'Playout' },
|
||||
{ hint: 'FFmpeg & output', icon: <SlidersHorizontal aria-hidden="true" size={16} />, id: 'streaming', label: 'Streaming' },
|
||||
{ hint: 'Audio & subtitles', icon: <AudioLines aria-hidden="true" size={16} />, id: 'selection', label: 'Stream selection' },
|
||||
{ hint: 'Credits & songs', icon: <Music aria-hidden="true" size={16} />, id: 'music', label: 'Music video' },
|
||||
{ hint: 'Logo & watermark', icon: <ImageIcon aria-hidden="true" size={16} />, id: 'branding', label: 'Branding' }
|
||||
];
|
||||
|
||||
const SLUG_SECONDS_OPTIONS = [
|
||||
{ label: '(none)', value: '' },
|
||||
{ label: '0.5 seconds', value: '0.5' },
|
||||
{ label: '1 second', value: '1' },
|
||||
{ label: '2 seconds', value: '2' },
|
||||
{ label: '3 seconds', value: '3' },
|
||||
{ label: '5 seconds', value: '5' }
|
||||
];
|
||||
|
||||
// Server serializes TimeSpan with the constant ("c") format: [-][d.]hh:mm:ss. The Blazor
|
||||
// editor exposes the offset as whole hours, so we translate to/from that here. Hours >= 24
|
||||
// must use the days component, or the value fails to round-trip on the wire.
|
||||
function timeSpanToHours(value: null | string | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const negative = value.startsWith('-');
|
||||
const body = negative ? value.slice(1) : value;
|
||||
const dotIndex = body.indexOf('.');
|
||||
const colonIndex = body.indexOf(':');
|
||||
let days = 0;
|
||||
let rest = body;
|
||||
|
||||
if (dotIndex !== -1 && (colonIndex === -1 || dotIndex < colonIndex)) {
|
||||
days = Number(body.slice(0, dotIndex));
|
||||
rest = body.slice(dotIndex + 1);
|
||||
}
|
||||
|
||||
const hours = Number(rest.split(':')[0] ?? '0');
|
||||
const total = days * 24 + hours;
|
||||
return negative ? -total : total;
|
||||
}
|
||||
|
||||
function hoursToTimeSpan(hours: number): null | string {
|
||||
if (!Number.isFinite(hours) || hours === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const negative = hours < 0;
|
||||
const abs = Math.abs(Math.trunc(hours));
|
||||
const days = Math.floor(abs / 24);
|
||||
const rem = abs % 24;
|
||||
const core = days > 0
|
||||
? `${days}.${String(rem).padStart(2, '0')}:00:00`
|
||||
: `${rem}:00:00`;
|
||||
return `${negative ? '-' : ''}${core}`;
|
||||
}
|
||||
|
||||
function channelIdFromPathname(pathname: string): number | null {
|
||||
const normalized = pathname.replace(/\/+$/, '');
|
||||
|
||||
if (!normalized.startsWith(`${EDIT_BASE_PATH}/`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = Number(normalized.slice(EDIT_BASE_PATH.length + 1).split('/')[0]);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
// The PUT body is the GET response minus the read-only id / playoutCount / webEncodedName
|
||||
// fields, so hydration is a straight field copy.
|
||||
function draftFromChannel(channel: Channel): UpdateChannelRequest {
|
||||
return {
|
||||
name: channel.name,
|
||||
number: channel.number,
|
||||
group: channel.group,
|
||||
categories: channel.categories,
|
||||
fFmpegProfileId: channel.fFmpegProfileId,
|
||||
slugSeconds: channel.slugSeconds,
|
||||
logo: channel.logo,
|
||||
streamSelectorMode: channel.streamSelectorMode,
|
||||
streamSelector: channel.streamSelector,
|
||||
preferredAudioLanguageCode: channel.preferredAudioLanguageCode,
|
||||
preferredAudioTitle: channel.preferredAudioTitle,
|
||||
playoutSource: channel.playoutSource,
|
||||
playoutMode: channel.playoutMode,
|
||||
mirrorSourceChannelId: channel.mirrorSourceChannelId,
|
||||
playoutOffset: channel.playoutOffset,
|
||||
streamingMode: channel.streamingMode,
|
||||
watermarkId: channel.watermarkId,
|
||||
fallbackFillerId: channel.fallbackFillerId,
|
||||
preferredSubtitleLanguageCode: channel.preferredSubtitleLanguageCode,
|
||||
subtitleMode: channel.subtitleMode,
|
||||
musicVideoCreditsMode: channel.musicVideoCreditsMode,
|
||||
musicVideoCreditsTemplate: channel.musicVideoCreditsTemplate,
|
||||
songVideoMode: channel.songVideoMode,
|
||||
transcodeMode: channel.transcodeMode,
|
||||
idleBehavior: channel.idleBehavior,
|
||||
isEnabled: channel.isEnabled,
|
||||
showInEpg: channel.showInEpg
|
||||
};
|
||||
}
|
||||
|
||||
function numberOrEmpty(value: null | number | undefined): string {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function sortByChannelNumber(channels: ChannelSummary[]): ChannelSummary[] {
|
||||
return [...channels].sort((left, right) => {
|
||||
const leftNumber = Number.parseFloat(left.number);
|
||||
const rightNumber = Number.parseFloat(right.number);
|
||||
|
||||
if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) {
|
||||
return left.number.localeCompare(right.number, undefined, { numeric: true });
|
||||
}
|
||||
|
||||
return leftNumber - rightNumber;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- shared row primitives (mirror SettingsScreen) ---------- */
|
||||
|
||||
function Row({
|
||||
children,
|
||||
control = 300,
|
||||
first = false,
|
||||
help,
|
||||
label
|
||||
}: {
|
||||
children: ReactNode;
|
||||
control?: number;
|
||||
first?: boolean;
|
||||
help?: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="ctv-settings-row" style={first ? { borderTop: 'none' } : undefined}>
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">{label}</div>
|
||||
{help && <div className="ctv-settings-row-help">{help}</div>}
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ flex: `0 0 ${control}px` }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pane({ children, subtitle, title }: { children: ReactNode; subtitle: string; title: string }) {
|
||||
return (
|
||||
<div className="ctv-settings-pane">
|
||||
<div className="ctv-settings-pane-header">
|
||||
<div className="ctv-settings-pane-title">{title}</div>
|
||||
<div className="ctv-settings-pane-subtitle">{subtitle}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReferenceData {
|
||||
channels: ChannelSummary[];
|
||||
fillerPresets: FillerPreset[];
|
||||
watermarks: Watermark[];
|
||||
ffmpegProfiles: FFmpegProfile[];
|
||||
}
|
||||
|
||||
/* ---------- panes ---------- */
|
||||
|
||||
function GeneralPane({
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Pane subtitle="Identity shown in the lineup, guide and IPTV playlist." title="General">
|
||||
<Card padded={false}>
|
||||
<Row control={200} first help="Displayed channel number, e.g. 5 or 5.1." label="Number">
|
||||
<Input
|
||||
error={draft.number?.trim() ? null : 'Number is required'}
|
||||
fullWidth
|
||||
onChange={(event) => set({ number: event.target.value })}
|
||||
size="sm"
|
||||
value={draft.number ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Channel display name." label="Name">
|
||||
<Input
|
||||
error={draft.name?.trim() ? null : 'Name is required'}
|
||||
fullWidth
|
||||
onChange={(event) => set({ name: event.target.value })}
|
||||
size="sm"
|
||||
value={draft.name ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Groups channels in clients that support it." label="Group">
|
||||
<Input fullWidth onChange={(event) => set({ group: event.target.value })} size="sm" value={draft.group ?? ''} />
|
||||
</Row>
|
||||
<Row help="Comma-separated list of categories." label="Categories">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ categories: event.target.value })}
|
||||
placeholder="News, Sports"
|
||||
size="sm"
|
||||
value={draft.categories ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Whether this channel is served to clients." label="Enabled">
|
||||
<Switch
|
||||
checked={draft.isEnabled}
|
||||
onChange={(next) => set(next ? { isEnabled: next } : { isEnabled: next, showInEpg: false })}
|
||||
size="sm"
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Include this channel in the XMLTV guide." label="Show in EPG">
|
||||
<Switch
|
||||
checked={draft.showInEpg}
|
||||
disabled={!draft.isEnabled}
|
||||
onChange={(next) => set({ showInEpg: next })}
|
||||
size="sm"
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutPane({
|
||||
channelId,
|
||||
data,
|
||||
draft,
|
||||
playoutLocked,
|
||||
set
|
||||
}: {
|
||||
channelId: number;
|
||||
data: ReferenceData;
|
||||
draft: UpdateChannelRequest;
|
||||
playoutLocked: boolean;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
const mirrorOptions = [
|
||||
{ label: '(none)', value: '' },
|
||||
...sortByChannelNumber(data.channels.filter((channel) => channel.id !== channelId)).map((channel) => ({
|
||||
label: `(${channel.number}) - ${channel.name}`,
|
||||
value: String(channel.id)
|
||||
}))
|
||||
];
|
||||
|
||||
return (
|
||||
<Pane subtitle="Where the channel's content comes from and how it progresses." title="Playout">
|
||||
<Card padded={false}>
|
||||
<Row
|
||||
first
|
||||
help={
|
||||
playoutLocked
|
||||
? 'Cannot be changed once a generated channel has a playout.'
|
||||
: 'Generated builds its own schedule; Mirror follows another channel.'
|
||||
}
|
||||
label="Playout source"
|
||||
>
|
||||
<Select
|
||||
disabled={playoutLocked}
|
||||
fullWidth
|
||||
onChange={(event) => set({ playoutSource: event.target.value as UpdateChannelRequest['playoutSource'] })}
|
||||
options={[
|
||||
{ label: 'Generated', value: 'Generated' },
|
||||
{ label: 'Mirror', value: 'Mirror' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.playoutSource}
|
||||
/>
|
||||
</Row>
|
||||
{draft.playoutSource === 'Mirror' ? (
|
||||
<>
|
||||
<Row help="The generated channel this channel mirrors." label="Mirror source channel">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) =>
|
||||
set({ mirrorSourceChannelId: event.target.value === '' ? null : Number(event.target.value) })
|
||||
}
|
||||
options={mirrorOptions}
|
||||
size="sm"
|
||||
value={draft.mirrorSourceChannelId == null ? '' : String(draft.mirrorSourceChannelId)}
|
||||
/>
|
||||
</Row>
|
||||
<Row control={160} help="Shift the mirrored playout by this many hours." label="Playout offset">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ playoutOffset: hoursToTimeSpan(Number(event.target.value)) })}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
trailing={<span className="ctv-settings-unit">hours</span>}
|
||||
type="number"
|
||||
value={String(timeSpanToHours(draft.playoutOffset))}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<Row help="Controls how the generated playout progresses." label="Playout mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ playoutMode: event.target.value as UpdateChannelRequest['playoutMode'] })}
|
||||
options={[
|
||||
{ label: 'Continuous', value: 'Continuous' },
|
||||
{ label: 'On Demand', value: 'OnDemand' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.playoutMode}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row help="How the transcoder behaves once all clients disconnect." label="Idle behavior">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ idleBehavior: event.target.value as UpdateChannelRequest['idleBehavior'] })}
|
||||
options={[
|
||||
{ label: 'Stop On Disconnect', value: 'StopOnDisconnect' },
|
||||
{ label: 'Keep Running', value: 'KeepRunning' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.idleBehavior}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="When the transcoding process is active. Only On Demand is supported." label="Transcode mode">
|
||||
<Select disabled fullWidth options={[{ label: 'On Demand', value: 'OnDemand' }]} size="sm" value={draft.transcodeMode} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingPane({
|
||||
data,
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
data: ReferenceData;
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
|
||||
|
||||
return (
|
||||
<Pane subtitle="Output container and the transcoding profile applied to this channel." title="Streaming">
|
||||
<Card padded={false}>
|
||||
<Row first help="Delivery container / protocol served to clients." label="Streaming mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ streamingMode: event.target.value as UpdateChannelRequest['streamingMode'] })}
|
||||
options={[
|
||||
{ label: 'MPEG-TS', value: 'TransportStreamHybrid' },
|
||||
{ label: 'MPEG-TS (Legacy)', value: 'TransportStream' },
|
||||
{ label: 'HLS Direct', value: 'HttpLiveStreamingDirect' },
|
||||
{ label: 'HLS Segmenter', value: 'HttpLiveStreamingSegmenter' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.streamingMode}
|
||||
/>
|
||||
</Row>
|
||||
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Transcoding preset for this channel.'} label="FFmpeg profile">
|
||||
<Select
|
||||
disabled={hlsDirect}
|
||||
fullWidth
|
||||
onChange={(event) => set({ fFmpegProfileId: Number(event.target.value) })}
|
||||
options={data.ffmpegProfiles.map((profile) => ({
|
||||
label: profile.name ?? `Profile ${profile.id}`,
|
||||
value: String(profile.id)
|
||||
}))}
|
||||
size="sm"
|
||||
value={String(draft.fFmpegProfileId)}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Black video / silent audio inserted between every playout item." label="Slug seconds">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ slugSeconds: event.target.value === '' ? null : Number(event.target.value) })}
|
||||
options={SLUG_SECONDS_OPTIONS}
|
||||
size="sm"
|
||||
value={numberOrEmpty(draft.slugSeconds)}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionPane({
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
const isDefault = draft.streamSelectorMode === 'Default';
|
||||
|
||||
return (
|
||||
<Pane subtitle="How audio and subtitle tracks are chosen for playback." title="Stream selection">
|
||||
<Card padded={false}>
|
||||
<Row first help="Default picks tracks by preference; Custom uses a selector script." label="Stream selector mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) =>
|
||||
set({ streamSelectorMode: event.target.value as UpdateChannelRequest['streamSelectorMode'] })
|
||||
}
|
||||
options={[
|
||||
{ label: 'Default', value: 'Default' },
|
||||
{ label: 'Custom', value: 'Custom' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.streamSelectorMode}
|
||||
/>
|
||||
</Row>
|
||||
{isDefault ? (
|
||||
<>
|
||||
<Row help="ISO 639-2 code, e.g. eng. Blank keeps the file order." label="Preferred audio language">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ preferredAudioLanguageCode: event.target.value || null })}
|
||||
placeholder="eng"
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.preferredAudioLanguageCode ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Prefer an audio track whose title contains this text." label="Preferred audio title">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ preferredAudioTitle: event.target.value || null })}
|
||||
size="sm"
|
||||
value={draft.preferredAudioTitle ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="ISO 639-2 code, e.g. eng. Blank disables preference." label="Preferred subtitle language">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ preferredSubtitleLanguageCode: event.target.value || null })}
|
||||
placeholder="eng"
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.preferredSubtitleLanguageCode ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="When subtitle tracks are burned in." label="Subtitle mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ subtitleMode: event.target.value as UpdateChannelRequest['subtitleMode'] })}
|
||||
options={[
|
||||
{ label: 'None', value: 'None' },
|
||||
{ label: 'Forced', value: 'Forced' },
|
||||
{ label: 'Default', value: 'Default' },
|
||||
{ label: 'Any', value: 'Any' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.subtitleMode}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<Row help="Name of a custom stream-selector script in the config folder." label="Stream selector">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ streamSelector: event.target.value || null })}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.streamSelector ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function MusicPane({
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
const creditsOn = draft.musicVideoCreditsMode === 'GenerateSubtitles';
|
||||
|
||||
return (
|
||||
<Pane subtitle="Overlays and progress behavior for music video and song content." title="Music video">
|
||||
<Card padded={false}>
|
||||
<Row first help="Generate on-screen credits for music videos." label="Credits mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) =>
|
||||
set({ musicVideoCreditsMode: event.target.value as UpdateChannelRequest['musicVideoCreditsMode'] })
|
||||
}
|
||||
options={[
|
||||
{ label: 'None', value: 'None' },
|
||||
{ label: 'Generate Subtitles', value: 'GenerateSubtitles' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.musicVideoCreditsMode}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Name of the credits template. Only used when credits are generated." label="Credits template">
|
||||
<Input
|
||||
disabled={!creditsOn}
|
||||
fullWidth
|
||||
onChange={(event) => set({ musicVideoCreditsTemplate: event.target.value || null })}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.musicVideoCreditsTemplate ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Optional progress bar overlay for song content." label="Song video mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ songVideoMode: event.target.value as UpdateChannelRequest['songVideoMode'] })}
|
||||
options={[
|
||||
{ label: 'Default', value: 'Default' },
|
||||
{ label: 'With Progress', value: 'WithProgress' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.songVideoMode}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandingPane({
|
||||
data,
|
||||
draft,
|
||||
logoPreview,
|
||||
onUploadLogo,
|
||||
set,
|
||||
uploadError,
|
||||
uploading
|
||||
}: {
|
||||
data: ReferenceData;
|
||||
draft: UpdateChannelRequest;
|
||||
logoPreview: string | null;
|
||||
onUploadLogo: (file: File) => void;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
uploadError: string | null;
|
||||
uploading: boolean;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
|
||||
const existingLogo = draft.logo?.urlWithContentType ?? draft.logo?.path ?? null;
|
||||
const previewSrc = logoPreview ?? existingLogo;
|
||||
|
||||
return (
|
||||
<Pane subtitle="Channel logo and the overlays applied while streaming." title="Branding">
|
||||
<Card padded={false}>
|
||||
<Row control={340} first help="Shown in the guide and as the on-screen bug." label="Logo">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<ChannelLogo name={draft.name ?? ''} size={48} src={previewSrc} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<Button
|
||||
loading={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
size="sm"
|
||||
startIcon={<Upload aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Upload logo
|
||||
</Button>
|
||||
{uploadError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{uploadError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
if (file) {
|
||||
onUploadLogo(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
|
||||
<Select
|
||||
disabled={hlsDirect}
|
||||
fullWidth
|
||||
onChange={(event) => set({ watermarkId: event.target.value === '' ? null : Number(event.target.value) })}
|
||||
options={[
|
||||
{ label: '(none)', value: '' },
|
||||
...data.watermarks.map((watermark) => ({
|
||||
label: watermark.name ?? `Watermark ${watermark.id}`,
|
||||
value: String(watermark.id)
|
||||
}))
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.watermarkId == null ? '' : String(draft.watermarkId)}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Plays when the playout has nothing scheduled." label="Fallback filler">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ fallbackFillerId: event.target.value === '' ? null : Number(event.target.value) })}
|
||||
options={[
|
||||
{ label: '(none)', value: '' },
|
||||
...data.fillerPresets.map((preset) => ({
|
||||
label: preset.name ?? `Filler ${preset.id}`,
|
||||
value: String(preset.id)
|
||||
}))
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.fallbackFillerId == null ? '' : String(draft.fallbackFillerId)}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- screen ---------- */
|
||||
|
||||
interface LoadedState {
|
||||
channelId: number;
|
||||
data: ReferenceData;
|
||||
playoutLocked: boolean;
|
||||
}
|
||||
|
||||
export function ChannelEditScreen() {
|
||||
const [channelId] = useState<number | null>(() => channelIdFromPathname(window.location.pathname));
|
||||
const [loaded, setLoaded] = useState<LoadedState | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<UpdateChannelRequest | null>(null);
|
||||
const [saved, setSaved] = useState<UpdateChannelRequest | null>(null);
|
||||
const [section, setSection] = useState<SectionId>('general');
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (channelId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
Promise.all([getChannelById(channelId), getFFmpegProfiles(), getWatermarks(), getFillerPresets(), getChannels()])
|
||||
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initial = draftFromChannel(channel);
|
||||
setDraft(initial);
|
||||
setSaved(initial);
|
||||
setLoaded({
|
||||
channelId,
|
||||
data: { channels, ffmpegProfiles, fillerPresets, watermarks },
|
||||
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setLoadError('Channel not found.');
|
||||
} else {
|
||||
setLoadError(messageFromError(error));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [channelId]);
|
||||
|
||||
const effectiveError = loadError ?? (channelId === null ? 'Invalid channel.' : null);
|
||||
|
||||
if (effectiveError) {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>{effectiveError}</span>
|
||||
<Button onClick={() => navigateToPath('/app/channels')} size="sm" variant="secondary">
|
||||
Back to channels
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loaded === null || draft === null || saved === null) {
|
||||
return (
|
||||
<div className="ctv-settings-loading">
|
||||
<span>Loading channel…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const set = (patch: Partial<UpdateChannelRequest>) => {
|
||||
setJustSaved(false);
|
||||
setDraft((current) => (current ? { ...current, ...patch } : current));
|
||||
};
|
||||
|
||||
const uploadLogo = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setUploadError('Choose an image file.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
const uploaded = await uploadArtwork(file, 'logo');
|
||||
setJustSaved(false);
|
||||
setDraft((current) =>
|
||||
current ? { ...current, logo: { path: uploaded.path, contentType: uploaded.contentType } } : current
|
||||
);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setLogoPreview(typeof reader.result === 'string' ? reader.result : null);
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
setUploadError(messageFromError(error));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nameValid = Boolean(draft.name?.trim());
|
||||
const numberValid = Boolean(draft.number?.trim());
|
||||
const valid = nameValid && numberValid;
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
|
||||
|
||||
const discard = () => {
|
||||
setDraft(saved);
|
||||
setLogoPreview(null);
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!valid || !dirty || saving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const updated = await updateChannel(loaded.channelId, draft);
|
||||
const next = draftFromChannel(updated);
|
||||
setSaved(next);
|
||||
setDraft(next);
|
||||
setLogoPreview(null);
|
||||
setJustSaved(true);
|
||||
window.setTimeout(() => setJustSaved(false), 1800);
|
||||
} catch (error) {
|
||||
setSaveError(messageFromError(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-settings">
|
||||
<nav aria-label="Channel sections" className="ctv-settings-rail">
|
||||
{SECTIONS.map((entry) => {
|
||||
const active = entry.id === section;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? 'true' : undefined}
|
||||
className={`ctv-settings-rail-item ctv-press${active ? ' ctv-settings-rail-item-active' : ''}`}
|
||||
key={entry.id}
|
||||
onClick={() => setSection(entry.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="ctv-settings-rail-icon" style={{ color: active ? 'var(--ctv-accent)' : 'var(--text-secondary)' }}>
|
||||
{entry.icon}
|
||||
</span>
|
||||
<span className="ctv-settings-rail-text">
|
||||
<span className="ctv-settings-rail-label" style={{ color: active ? 'var(--text-primary)' : 'var(--text-secondary)' }}>
|
||||
{entry.label}
|
||||
</span>
|
||||
<span className="ctv-settings-rail-hint">{entry.hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="ctv-settings-body">
|
||||
<div className="ctv-settings-scroll">
|
||||
{section === 'general' && <GeneralPane draft={draft} set={set} />}
|
||||
{section === 'playout' && (
|
||||
<PlayoutPane channelId={loaded.channelId} data={loaded.data} draft={draft} playoutLocked={loaded.playoutLocked} set={set} />
|
||||
)}
|
||||
{section === 'streaming' && <StreamingPane data={loaded.data} draft={draft} set={set} />}
|
||||
{section === 'selection' && <SelectionPane draft={draft} set={set} />}
|
||||
{section === 'music' && <MusicPane draft={draft} set={set} />}
|
||||
{section === 'branding' && (
|
||||
<BrandingPane
|
||||
data={loaded.data}
|
||||
draft={draft}
|
||||
logoPreview={logoPreview}
|
||||
onUploadLogo={(file) => void uploadLogo(file)}
|
||||
set={set}
|
||||
uploadError={uploadError}
|
||||
uploading={uploading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(dirty || justSaved) && (
|
||||
<div className="ctv-settings-savebar-wrap">
|
||||
<div className="ctv-settings-savebar ctv-lift">
|
||||
{justSaved ? (
|
||||
<>
|
||||
<CircleCheck aria-hidden="true" color="var(--status-ok)" size={15} />
|
||||
<span className="ctv-settings-savebar-text">Channel saved</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="ctv-settings-savebar-text">Unsaved changes</span>
|
||||
{!valid && <Badge tone="neutral">Name and number are required</Badge>}
|
||||
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
|
||||
<span className="ctv-settings-savebar-divider" />
|
||||
<Button onClick={discard} size="sm" variant="ghost">
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!valid || saving}
|
||||
loading={saving}
|
||||
onClick={() => void save()}
|
||||
size="sm"
|
||||
startIcon={<Check aria-hidden="true" size={14} />}
|
||||
variant="primary"
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CollectionsScreen } from './CollectionsScreen';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
const manualCollections = [
|
||||
{ collectionType: 'Collection', id: 1, name: 'Favorites', state: 'Normal', useCustomPlaybackOrder: false },
|
||||
{ collectionType: 'Collection', id: 2, name: 'Kids', state: 'Normal', useCustomPlaybackOrder: true }
|
||||
];
|
||||
|
||||
const smartCollections = [{ id: 10, name: 'Action', query: 'genre:action' }];
|
||||
|
||||
interface MockOptions {
|
||||
manual?: unknown[];
|
||||
smart?: unknown[];
|
||||
onRequest?: (url: string, method: string, body: unknown) => Response | null;
|
||||
}
|
||||
|
||||
function mockApi(options: MockOptions = {}) {
|
||||
const manual = options.manual ?? manualCollections;
|
||||
const smart = options.smart ?? smartCollections;
|
||||
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
|
||||
if (options.onRequest) {
|
||||
const override = options.onRequest(url, method, body);
|
||||
|
||||
if (override) {
|
||||
return Promise.resolve(override);
|
||||
}
|
||||
}
|
||||
|
||||
if (url === '/api/collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(manual));
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(smart));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('CollectionsScreen', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders both manual and smart collections', async () => {
|
||||
mockApi();
|
||||
render(<CollectionsScreen />);
|
||||
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kids')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Smart/ }));
|
||||
|
||||
expect(await screen.findByText('Action')).toBeInTheDocument();
|
||||
expect(screen.getByText('genre:action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error state when loading fails', async () => {
|
||||
mockApi({
|
||||
onRequest: (url) => (url === '/api/collections' ? jsonResponse({ status: 500, title: 'Boom' }, 500) : null)
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
|
||||
expect(await screen.findByText('Boom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a manual collection via POST', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/collections' && method === 'POST'
|
||||
? jsonResponse({ collectionType: 'Collection', id: 3, name: 'New One', state: 'Normal', useCustomPlaybackOrder: false }, 201)
|
||||
: null
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New collection' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Collection name'), { target: { value: 'New One' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([u, init]) => u === '/api/collections' && (init?.method ?? '').toUpperCase() === 'POST'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const postCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/collections' && (init?.method ?? '').toUpperCase() === 'POST'
|
||||
);
|
||||
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({ name: 'New One' });
|
||||
});
|
||||
|
||||
it('renames a manual collection via PUT', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/collections/1' && method === 'PUT'
|
||||
? jsonResponse({ collectionType: 'Collection', id: 1, name: 'Renamed', state: 'Normal', useCustomPlaybackOrder: false })
|
||||
: null
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Rename' })[0]);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const input = within(dialog).getByPlaceholderText('Collection name') as HTMLInputElement;
|
||||
expect(input.value).toBe('Favorites');
|
||||
fireEvent.change(input, { target: { value: 'Renamed' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
|
||||
);
|
||||
expect(putCall).toBeDefined();
|
||||
expect(JSON.parse(String(putCall?.[1]?.body))).toMatchObject({ name: 'Renamed' });
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles custom playback order via PUT', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/collections/1' && method === 'PUT'
|
||||
? jsonResponse({ collectionType: 'Collection', id: 1, name: 'Favorites', state: 'Normal', useCustomPlaybackOrder: true })
|
||||
: null
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
// First switch in the list belongs to "Favorites".
|
||||
const switches = screen.getAllByRole('switch');
|
||||
fireEvent.click(switches[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
|
||||
);
|
||||
expect(putCall).toBeDefined();
|
||||
expect(JSON.parse(String(putCall?.[1]?.body))).toMatchObject({ useCustomPlaybackOrder: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms and DELETEs a manual collection', async () => {
|
||||
const fetchMock = mockApi();
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
const rows = screen.getAllByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(rows[0]);
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText(/Delete "Favorites"/)).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([u, init]) => u === '/api/collections/1' && (init?.method ?? '').toUpperCase() === 'DELETE'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a smart collection and previews the query against browse', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) => {
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return jsonResponse({ page: [{ id: 1, mediaType: 'Movie', title: 'The Matrix' }], totalCount: 1 });
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections' && method === 'POST') {
|
||||
return jsonResponse({ id: 11, name: 'Sci-Fi', query: 'genre:scifi' }, 201);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Smart/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New smart collection' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Smart collection name'), { target: { value: 'Sci-Fi' } });
|
||||
fireEvent.change(within(dialog).getByPlaceholderText(/genre/), { target: { value: 'genre:scifi' } });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Preview results' }));
|
||||
|
||||
expect(await within(dialog).findByText('The Matrix')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('1 matches')).toBeInTheDocument();
|
||||
|
||||
const browseCall = fetchMock.mock.calls.find(([u]) => u.toString().startsWith('/api/library/browse'));
|
||||
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('genre:scifi');
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const postCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/smart-collections' && (init?.method ?? '').toUpperCase() === 'POST'
|
||||
);
|
||||
expect(postCall).toBeDefined();
|
||||
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' });
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a manual collection and previews its items via a collection: query', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url) => {
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const q = new URL(url, 'http://localhost').searchParams.get('query') ?? '';
|
||||
|
||||
if (q.startsWith('collection:')) {
|
||||
return jsonResponse({ page: [{ id: 5, mediaItemId: 5, mediaType: 'Movie', title: 'Inception' }], totalCount: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getByText('Favorites'));
|
||||
|
||||
expect(await screen.findByText('Inception')).toBeInTheDocument();
|
||||
expect(screen.getByText(/best-effort search preview/)).toBeInTheDocument();
|
||||
|
||||
const browseCall = fetchMock.mock.calls.find(([u]) =>
|
||||
u.toString().includes('collection%3A')
|
||||
);
|
||||
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,926 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
FolderTree,
|
||||
Info,
|
||||
ListVideo,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
TriangleAlert
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
IconButton,
|
||||
Input,
|
||||
Spinner,
|
||||
Switch
|
||||
} from '../components';
|
||||
import {
|
||||
addItemsToCollection,
|
||||
createCollection,
|
||||
createSmartCollection,
|
||||
deleteCollection,
|
||||
deleteSmartCollection,
|
||||
getCollectionItemsPreview,
|
||||
getCollections,
|
||||
getLibraryBrowseItems,
|
||||
getSmartCollections,
|
||||
messageFromCollectionError,
|
||||
removeItemFromCollection,
|
||||
toAddItemsRequest,
|
||||
updateCollection,
|
||||
updateSmartCollection,
|
||||
type LibraryBrowseItem,
|
||||
type MediaCollection,
|
||||
type SmartCollection
|
||||
} from '../api';
|
||||
|
||||
type Tab = 'manual' | 'smart';
|
||||
|
||||
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>([
|
||||
'Movie',
|
||||
'TelevisionShow',
|
||||
'TelevisionSeason',
|
||||
'Artist'
|
||||
]);
|
||||
|
||||
const TYPE_LABEL: Record<LibraryBrowseItem['mediaType'], string> = {
|
||||
Artist: 'Artist',
|
||||
Collection: 'Collection',
|
||||
Movie: 'Movie',
|
||||
MultiCollection: 'Multi',
|
||||
Playlist: 'Playlist',
|
||||
RerunCollection: 'Rerun',
|
||||
SmartCollection: 'Smart',
|
||||
TelevisionSeason: 'Season',
|
||||
TelevisionShow: 'Show'
|
||||
};
|
||||
|
||||
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
|
||||
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
||||
}
|
||||
|
||||
/* ---------- data hook ---------- */
|
||||
|
||||
interface CollectionsData {
|
||||
manual: MediaCollection[];
|
||||
smart: SmartCollection[];
|
||||
}
|
||||
|
||||
type CollectionsState =
|
||||
| { data: CollectionsData; error: null; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
function useCollectionsData() {
|
||||
const [state, setState] = useState<CollectionsState>({ data: null, error: null, status: 'loading' });
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch only; state updates happen in the async callbacks (never synchronously in the
|
||||
// effect body) so the initial 'loading' default stands until data resolves.
|
||||
const load = useCallback(() => {
|
||||
Promise.all([getCollections(), getSmartCollections()])
|
||||
.then(([manual, smart]) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: { manual, smart }, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromCollectionError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(
|
||||
(quiet = false) => {
|
||||
if (!quiet) {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
}
|
||||
|
||||
load();
|
||||
},
|
||||
[load]
|
||||
);
|
||||
|
||||
return { refresh, state };
|
||||
}
|
||||
|
||||
/* ---------- create / rename dialogs ---------- */
|
||||
|
||||
function NameDialog({
|
||||
busy,
|
||||
confirmLabel,
|
||||
error,
|
||||
initialName = '',
|
||||
onCancel,
|
||||
onSubmit,
|
||||
open,
|
||||
title
|
||||
}: {
|
||||
busy: boolean;
|
||||
confirmLabel: string;
|
||||
error: string | null;
|
||||
initialName?: string;
|
||||
onCancel: () => void;
|
||||
onSubmit: (name: string) => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}) {
|
||||
// Parent remounts this via `key` on each open, so `initialName` seeds fresh state
|
||||
// without a reset effect.
|
||||
const [name, setName] = useState(initialName);
|
||||
const trimmed = name.trim();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || trimmed.length === 0} loading={busy} onClick={() => onSubmit(trimmed)} variant="primary">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onCancel}
|
||||
open={open}
|
||||
title={title}
|
||||
width={440}
|
||||
>
|
||||
<Input label="Name" onChange={(event) => setName(event.target.value)} placeholder="Collection name" value={name} />
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SmartDialog({
|
||||
busy,
|
||||
error,
|
||||
initial,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
open
|
||||
}: {
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
initial: { name: string; query: string } | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: { name: string; query: string }) => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
// Parent remounts this via `key` on each open, so `initial` seeds fresh state.
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [query, setQuery] = useState(initial?.query ?? '');
|
||||
const [preview, setPreview] = useState<{ count: number; sample: LibraryBrowseItem[] } | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
const runPreview = async () => {
|
||||
const trimmed = query.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewing(true);
|
||||
setPreviewError(null);
|
||||
|
||||
try {
|
||||
const result = await getLibraryBrowseItems({ pageSize: 24, query: trimmed });
|
||||
setPreview({ count: result.totalCount ?? result.page?.length ?? 0, sample: result.page ?? [] });
|
||||
} catch (error) {
|
||||
setPreviewError(messageFromCollectionError(error, 'Unable to preview query'));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || trimmedName.length === 0 || trimmedQuery.length === 0}
|
||||
loading={busy}
|
||||
onClick={() => onSubmit({ name: trimmedName, query: trimmedQuery })}
|
||||
variant="primary"
|
||||
>
|
||||
{initial ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onCancel}
|
||||
open={open}
|
||||
title={initial ? 'Edit smart collection' : 'New smart collection'}
|
||||
width={560}
|
||||
>
|
||||
<Input label="Name" onChange={(event) => setName(event.target.value)} placeholder="Smart collection name" value={name} />
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Input
|
||||
label="Search query"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder='e.g. genre:"action" AND released:2000-2010'
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<Button
|
||||
disabled={previewing || trimmedQuery.length === 0}
|
||||
loading={previewing}
|
||||
onClick={() => void runPreview()}
|
||||
size="sm"
|
||||
startIcon={<Search aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Preview results
|
||||
</Button>
|
||||
</div>
|
||||
{previewError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{previewError}
|
||||
</span>
|
||||
)}
|
||||
{preview && (
|
||||
<div className="ctv-collections-preview">
|
||||
<div className="ctv-collections-preview-head">
|
||||
<Badge tone="accent">{preview.count} matches</Badge>
|
||||
</div>
|
||||
<ul className="ctv-collections-preview-list">
|
||||
{preview.sample.slice(0, 12).map((item) => (
|
||||
<li key={`${item.mediaType}:${item.id}`}>
|
||||
<span className="ctv-collections-preview-title">{item.title}</span>
|
||||
<span className="ctv-collections-preview-type">{TYPE_LABEL[item.mediaType]}</span>
|
||||
</li>
|
||||
))}
|
||||
{preview.sample.length === 0 && <li className="ctv-collections-preview-empty">No matches</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- add-items picker ---------- */
|
||||
|
||||
function AddItemsDialog({
|
||||
collection,
|
||||
onAdded,
|
||||
onClose,
|
||||
open
|
||||
}: {
|
||||
collection: MediaCollection;
|
||||
onAdded: () => void;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<LibraryBrowseItem[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const runSearch = async () => {
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await getLibraryBrowseItems({ pageSize: 50, query: query.trim() });
|
||||
setResults((result.page ?? []).filter((item) => ADDABLE_TYPES.has(item.mediaType)));
|
||||
} catch (searchError) {
|
||||
setError(messageFromCollectionError(searchError, 'Unable to search library'));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (item: LibraryBrowseItem) => {
|
||||
const key = `${item.mediaType}:${item.id}`;
|
||||
setSelected((current) => {
|
||||
const next = new Map(current);
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, item);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (selected.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await addItemsToCollection(collection.id, toAddItemsRequest([...selected.values()]));
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (addError) {
|
||||
setError(messageFromCollectionError(addError, 'Unable to add items'));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={adding} onClick={onClose} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={adding || selected.size === 0}
|
||||
loading={adding}
|
||||
onClick={() => void submit()}
|
||||
startIcon={<Plus aria-hidden="true" size={14} />}
|
||||
variant="primary"
|
||||
>
|
||||
Add {selected.size > 0 ? selected.size : ''} item{selected.size === 1 ? '' : 's'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
title={`Add items to ${collection.name ?? 'collection'}`}
|
||||
width={620}
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void runSearch();
|
||||
}}
|
||||
style={{ display: 'flex', gap: 8 }}
|
||||
>
|
||||
<Input
|
||||
leadingIcon={<Search aria-hidden="true" size={14} />}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search movies, shows, seasons, artists…"
|
||||
value={query}
|
||||
/>
|
||||
<Button loading={searching} size="sm" type="submit" variant="secondary">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<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.
|
||||
</p>
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
<div className="ctv-collections-picker-results">
|
||||
{results.length === 0 && !searching ? (
|
||||
<div className="ctv-collections-picker-empty">No results — try a search above.</div>
|
||||
) : (
|
||||
results.map((item) => {
|
||||
const key = `${item.mediaType}:${item.id}`;
|
||||
const isSelected = selected.has(key);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isSelected}
|
||||
className={`ctv-collections-picker-row ctv-press${isSelected ? ' ctv-collections-picker-row-active' : ''}`}
|
||||
key={key}
|
||||
onClick={() => toggle(item)}
|
||||
type="button"
|
||||
>
|
||||
<span className="ctv-collections-picker-check">
|
||||
{isSelected ? <Check aria-hidden="true" size={14} /> : null}
|
||||
</span>
|
||||
<span className="ctv-collections-picker-row-title">{item.title}</span>
|
||||
<Badge tone="neutral">{TYPE_LABEL[item.mediaType]}</Badge>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- manual collection items view ---------- */
|
||||
|
||||
function ManualItemsView({
|
||||
collection,
|
||||
onBack
|
||||
}: {
|
||||
collection: MediaCollection;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<LibraryBrowseItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [removing, setRemoving] = useState<number | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
// No synchronous setState here: `loading` starts true and flips false in `finally`, so
|
||||
// this is safe to call from an effect. Reloads (after add/remove) keep the list visible.
|
||||
const load = useCallback(() => {
|
||||
getCollectionItemsPreview(collection.name ?? '')
|
||||
.then((preview) => {
|
||||
if (activeRef.current) {
|
||||
setItems(preview);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setError(messageFromCollectionError(loadError, 'Unable to load collection items'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, [collection.name]);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
load();
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const remove = async (item: LibraryBrowseItem) => {
|
||||
const mediaItemId = item.mediaItemId ?? item.id;
|
||||
setRemoving(mediaItemId);
|
||||
|
||||
try {
|
||||
await removeItemFromCollection(collection.id, mediaItemId);
|
||||
load();
|
||||
} catch (removeError) {
|
||||
setError(messageFromCollectionError(removeError, 'Unable to remove item'));
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
<Button onClick={onBack} size="sm" startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">
|
||||
All collections
|
||||
</Button>
|
||||
<span className="ctv-collections-detail-title">{collection.name}</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button onClick={() => setPickerOpen(true)} size="sm" startIcon={<Plus aria-hidden="true" size={14} />}>
|
||||
Add items
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ctv-settings-warn-callout" role="note">
|
||||
<Info aria-hidden="true" color="var(--status-warn)" size={14} />
|
||||
<span>
|
||||
The API has no endpoint to list a manual collection's items. This is a best-effort search preview covering
|
||||
movies, shows, seasons and artists only — other kinds in this collection won't appear. Adding items works for
|
||||
all shown kinds.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card padded={false}>
|
||||
{loading ? (
|
||||
<div className="ctv-collections-loading">
|
||||
<Spinner size={18} />
|
||||
<span>Loading items…</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No previewable items in this collection.</div>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
className="ctv-settings-flush-row"
|
||||
key={`${item.mediaType}:${item.id}`}
|
||||
style={index === 0 ? { borderTop: 'none' } : undefined}
|
||||
>
|
||||
<ListVideo aria-hidden="true" color="var(--text-disabled)" size={15} />
|
||||
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.title}</span>
|
||||
<Badge tone="neutral">{TYPE_LABEL[item.mediaType]}</Badge>
|
||||
<IconButton
|
||||
onClick={() => void remove(item)}
|
||||
size="sm"
|
||||
title={`Remove ${item.title}`}
|
||||
variant="ghost"
|
||||
>
|
||||
{removing === (item.mediaItemId ?? item.id) ? (
|
||||
<Spinner size={14} />
|
||||
) : (
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AddItemsDialog
|
||||
collection={collection}
|
||||
key={`add-${pickerOpen}`}
|
||||
onAdded={load}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
open={pickerOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- screen ---------- */
|
||||
|
||||
export function CollectionsScreen() {
|
||||
const { refresh, state } = useCollectionsData();
|
||||
const [tab, setTab] = useState<Tab>('manual');
|
||||
const [selected, setSelected] = useState<MediaCollection | null>(null);
|
||||
|
||||
// dialogs
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<MediaCollection | null>(null);
|
||||
const [smartTarget, setSmartTarget] = useState<{ collection: SmartCollection | null; open: boolean }>({
|
||||
collection: null,
|
||||
open: false
|
||||
});
|
||||
const [deleteTarget, setDeleteTarget] = useState<
|
||||
{ kind: 'manual'; item: MediaCollection } | { kind: 'smart'; item: SmartCollection } | null
|
||||
>(null);
|
||||
|
||||
const [dialogBusy, setDialogBusy] = useState(false);
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
const [rowError, setRowError] = useState<string | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return (
|
||||
<div className="ctv-collections-loading" role="status">
|
||||
<Spinner size={18} />
|
||||
<span>Loading collections…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{state.error}</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button onClick={() => refresh()} size="sm" variant="secondary">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
// Keep the selected reference fresh across refreshes (e.g. after a rename elsewhere).
|
||||
const current = state.data.manual.find((collection) => collection.id === selected.id) ?? selected;
|
||||
return <ManualItemsView collection={current} onBack={() => setSelected(null)} />;
|
||||
}
|
||||
|
||||
const manual = sortByName(state.data.manual);
|
||||
const smart = sortByName(state.data.smart);
|
||||
|
||||
const runDialog = async (operation: () => Promise<void>, onSuccess: () => void) => {
|
||||
setDialogBusy(true);
|
||||
setDialogError(null);
|
||||
|
||||
try {
|
||||
await operation();
|
||||
onSuccess();
|
||||
refresh(true);
|
||||
} catch (error) {
|
||||
setDialogError(messageFromCollectionError(error, 'Operation failed'));
|
||||
} finally {
|
||||
setDialogBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCustomOrder = async (collection: MediaCollection, next: boolean) => {
|
||||
setRowError(null);
|
||||
setTogglingId(collection.id);
|
||||
|
||||
try {
|
||||
await updateCollection(collection.id, { name: collection.name, useCustomPlaybackOrder: next });
|
||||
refresh(true);
|
||||
} catch (error) {
|
||||
setRowError(messageFromCollectionError(error, 'Unable to update collection'));
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = deleteTarget;
|
||||
void runDialog(
|
||||
() =>
|
||||
target.kind === 'manual'
|
||||
? deleteCollection(target.item.id)
|
||||
: deleteSmartCollection(target.item.id),
|
||||
() => setDeleteTarget(null)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
<div aria-label="Collection type" className="ctv-segmented" role="group">
|
||||
<button aria-pressed={tab === 'manual'} onClick={() => setTab('manual')} type="button">
|
||||
Manual <code>{manual.length}</code>
|
||||
</button>
|
||||
<button aria-pressed={tab === 'smart'} onClick={() => setTab('smart')} type="button">
|
||||
Smart <code>{smart.length}</code>
|
||||
</button>
|
||||
</div>
|
||||
<span className="ctv-channels-spacer" />
|
||||
{tab === 'manual' ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogError(null);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
startIcon={<Plus aria-hidden="true" size={14} />}
|
||||
>
|
||||
New collection
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogError(null);
|
||||
setSmartTarget({ collection: null, open: true });
|
||||
}}
|
||||
size="sm"
|
||||
startIcon={<Plus aria-hidden="true" size={14} />}
|
||||
>
|
||||
New smart collection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rowError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{rowError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'manual' && (
|
||||
<Card padded={false}>
|
||||
{manual.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No manual collections yet.</div>
|
||||
) : (
|
||||
manual.map((collection, index) => (
|
||||
<div
|
||||
className="ctv-settings-flush-row"
|
||||
key={collection.id}
|
||||
style={index === 0 ? { borderTop: 'none' } : undefined}
|
||||
>
|
||||
<FolderTree aria-hidden="true" color="var(--ctv-accent)" size={15} />
|
||||
<button
|
||||
className="ctv-settings-flush-row-main ctv-settings-flush-row-title ctv-collections-linkbtn"
|
||||
onClick={() => setSelected(collection)}
|
||||
type="button"
|
||||
>
|
||||
{collection.name}
|
||||
</button>
|
||||
<label className="ctv-collections-order-toggle" title="Use custom playback order">
|
||||
<Switch
|
||||
checked={collection.useCustomPlaybackOrder}
|
||||
disabled={togglingId === collection.id}
|
||||
onChange={(next) => void toggleCustomOrder(collection, next)}
|
||||
size="sm"
|
||||
/>
|
||||
<span>Custom order</span>
|
||||
</label>
|
||||
<IconButton onClick={() => setSelected(collection)} size="sm" title="Manage items" variant="ghost">
|
||||
<ListVideo aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setDialogError(null);
|
||||
setRenameTarget(collection);
|
||||
}}
|
||||
size="sm"
|
||||
title="Rename"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => setDeleteTarget({ item: collection, kind: 'manual' })}
|
||||
size="sm"
|
||||
title="Delete"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === 'smart' && (
|
||||
<Card padded={false}>
|
||||
{smart.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No smart collections yet.</div>
|
||||
) : (
|
||||
smart.map((collection, index) => (
|
||||
<div
|
||||
className="ctv-settings-flush-row"
|
||||
key={collection.id}
|
||||
style={index === 0 ? { borderTop: 'none' } : undefined}
|
||||
>
|
||||
<Sparkles aria-hidden="true" color="var(--ctv-accent)" size={15} />
|
||||
<div className="ctv-settings-flush-row-main">
|
||||
<div className="ctv-settings-flush-row-title">{collection.name}</div>
|
||||
<div className="ctv-collections-query">{collection.query}</div>
|
||||
</div>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setDialogError(null);
|
||||
setSmartTarget({ collection, open: true });
|
||||
}}
|
||||
size="sm"
|
||||
title="Edit"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => setDeleteTarget({ item: collection, kind: 'smart' })}
|
||||
size="sm"
|
||||
title="Delete"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ClassicNote />
|
||||
|
||||
{/* create manual */}
|
||||
<NameDialog
|
||||
busy={dialogBusy}
|
||||
confirmLabel="Create"
|
||||
error={createOpen ? dialogError : null}
|
||||
key={`create-${createOpen}`}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onSubmit={(name) => void runDialog(() => createCollection({ name }).then(() => undefined), () => setCreateOpen(false))}
|
||||
open={createOpen}
|
||||
title="New collection"
|
||||
/>
|
||||
|
||||
{/* rename manual */}
|
||||
<NameDialog
|
||||
busy={dialogBusy}
|
||||
confirmLabel="Save"
|
||||
error={renameTarget ? dialogError : null}
|
||||
initialName={renameTarget?.name ?? ''}
|
||||
key={`rename-${renameTarget?.id ?? 'none'}`}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
onSubmit={(name) =>
|
||||
renameTarget &&
|
||||
void runDialog(
|
||||
() =>
|
||||
updateCollection(renameTarget.id, {
|
||||
name,
|
||||
useCustomPlaybackOrder: renameTarget.useCustomPlaybackOrder
|
||||
}).then(() => undefined),
|
||||
() => setRenameTarget(null)
|
||||
)
|
||||
}
|
||||
open={renameTarget !== null}
|
||||
title="Rename collection"
|
||||
/>
|
||||
|
||||
{/* create / edit smart */}
|
||||
<SmartDialog
|
||||
busy={dialogBusy}
|
||||
error={dialogError}
|
||||
key={`smart-${smartTarget.open}-${smartTarget.collection?.id ?? 'new'}`}
|
||||
initial={
|
||||
smartTarget.collection
|
||||
? { name: smartTarget.collection.name ?? '', query: smartTarget.collection.query ?? '' }
|
||||
: null
|
||||
}
|
||||
onCancel={() => setSmartTarget({ collection: null, open: false })}
|
||||
onSubmit={(values) => {
|
||||
const editing = smartTarget.collection;
|
||||
void runDialog(
|
||||
() =>
|
||||
(editing
|
||||
? updateSmartCollection(editing.id, values)
|
||||
: createSmartCollection(values)
|
||||
).then(() => undefined),
|
||||
() => setSmartTarget({ collection: null, open: false })
|
||||
);
|
||||
}}
|
||||
open={smartTarget.open}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
busy={dialogBusy}
|
||||
confirmLabel="Delete"
|
||||
message={
|
||||
deleteTarget ? (
|
||||
<>
|
||||
<span>{`Delete "${deleteTarget.item.name ?? 'this collection'}"? This cannot be undone.`}</span>
|
||||
{dialogError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{dialogError}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
onCancel={() => {
|
||||
setDeleteTarget(null);
|
||||
setDialogError(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
open={deleteTarget !== null}
|
||||
title={deleteTarget?.kind === 'smart' ? 'Delete smart collection' : 'Delete collection'}
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicNote() {
|
||||
return (
|
||||
<div className="ctv-settings-callout">
|
||||
<Info aria-hidden="true" size={14} />
|
||||
<span>
|
||||
Multi-collections, rerun collections and playlists aren't available here yet — manage them in the Classic UI
|
||||
for now.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -854,6 +854,12 @@ function SystemPane({
|
||||
Open Dashboard
|
||||
</Button>
|
||||
</Row>
|
||||
<Row control={220} help="Collections, media browse/search, trakt, filler, watermarks, ffmpeg profiles, blocks/decos/templates, playout editors, logs and troubleshooting still live here." label="Classic UI">
|
||||
<a className="ctv-button ctv-button-secondary ctv-button-sm" href="/system/health">
|
||||
<span>Open Classic UI</span>
|
||||
<ExternalLink aria-hidden="true" size={13} />
|
||||
</a>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
|
||||
@@ -3428,3 +3428,168 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ---------- collections screen (#140) ---------- */
|
||||
.ctv-collections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5, 10px);
|
||||
}
|
||||
|
||||
.ctv-collections-loading,
|
||||
.ctv-collections-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4, 8px);
|
||||
padding: var(--space-8, 20px);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm, 13px);
|
||||
}
|
||||
|
||||
.ctv-collections-detail-title {
|
||||
font-size: var(--text-sm, 13px);
|
||||
font-weight: var(--weight-medium, 500);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctv-collections-linkbtn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-weight: var(--weight-medium, 500);
|
||||
}
|
||||
|
||||
.ctv-collections-linkbtn:hover {
|
||||
color: var(--ctv-accent);
|
||||
}
|
||||
|
||||
.ctv-collections-order-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 6px);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctv-collections-query {
|
||||
margin-top: 3px;
|
||||
font: var(--text-2xs) / 1.3 var(--font-mono);
|
||||
color: var(--text-disabled);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
/* smart-collection preview + picker */
|
||||
.ctv-collections-preview {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--ctv-bg-sunken);
|
||||
padding: var(--space-5, 10px);
|
||||
}
|
||||
|
||||
.ctv-collections-preview-head {
|
||||
margin-bottom: var(--space-4, 8px);
|
||||
}
|
||||
|
||||
.ctv-collections-preview-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ctv-collections-preview-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4, 8px);
|
||||
font-size: var(--text-xs, 12px);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-collections-preview-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctv-collections-preview-type,
|
||||
.ctv-collections-preview-empty {
|
||||
font: var(--text-2xs) / 1 var(--font-mono);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-note {
|
||||
margin: 10px 0 0;
|
||||
font: var(--text-2xs) / 1.4 var(--font-sans);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-results {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ctv-collections-picker-empty {
|
||||
padding: var(--space-7, 16px);
|
||||
text-align: center;
|
||||
color: var(--text-disabled);
|
||||
font-size: var(--text-xs, 12px);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4, 8px);
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--surface-card);
|
||||
padding: var(--space-4, 8px) var(--space-5, 10px);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ctv-collections-picker-row-active {
|
||||
background: color-mix(in srgb, var(--action-primary) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--action-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-row-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm, 13px);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--action-primary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user