Compare commits

...
Author SHA1 Message Date
timothyandClaude Fable 5 5995707c84 feat(web): collections management screen for manual + smart collections (#140)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Replaces the PlaceholderScreen at /app/collections with a real screen that
covers manual and smart collections, matching the SPA's extracted-screen
pattern (screens/CollectionsScreen.tsx; App.tsx gets only the route branch +
import).

Manual collections: list, create, rename, delete, and a per-row toggle for
UseCustomPlaybackOrder (PUT). Item management: an add-items picker that searches
the library (library/browse) and buckets results into the typed
AddItemsToCollectionRequest, plus per-item remove.

Smart collections: list, create, edit (name + query), delete, with a live
result preview that runs the query through library/browse.

API layer: new api/collections.ts owns the typed CRUD (getCollections/
getSmartCollections moved here from schedules.ts; schedules imports them). Unit
tests cover the client, the add-items bucket mapping, and the screen.

Honest gap: no API endpoint lists a manual collection's items by id. The items
view uses a best-effort collection:"name" Lucene search (movies/shows/seasons/
artists only) behind a prominent note. Multi/rerun/playlist collections are out
of scope (no API) and pointed to the Classic UI. Follow-ups #151/#152/#153.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:44:51 +02:00
timothy fcdecd16c0 Merge pull request 'feat(web): dedicated channel edit screen (#146)' (#154) from feat/146-channel-edit into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m50s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m52s
2026-07-07 10:40:17 +00:00
timothy 2b09bffab8 Merge pull request 'feat(web): Classic UI link in Settings + ChicoryTV cue on redirecting Blazor nav links (#147)' (#150) from feat/147-classic-ui-link into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-07 10:40:09 +00:00
timothyandClaude Fable 5 1f3385a2ff fix(web): review fixes — EPG switch gating, edit route key, stronger PUT assertion (#146)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:32:29 +02:00
timothyandClaude Fable 5 c21827667a feat(web): dedicated channel edit screen for SPA parity (#146)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Add a full ChannelEditScreen so the SPA can edit an existing channel
(the list/builder could only create). Mirrors the Blazor ChannelEditor
field set via hydrate-then-PUT against GET/PUT /api/channels/{id}.

- api/channels.ts: getChannelById + updateChannel (typed from generated
  ChannelViewModel / UpdateChannelRequest).
- screens/ChannelEditScreen.tsx: section-rail form (General, Playout,
  Streaming, Stream selection, Music video, Branding) following the
  SettingsScreen draft/saved dirtiness + floating save bar pattern. Logo
  upload reuses uploadArtwork; option data via existing pickers clients.
- App.tsx: /app/edit-channel/{id} route (allowSubPaths, screen parses the
  id); repoint the channel-table edit pencil at it.
- Tests: API-client GET/PUT + screen hydrate/edit-save/404 coverage.

Deferred per scope: ExternalLogoUrl (backend contract gap), and the
/channels/numbers bulk renumber (existing Renumber toolbar = parity).
Language codes / stream selector / credits template are free-text inputs
(no enumeration endpoints exist); mirror-source list can't filter to
Generated-only (ChannelResponseModel omits playoutSource).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:22:30 +02:00
timothyandClaude Fable 5 39455a96ed feat(web): Classic UI link in Settings → System (#147)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Add a discoverable "Open Classic UI" link in the SPA's Settings → System
About card, pointing at /system/health (a real full-page navigation, not
the SPA's pushState router), so un-migrated Blazor-only admin screens
stay reachable now that root / redirects to the SPA.

Also add a lightweight "Opens in ChicoryTV" tooltip cue (small icon) on
the Blazor sidebar's redirecting MudNavLink entries (per
LegacyUiRedirects.Map), so it's clear before clicking which sidebar
entries leave the Blazor UI vs. stay in it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:11:11 +02:00
timothyandClaude Fable 5 2362850559 docs: post-cutover housekeeping — dep PRs #61/#132 merged, #21/#48/#49/#131 closed, #76 rebased, prod v26.5.0 verified; next prompt = parity #147/#146/#140
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:01:07 +02:00
timothy 7e5a6b75ce Merge pull request 'chore(deps): update dependency entityframeworkprofiler.appender to 6.0.6053' (#132) from renovate/entityframeworkprofiler.appender-6.x into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-07 09:58:02 +00:00
timothy fbb659b19f Merge pull request 'chore(deps): apply open Renovate patch updates' (#61) from issue22-renovate-dashboard into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-07 09:57:24 +00:00
renovate 7319e31fa1 chore(deps): update dependency entityframeworkprofiler.appender to 6.0.6053
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:44:15 +02:00
timothy d3f71faf66 chore(deps): apply open Renovate patch updates refs #22
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:42:56 +02:00
18 changed files with 3061 additions and 278 deletions
+4 -4
View File
@@ -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" />
+3
View File
@@ -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>
+20 -10
View File
@@ -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";
+94 -253
View File
@@ -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.
+9
View File
@@ -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
View File
@@ -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} />;
}
+92
View File
@@ -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' });
});
});
+13
View File
@@ -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(),
+223
View File
@@ -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([]);
});
});
+145
View File
@@ -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;
}
+1
View File
@@ -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 -10
View File
@@ -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,
+161
View File
@@ -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();
});
});
+903
View File
@@ -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>
);
}
+268
View File
@@ -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"');
});
});
+926
View File
@@ -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>
);
}
+6
View File
@@ -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>
);
+165
View File
@@ -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);
}